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
|
// 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.
|
// 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 mode = process.argv[2];
|
||||||
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
|
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
|
||||||
const dashboardOrigin = process.env.SMOKE_DASHBOARD_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() {
|
async function storageDeliveryCheck() {
|
||||||
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage
|
// 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.
|
// 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",
|
"a delivered object is Cache-Control: private",
|
||||||
);
|
);
|
||||||
await provider.delete(bucket, "smoke/object.txt");
|
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 {
|
} finally {
|
||||||
await provider.close();
|
await provider.close();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user