fix(deploy): reach storage over HTTP/1.1, and own storage CORS in the proxy #58
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user