diff --git a/scripts/lib/deploy-smoke-client.mjs b/scripts/lib/deploy-smoke-client.mjs index 4ec349f..4157d6e 100644 --- a/scripts/lib/deploy-smoke-client.mjs +++ b/scripts/lib/deploy-smoke-client.mjs @@ -14,6 +14,8 @@ // Output is one line per check; any failed check exits non-zero. Nothing printed carries a // credential, cookie, signed URL, or response body beyond the fixed shapes asserted here. +import { connect as connectHttp2 } from "node:http2"; + const mode = process.argv[2]; const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`; const dashboardOrigin = process.env.SMOKE_DASHBOARD_HOSTNAME @@ -192,6 +194,46 @@ async function splitCorsChecks() { ); } +/** + * One GET over a real HTTP/2 connection. Node's fetch speaks HTTP/1.1, and the failure this guards + * against exists only on HTTP/2: a reset stream, which a browser reports as ERR_HTTP2_PROTOCOL_ERROR. + */ +function http2Get(url, headers = {}) { + return new Promise((resolve) => { + const session = connectHttp2(url.origin); + const finish = (result) => { + clearTimeout(timer); + session.close(); + resolve(result); + }; + const timer = setTimeout(() => finish({ error: "timeout" }), 10_000); + session.on("error", () => finish({ error: "session" })); + const stream = session.request({ + ":method": "GET", + ":path": `${url.pathname}${url.search}`, + ...headers, + }); + let status; + let responseHeaders = {}; + let bytes = 0; + stream.on("response", (received) => { + status = received[":status"]; + responseHeaders = received; + }); + stream.on("data", (chunk) => { + bytes += chunk.length; + }); + stream.on("error", (error) => finish({ error: error.code ?? "stream" })); + stream.on("end", () => finish({ bytes, headers: responseHeaders, status })); + stream.end(); + }); +} + +/** The CORS headers of a response, by name: what a browser would act on. */ +function corsOf(response) { + return [...response.headers.keys()].filter((name) => name.startsWith("access-control-")); +} + async function storageDeliveryCheck() { // Core's own provider, from the image, exactly as the API composes it: it reaches object-storage // over HTTPS on the private network and signs read URLs for the storage hostname. @@ -238,6 +280,79 @@ async function storageDeliveryCheck() { "a delivered object is Cache-Control: private", ); await provider.delete(bucket, "smoke/object.txt"); + + // The same image, twice, over HTTP/2: a browser's second load of a signed URL it has cached is a + // conditional request, and must come back as a clean 304, not a reset stream. + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ); + await provider.put(bucket, "smoke/image.png", png, { + contentLength: png.byteLength, + contentType: "image/png", + }); + const image = new URL( + (await provider.createReadUrl(bucket, "smoke/image.png", { expiresInSeconds: 300 })).url, + ); + const first = await http2Get(image); + check( + first.status === 200 && first.bytes === png.byteLength && Boolean(first.headers?.etag), + "an image loads over HTTP/2 through the proxy, with a validator (200, ETag)", + ); + const revalidated = await http2Get(image, { "if-none-match": first.headers?.etag ?? "" }); + check( + revalidated.status === 304 && revalidated.error === undefined, + `the same image loaded again over HTTP/2 revalidates cleanly (${revalidated.status ?? revalidated.error})`, + ); + const sinceModified = await http2Get(image, { + "if-modified-since": first.headers?.["last-modified"] ?? "", + }); + check( + sinceModified.status === 304 && sinceModified.error === undefined, + `a revalidation by date over HTTP/2 is a clean 304 too (${sinceModified.status ?? sinceModified.error})`, + ); + + // Delivery-layer CORS is the deployment's own, set by the proxy: exactly the origins whose pages + // read objects with fetch(), GET and HEAD only, and nothing of the object store's own answer. + const fromApi = await fetch(image, { headers: { origin: apiOrigin } }); + check( + fromApi.headers.get("access-control-allow-origin") === apiOrigin && + (fromApi.headers.get("vary") ?? "").toLowerCase().includes("origin") && + corsOf(fromApi).length === 1, + "a read from the API origin is allowed for exactly that origin, with Vary: Origin and nothing else", + ); + if (split) { + const fromDashboard = await fetch(image, { headers: { origin: dashboardOrigin } }); + check( + fromDashboard.headers.get("access-control-allow-origin") === dashboardOrigin, + "a read from the Dashboard origin is allowed for exactly that origin", + ); + } + const fromElsewhere = await fetch(image, { headers: { origin: untrustedOrigin } }); + check( + fromElsewhere.status === 200 && corsOf(fromElsewhere).length === 0, + "a read from any other origin gets no CORS header at all", + ); + const preflight = await fetch(image, { + headers: { "access-control-request-method": "GET", origin: apiOrigin }, + method: "OPTIONS", + }); + check( + preflight.status === 204 && + preflight.headers.get("access-control-allow-origin") === apiOrigin && + preflight.headers.get("access-control-allow-methods") === "GET, HEAD" && + preflight.headers.get("x-amz-request-id") === null, + "the proxy answers a preflight itself (204, GET and HEAD only), never the object store", + ); + const foreignPreflight = await fetch(image, { + headers: { "access-control-request-method": "GET", origin: untrustedOrigin }, + method: "OPTIONS", + }); + check( + foreignPreflight.status === 204 && corsOf(foreignPreflight).length === 0, + "a preflight from any other origin is answered without permission", + ); + await provider.delete(bucket, "smoke/image.png"); } finally { await provider.close(); }