Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions src/commands/sdk/preview-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import * as http from "node:http";
import * as https from "node:https";
import * as net from "node:net";
Expand All @@ -6,6 +7,9 @@ import * as tls from "node:tls";
import { FatalError, logger } from "../../core/utils/logger";
import { PreviewCertificate } from "./preview-certificate";
import { allowInlineScripts, inlineScriptHashes } from "./preview-inline-scripts";
import { PREVIEW_ROUTE_OBSERVER } from "./preview-route-observer";

const ROUTE_OBSERVER_HASH = `'sha256-${createHash("sha256").update(PREVIEW_ROUTE_OBSERVER).digest("base64")}'`;

export interface PreviewProxyOptions {
target: URL;
Expand Down Expand Up @@ -90,24 +94,35 @@ export function startPreviewProxy(options: PreviewProxyOptions): Promise<Preview
if (res.destroyed) {
return;
}
const body = Buffer.concat(chunks);
const scripts = inlineScriptHashes(body.toString("utf8"));
const html = Buffer.concat(chunks).toString("utf8");
const scripts = inlineScriptHashes(html);
for (const { hash, excerpt } of scripts) {
if (!warnedInlineScripts.has(hash)) {
warnedInlineScripts.add(hash);
logger.warn(inlineScriptWarning(req.url, excerpt, allowInline));
}
}
if (proxyRes.headers["content-length"] !== undefined) {
responseHeaders["content-length"] = body.length;
}
const bootstrap = `<script>${PREVIEW_ROUTE_OBSERVER}</script>`;
const body = Buffer.from(
/<head(?:\s|>)/i.test(html)
? html.replace(/<head([^>]*)>/i, `<head$1>${bootstrap}`)
: bootstrap + html
);
responseHeaders["content-length"] = body.length;
delete responseHeaders["transfer-encoding"];
delete responseHeaders.etag;
const csp = headers["content-security-policy"];
res.writeHead(proxyRes.statusCode, {
...responseHeaders,
...headers,
...(csp === undefined || !allowInline ? {} : {
"content-security-policy": allowInlineScripts(csp, scripts.map(script => script.hash)),
}),
...(csp === undefined
? {}
: {
"content-security-policy": allowInlineScripts(csp, [
ROUTE_OBSERVER_HASH,
...(allowInline ? scripts.map(script => script.hash) : []),
]),
}),
});
res.end(body);
});
Expand Down
31 changes: 31 additions & 0 deletions src/commands/sdk/preview-route-observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Keep in sync with ems-frontend fragments/sandbox route-observer.ts.
export const PREVIEW_ROUTE_OBSERVER = `(() => {
const delivery = document.getElementById('fragment-delivery');
const base = delivery ? JSON.parse(delivery.textContent).baseHref.slice(0, -1) : '';
let last;
let queued = false;
const report = (initial = false) => {
const path = location.pathname;
const url = (path === base ? '/' : path.startsWith(base + '/') ? path.slice(base.length) : path)
+ location.search + location.hash;
if (parent === window || url === last) return;
last = url;
parent.postMessage({type: 'ems-fragment-route-report-v1', url, initial}, '*');
};
const schedule = () => {
if (queued) return;
queued = true;
queueMicrotask(() => { queued = false; report(); });
};
for (const name of ['pushState', 'replaceState']) {
const original = history[name];
history[name] = function (...args) {
const result = Reflect.apply(original, this, args);
schedule();
return result;
};
}
addEventListener('popstate', schedule);
addEventListener('hashchange', schedule);
report(true);
})();`;
67 changes: 40 additions & 27 deletions tests/commands/sdk/preview-proxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@ import { Duplex } from "node:stream";
import { loadOrCreatePreviewCertificate, PreviewCertificate } from "../../../src/commands/sdk/preview-certificate";
import { LOCAL_PREVIEW_HEADERS } from "../../../src/commands/sdk/preview-headers";
import { PreviewProxy, PreviewProxyOptions, startPreviewProxy } from "../../../src/commands/sdk/preview-proxy";
import { PREVIEW_ROUTE_OBSERVER } from "../../../src/commands/sdk/preview-route-observer";
import { loggingTestTransport } from "../../jest.setup";
import { makeTempDir, rmTempDir } from "../../utls/fs-utils";

const observerHash = "'sha256-" + createHash("sha256").update(PREVIEW_ROUTE_OBSERVER).digest("base64") + "'";
const observerCsp = LOCAL_PREVIEW_HEADERS["content-security-policy"].replace(
"script-src 'self'",
"script-src 'self' " + observerHash
);
function withObserver(html: string): string {
const script = "<script>" + PREVIEW_ROUTE_OBSERVER + "</script>";
return html.includes("<head>") ? html.replace("<head>", "<head>" + script) : script + html;
}

interface ProxyResponse {
status: number;
headers: http.IncomingHttpHeaders;
Expand Down Expand Up @@ -143,16 +154,16 @@ describe("Preview proxy", () => {

const response = await send(proxy, "/");

expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]);
expect(response.headers["content-security-policy"]).toBe(observerCsp);
expect(response.headers["content-security-policy-report-only"]).toBeUndefined();
expect(response.headers["x-frame-options"]).toBeUndefined();
expect(response.headers["access-control-allow-origin"]).toBe("*");
expect(response.headers["content-type"]).toBe("text/html");
for (const [name, value] of Object.entries(LOCAL_PREVIEW_HEADERS)) {
expect(headerCount(response.rawHeaders, name)).toBe(1);
expect(response.headers[name]).toBe(value);
expect(response.headers[name]).toBe(name === "content-security-policy" ? observerCsp : value);
}
expect(response.body).toBe("<html></html>");
expect(response.body).toBe(withObserver("<html></html>"));
});

it("Should add the preview headers to responses without any headers of their own", async () => {
Expand Down Expand Up @@ -215,7 +226,7 @@ describe("Preview proxy", () => {

it("Should cancel the target request when the client disconnects", async () => {
let targetRequestClosed: () => void;
const closed = new Promise<void>(resolve => targetRequestClosed = resolve);
const closed = new Promise<void>(resolve => (targetRequestClosed = resolve));
let clientRequest: http.ClientRequest;
const target = await startTarget((req, res) => {
res.writeHead(200);
Expand All @@ -239,8 +250,8 @@ describe("Preview proxy", () => {
it("Should not report the target as unreachable when the client disconnects before it answers", async () => {
let requestReceived: () => void;
let targetRequestClosed: () => void;
const received = new Promise<void>(resolve => requestReceived = resolve);
const closed = new Promise<void>(resolve => targetRequestClosed = resolve);
const received = new Promise<void>(resolve => (requestReceived = resolve));
const closed = new Promise<void>(resolve => (targetRequestClosed = resolve));
const target = await startTarget((req, res) => {
res.on("close", () => targetRequestClosed());
requestReceived();
Expand Down Expand Up @@ -307,9 +318,10 @@ describe("Preview proxy", () => {
.map(entry => entry.message.trim());
}

const reactPreamble = "import { injectIntoGlobalHook } from \"/@react-refresh\";";
const reactPage = `<html><head><script type="module">${reactPreamble}</script>` +
"<script type=\"module\" src=\"/@vite/client\"></script></head><body>Grüße</body></html>";
const reactPreamble = 'import { injectIntoGlobalHook } from "/@react-refresh";';
const reactPage =
`<html><head><script type="module">${reactPreamble}</script>` +
'<script type="module" src="/@vite/client"></script></head><body>Grüße</body></html>';

function startHtmlTarget(html: string): Promise<http.Server> {
return startTarget((req, res) => {
Expand All @@ -328,13 +340,13 @@ describe("Preview proxy", () => {
const second = await send(proxy, "/index.html");

for (const response of [first, second]) {
expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]);
expect(response.body).toBe(reactPage);
expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(reactPage)));
expect(response.headers["content-security-policy"]).toBe(observerCsp);
expect(response.body).toBe(withObserver(reactPage));
expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(withObserver(reactPage))));
}
expect(warnings()).toEqual([
`Blocking an inline script in /index.html: "${reactPreamble}". Inline scripts are disabled for published apps. ` +
"You can allow inline scripts during development by passing --allow-inline-scripts.",
"You can allow inline scripts during development by passing --allow-inline-scripts.",
]);
});

Expand All @@ -347,16 +359,17 @@ describe("Preview proxy", () => {
const second = await send(proxy, "/index.html");

for (const response of [first, second]) {
expect(scriptSrc(response)).toEqual(["'self'", sha256(inline)]);
expect((response.headers["content-security-policy"] as string).replace(` ${sha256(inline)}`, ""))
.toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]);
expect(response.body).toBe(html);
expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(html)));
expect(scriptSrc(response)).toEqual(["'self'", observerHash, sha256(inline)]);
expect((response.headers["content-security-policy"] as string).replace(` ${sha256(inline)}`, "")).toBe(
observerCsp
);
expect(response.body).toBe(withObserver(html));
expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(withObserver(html))));
expect(response.headers["content-type"]).toBe("text/html; charset=utf-8");
}
expect(warnings()).toEqual([
`Allowing an inline script in /index.html for the local preview: "${inline}". ` +
"Inline scripts are disabled for published apps, so they can only be used during development.",
"Inline scripts are disabled for published apps, so they can only be used during development.",
]);
});

Expand All @@ -370,22 +383,22 @@ describe("Preview proxy", () => {

const response = await send(proxy, "/");

expect(scriptSrc(response)).toEqual(["'self'", sha256("a()")]);
expect(response.body).toBe("<html><head><script>a()</script></head></html>");
expect(response.headers["content-length"]).toBeUndefined();
expect(scriptSrc(response)).toEqual(["'self'", observerHash, sha256("a()")]);
expect(response.body).toBe(withObserver("<html><head><script>a()</script></head></html>"));
expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(response.body)));
});

it("Should leave the CSP of non-HTML responses unchanged", async () => {
const target = await startTarget((req, res) => {
res.writeHead(200, { "content-type": "text/javascript" });
res.end("document.write(\"<script>a()</script>\");");
res.end('document.write("<script>a()</script>");');
});
const proxy = await startProxy(targetUrl(target));

const response = await send(proxy, "/main.js");

expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]);
expect(response.body).toBe("document.write(\"<script>a()</script>\");");
expect(response.body).toBe('document.write("<script>a()</script>");');
expect(warnings()).toEqual([]);
});

Expand All @@ -396,7 +409,7 @@ describe("Preview proxy", () => {
res.end();
return;
}
res.writeHead(304, { "content-type": "text/html", etag: "\"v1\"" });
res.writeHead(304, { "content-type": "text/html", etag: '"v1"' });
res.end();
});
const proxy = await startProxy(targetUrl(target));
Expand All @@ -409,7 +422,7 @@ describe("Preview proxy", () => {
expect(head.body).toBe("");
expect(head.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]);
expect(notModified.status).toBe(304);
expect(notModified.headers.etag).toBe("\"v1\"");
expect(notModified.headers.etag).toBe('"v1"');
expect(notModified.body).toBe("");
});

Expand All @@ -431,7 +444,7 @@ describe("Preview proxy", () => {
const next = await send(proxy, "/");

expect(next.status).toBe(200);
expect(next.body).toBe("<html></html>");
expect(next.body).toBe(withObserver("<html></html>"));
expect(warnings()).toEqual([]);
});

Expand Down
Loading