From c87cf5f1acda37f964b06b1650cf23756292d1b8 Mon Sep 17 00:00:00 2001 From: Akanksha Trehun Date: Thu, 5 Nov 2026 23:58:46 +0530 Subject: [PATCH] fix(deploy): CORP, a narrow CSP, immutable asset caching and a deploy check Ported from the old deploy-hardening branch onto main: - every host sends Cross-Origin-Resource-Policy: same-origin and a CSP with no fetch directives (base-uri, object-src, frame-ancestors, form-action), so it can't block the FFmpeg cores or analytics; - /_next/static/* is cached for a year as immutable, declared rather than left to the Netlify Next.js plugin; - scripts/deploy/check-deploy.mjs checks a deployed or local site: headers, MIME types and caching on the page, a script, the font, the FFmpeg worker chunk, the engine WASM, the trailing-slash redirect and a 404, and with --expect-commit that the build is the right one (stamped in ); - deploy-smoke.yml runs it on every PR against a local build and against Netlify deploy previews; - the local test server sends the declared headers, caching and redirect. --- .github/workflows/deploy-smoke.yml | 85 +++++++++++++++ docs/DEPLOYMENT.md | 33 +++++- netlify.toml | 13 +++ public/_headers | 6 ++ scripts/deploy/check-deploy.mjs | 132 +++++++++++++++++++++++ scripts/deploy/lib/deployCheck.mjs | 126 ++++++++++++++++++++++ scripts/serve-isolated.mjs | 35 +++++-- src/app/layout.tsx | 5 + src/lib/tests/deployCheck.test.ts | 162 +++++++++++++++++++++++++++++ src/lib/tests/deployConfig.test.ts | 14 ++- vercel.json | 12 +++ vitest.setup.ts | 3 +- 12 files changed, 613 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/deploy-smoke.yml create mode 100644 scripts/deploy/check-deploy.mjs create mode 100644 scripts/deploy/lib/deployCheck.mjs create mode 100644 src/lib/tests/deployCheck.test.ts diff --git a/.github/workflows/deploy-smoke.yml b/.github/workflows/deploy-smoke.yml new file mode 100644 index 000000000..89edd624c --- /dev/null +++ b/.github/workflows/deploy-smoke.yml @@ -0,0 +1,85 @@ +name: ๐Ÿšฆ Deploy smoke test + +# Checks that Reframe is served with the headers it depends on: COOP/COEP for +# cross-origin isolation (multi-threaded FFmpeg) plus the security headers, +# correct MIME types and immutable caching for hashed assets. +# +# No secrets, no tokens, read-only permissions: +# * static-export builds the site and serves out/ locally with the headers +# declared in vercel.json (kept identical to netlify.toml and +# public/_headers by deployConfig.test.ts), then runs the deploy check. +# * deployed runs the same check against a real Netlify deploy preview once +# Netlify reports it ready, or against any URL given by hand. Note that +# GitHub only runs `status`-triggered workflows from the default branch, so +# this job starts working once this file is on main. + +on: + pull_request: + push: + branches: [main] + status: + workflow_dispatch: + inputs: + url: + description: "Deployed URL to check (https://โ€ฆ)" + required: true + +permissions: + contents: read + +jobs: + static-export: + if: github.event_name == 'pull_request' || github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + + - run: bun install --frozen-lockfile + + # No Datadog keys exist here, so RUM can't start in this build. + - run: bun run build + + - name: Serve out/ with the declared headers + run: | + node scripts/serve-isolated.mjs 4300 & + for _ in $(seq 1 30); do curl -sf -o /dev/null http://127.0.0.1:4300/ && exit 0; sleep 1; done + echo "server did not start" && exit 1 + + # GITHUB_SHA is the commit next.config.ts stamped into the build. + - name: Check headers, MIME types, caching and the build commit + run: node scripts/deploy/check-deploy.mjs http://127.0.0.1:4300/ --expect-commit "$GITHUB_SHA" + + deployed: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'status' && + github.event.state == 'success' && + startsWith(github.event.context, 'netlify/')) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + + - name: Check the deployed URL + env: + # Passed through the environment, never interpolated into the script. + URL: ${{ github.event.inputs.url || github.event.target_url }} + run: | + case "$URL" in + https://*) ;; + *) echo "Not an https URL; nothing to check."; exit 0 ;; + esac + set +e + node scripts/deploy/check-deploy.mjs "$URL" + code=$? + if [ "$code" = 3 ]; then + echo "::notice::The deployment requires authentication, so its headers were not checked." + exit 0 + fi + if [ "$code" = 4 ]; then + echo "::error::The deployment could not be reached, so nothing was checked." + fi + exit $code diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0e62b3d90..71685bb97 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -26,10 +26,39 @@ Netlify badge and brand logos are served from this site instead of hotlinked. Check a deployment with: ```bash -curl -sI https://your-site.example | grep -i cross-origin +node scripts/deploy/check-deploy.mjs https://your-site.example [--expect-commit ] ``` -and, in the browser console on the site, `crossOriginIsolated` should be `true`. +It requests the page, a hashed script, the preloaded font, the FFmpeg worker +chunk, the engine's WASM on jsDelivr, the trailing-slash redirect and a 404, +and checks the headers, MIME types and caching on each. With +`--expect-commit`, it also checks that the site serves that commit (each +build stamps it in ``), which catches a stale +deploy. It exits 0 when everything passes, 1 on a failure, 3 when the +deployment needs a login and 4 when it can't be reached. In the browser +console on the site, `crossOriginIsolated` should be `true`. + +`deploy-smoke.yml` runs this check on every pull request against a local +build, and against each Netlify deploy preview once Netlify reports it +ready. + +## The other headers + +Every host sends the same set (`netlify.toml`, `public/_headers` and +`vercel.json`, kept identical by `src/lib/tests/deployConfig.test.ts`): + +- `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, + `Referrer-Policy` and `Permissions-Policy` (no camera, microphone or + location). +- `Cross-Origin-Resource-Policy: same-origin`: other sites can't embed + Reframe's files. +- `Content-Security-Policy: base-uri 'self'; object-src 'none'; + frame-ancestors 'none'; form-action 'self'`. It has no fetch directives, + so it can't block the FFmpeg cores or analytics; it stops framing, + plugins, `` hijacking and off-site form posts. +- `Cache-Control: public, max-age=31536000, immutable` on `/_next/static/*`, + whose file names change with their content. Pages are revalidated on + every visit. ## Netlify (recommended) diff --git a/netlify.toml b/netlify.toml index 9018a7dc4..9f3cbc2d3 100644 --- a/netlify.toml +++ b/netlify.toml @@ -54,3 +54,16 @@ Strict-Transport-Security = "max-age=31536000; includeSubDomains" Cross-Origin-Opener-Policy = "same-origin" Cross-Origin-Embedder-Policy = "require-corp" + Cross-Origin-Resource-Policy = "same-origin" + # No fetch directives (script-src, connect-src โ€ฆ), so nothing the app + # loads (the FFmpeg cores, Datadog RUM) can be blocked; it only stops + # framing, plugins, hijacking and off-site form posts. + Content-Security-Policy = "base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'" + +# Hashed build assets never change, so browsers may keep them for a year. +# Declared here rather than relying on @netlify/plugin-nextjs, which was what +# set this before: a deploy without the plugin served them with max-age=0. +[[headers]] + for = "/_next/static/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" diff --git a/public/_headers b/public/_headers index 0462c40a6..d860646ed 100644 --- a/public/_headers +++ b/public/_headers @@ -21,3 +21,9 @@ Strict-Transport-Security: max-age=31536000; includeSubDomains Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp + Cross-Origin-Resource-Policy: same-origin + Content-Security-Policy: base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self' + +# Hashed build assets never change (same rule as netlify.toml). +/_next/static/* + Cache-Control: public, max-age=31536000, immutable diff --git a/scripts/deploy/check-deploy.mjs b/scripts/deploy/check-deploy.mjs new file mode 100644 index 000000000..b86d3cfe3 --- /dev/null +++ b/scripts/deploy/check-deploy.mjs @@ -0,0 +1,132 @@ +// Checks a deployed (or locally served) Reframe site with real HTTP requests: +// security and cross-origin-isolation headers, MIME types and caching on the +// HTML page, a hashed JS chunk, the preloaded font, the FFmpeg worker chunk +// and a 404; the trailing-slash redirect; CORS on the jsDelivr WASM the +// worker loads; and, with --expect-commit, that the build is the right one. +// Rules live in lib/deployCheck.mjs. +// +// Usage: node scripts/deploy/check-deploy.mjs [--expect-commit ] [--json] +// Exit codes: +// 0 pass ยท 1 fail (including a stale/wrong commit) ยท 2 usage error +// 3 protected: the deployment requires authentication, nothing was checked +// 4 unavailable: the site couldn't be reached (DNS, network, 5xx on the page) +// For an SSO-protected Vercel preview, set VERCEL_AUTOMATION_BYPASS_SECRET +// (Project โ†’ Settings โ†’ Deployment Protection โ†’ Protection Bypass for +// Automation). It is sent as a header and never printed. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + RESULT, + checkResponse, + commitCheck, + discoverFromHtml, + exitCode, + expectedHeaders, + isWorkerChunk, + lazyChunksFromRuntime, + overallResult, + wasmUrlFromWorker, +} from "./lib/deployCheck.mjs"; + +const args = process.argv.slice(2); +const json = args.includes("--json"); +const commitIdx = args.indexOf("--expect-commit"); +const expectCommit = commitIdx >= 0 ? args[commitIdx + 1] : null; +const target = args.find((a, i) => !a.startsWith("--") && (commitIdx < 0 || i !== commitIdx + 1)); + +let base; +try { + base = new URL(target); +} catch { + base = null; +} +if (!base || !/^https?:$/.test(base.protocol) || (commitIdx >= 0 && !expectCommit)) { + console.error("usage: node scripts/deploy/check-deploy.mjs [--expect-commit ] [--json]"); + process.exit(2); +} + +const vercelJson = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../vercel.json"); +const expected = expectedHeaders(fs.readFileSync(vercelJson, "utf8")); + +const requestHeaders = {}; +if (process.env.VERCEL_AUTOMATION_BYPASS_SECRET) { + requestHeaders["x-vercel-protection-bypass"] = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; +} + +const checks = []; +const log = (line) => { + if (!json) console.log(line); +}; + +async function request(url, method = "GET") { + try { + const res = await fetch(url, { method, headers: requestHeaders, redirect: "manual" }); + return { res, body: method === "GET" ? await res.text() : "" }; + } catch (err) { + return { error: err instanceof Error ? err.cause?.code ?? err.message : String(err) }; + } +} + +async function check(kind, url, method) { + const { res, body, error } = await request(url, method); + const outcome = error + ? { kind, url, status: null, result: RESULT.UNAVAILABLE, problems: [], notes: [`unreachable: ${error}`] } + : checkResponse({ kind, url, status: res.status, headers: res.headers, expected, location: res.headers.get("location") }); + checks.push(outcome); + const mark = { pass: "ok ", fail: "FAIL", protected: "AUTH", unavailable: "DOWN" }[outcome.result]; + log(`${mark} ${kind.padEnd(9)} ${outcome.status ?? "---"} ${url}`); + for (const p of outcome.problems) log(` โœ— ${p}`); + for (const n of outcome.notes) log(` ยท ${n}`); + return { outcome, body }; +} + +const page = await check("document", base.href); +let commit = null; +if (page.outcome.result !== RESULT.PROTECTED && page.outcome.result !== RESULT.UNAVAILABLE && page.outcome.status === 200) { + const found = discoverFromHtml(page.body); + commit = commitCheck(found.commit, expectCommit); + log(`${commit.result === RESULT.PASS ? "ok " : "FAIL"} commit ${commit.note}`); + + if (found.script) await check("script", new URL(found.script, base).href); + if (found.font) await check("font", new URL(found.font, base).href); + else log(" font (no preloaded font in the page; skipped)"); + + let workerJs = null; + if (found.runtime) { + const runtime = await request(new URL(found.runtime, base).href); + for (const chunk of lazyChunksFromRuntime(runtime.body ?? "").slice(0, 40)) { + const probe = await request(new URL(chunk, base).href); + if (probe.body && isWorkerChunk(probe.body)) { + workerJs = (await check("worker", new URL(chunk, base).href)).body; + break; + } + } + } + if (!workerJs) { + checks.push({ kind: "worker", result: RESULT.FAIL, problems: ["FFmpeg worker chunk not found via the webpack runtime"], notes: [] }); + log("FAIL worker FFmpeg worker chunk not found via the webpack runtime"); + } else { + const wasm = wasmUrlFromWorker(workerJs); + if (wasm) await check("wasm", wasm, "HEAD"); + } + + await check("redirect", new URL("/privacy/", base).href); + await check("notFound", new URL("/__reframe-deploy-check-404", base).href); +} + +const results = checks.map((c) => c.result); +if (commit) results.push(commit.result); +if (page.outcome.status && page.outcome.status >= 500) results.push(RESULT.UNAVAILABLE); +const overall = overallResult(results); + +const summary = { + pass: "All checks passed.", + fail: "Some checks failed.", + protected: "Not checked: the deployment requires authentication (set VERCEL_AUTOMATION_BYPASS_SECRET for Vercel previews).", + unavailable: "Not checked: the deployment could not be reached.", +}[overall]; + +if (json) console.log(JSON.stringify({ url: base.href, result: overall, commit, checks }, null, 2)); +else console.log(`\n${summary}`); +process.exit(exitCode(overall)); diff --git a/scripts/deploy/lib/deployCheck.mjs b/scripts/deploy/lib/deployCheck.mjs new file mode 100644 index 000000000..f0497229c --- /dev/null +++ b/scripts/deploy/lib/deployCheck.mjs @@ -0,0 +1,126 @@ +// Rules for checking a deployed Reframe site. Pure functions, so they are +// unit-tested (src/lib/tests/deployCheck.test.ts); check-deploy.mjs does the +// fetching. Expected security headers come from vercel.json, which +// deployConfig.test.ts keeps identical to netlify.toml and public/_headers. + +export const RESULT = { PASS: "pass", FAIL: "fail", PROTECTED: "protected", UNAVAILABLE: "unavailable" }; + +const EXIT = { pass: 0, fail: 1, protected: 3, unavailable: 4 }; +export const exitCode = (result) => EXIT[result] ?? 1; + +/** The catch-all header rule from vercel.json's text. */ +export function expectedHeaders(vercelJsonText) { + const rule = JSON.parse(vercelJsonText).headers.find((h) => h.source === "/(.*)"); + if (!rule) throw new Error("vercel.json has no catch-all header rule"); + return rule.headers; +} + +const IMMUTABLE = (cache) => /immutable/.test(cache) && /max-age=31536000/.test(cache); +const REVALIDATE = (cache) => /max-age=0|no-cache/.test(cache); + +/** + * What each kind of response must look like. `security` = carries the site's + * security headers (everything served from our origin does; the CDN's WASM + * and bare redirects don't need to). + */ +export const KINDS = { + document: { status: [200], type: /^text\/html/i, cache: REVALIDATE, cacheLabel: "revalidated", security: true }, + script: { status: [200], type: /javascript/i, cache: IMMUTABLE, cacheLabel: "immutable", security: true }, + font: { status: [200], type: /^font\/woff2/i, cache: IMMUTABLE, cacheLabel: "immutable", security: true }, + worker: { status: [200], type: /javascript/i, cache: IMMUTABLE, cacheLabel: "immutable", security: true }, + notFound: { status: [404], type: /^text\/html/i, security: true }, + redirect: { status: [200, 301, 302, 307, 308], security: false }, + wasm: { status: [200], type: /^application\/wasm/i, security: false, cors: true }, +}; + +const lower = (headers) => + headers instanceof Map || typeof headers?.get === "function" + ? (k) => headers.get(k) + : (k) => headers[k.toLowerCase()] ?? headers[k] ?? null; + +/** + * Evaluates one response. `headers` is a Headers object or a plain object. + * Returns { kind, url, status, result, problems: string[], notes: string[] }. + */ +export function checkResponse({ kind, url, status, headers, expected, location }) { + const rule = KINDS[kind]; + const get = lower(headers); + const problems = []; + const notes = []; + + const authRedirect = status >= 300 && status < 400 && kind !== "redirect"; + if (status === 401 || status === 403 || authRedirect) { + return { kind, url, status, result: RESULT.PROTECTED, problems: [], notes: [`needs authentication (${status}${location ? ` โ†’ ${location.split("?")[0]}` : ""})`] }; + } + if (!rule.status.includes(status)) problems.push(`status ${status} (expected ${rule.status.join("/")})`); + + if (rule.security) { + for (const { key, value } of expected) { + const actual = get(key); + if (actual !== value) problems.push(`${key}: ${actual ?? "(missing)"} (expected ${value})`); + } + } + const type = get("content-type") ?? ""; + if (rule.type && !rule.type.test(type)) problems.push(`Content-Type: ${type || "(missing)"}`); + const cache = get("cache-control") ?? ""; + if (rule.cache && !rule.cache(cache)) problems.push(`Cache-Control: ${cache || "(missing)"} (expected ${rule.cacheLabel})`); + if (rule.cors) { + const acao = get("access-control-allow-origin"); + if (acao !== "*") problems.push(`Access-Control-Allow-Origin: ${acao ?? "(missing)"} (COEP needs CORS here)`); + } + if (kind === "redirect" && status >= 300 && status < 400) { + if (!location || !/\/privacy$/.test(location.split("?")[0])) problems.push(`redirects to ${location ?? "(nowhere)"}`); + else notes.push(`${status} โ†’ ${location}`); + } + if (kind === "document") notes.push(`Strict-Transport-Security: ${get("strict-transport-security") ?? "(host default not sent)"}`); + + return { kind, url, status, result: problems.length ? RESULT.FAIL : RESULT.PASS, problems, notes }; +} + +/** fail beats unavailable beats protected beats pass. */ +export function overallResult(results) { + const seen = new Set(results); + for (const r of [RESULT.FAIL, RESULT.UNAVAILABLE, RESULT.PROTECTED]) if (seen.has(r)) return r; + return RESULT.PASS; +} + +/** Same-origin resources referenced by the exported HTML. */ +export function discoverFromHtml(html) { + return { + script: html.match(/\/_next\/static\/chunks\/[^"']+\.js/)?.[0] ?? null, + runtime: html.match(/\/_next\/static\/chunks\/webpack-[^"']+\.js/)?.[0] ?? null, + font: html.match(/\/_next\/static\/media\/[^"']+\.woff2/)?.[0] ?? null, + commit: html.match(/]+name="reframe-build"[^>]+content="([^"]+)"/)?.[1] ?? null, + }; +} + +/** Lazy chunk URLs from webpack's runtime (`id:"hash"` โ†’ /_next/static/chunks/id.hash.js). */ +export function lazyChunksFromRuntime(runtimeJs) { + const urls = new Set(); + for (const m of runtimeJs.matchAll(/(\d+):"([0-9a-f]{16})"/g)) urls.add(`/_next/static/chunks/${m[1]}.${m[2]}.js`); + return [...urls]; +} + +/** The FFmpeg worker chunk is the one that names the jsDelivr cores. */ +export const isWorkerChunk = (js) => js.includes("cdn.jsdelivr.net/npm/@ffmpeg/core"); + +export function wasmUrlFromWorker(js) { + const base = js.match(/https:\/\/cdn\.jsdelivr\.net\/npm\/@ffmpeg\/core-mt@[\d.]+\/dist\/umd/)?.[0]; + return base ? `${base}/ffmpeg-core.wasm` : null; +} + +/** + * Compares the deployed build's commit with the one expected. A prefix match + * of at least 7 characters counts; "+dirty" builds never match. + */ +export function commitCheck(actual, expected) { + if (!expected) return { result: RESULT.PASS, note: `build commit: ${actual ?? "(not stamped)"}` }; + if (!actual) return { result: RESULT.FAIL, note: `build commit not stamped; expected ${expected}` }; + if (actual.endsWith("+dirty")) return { result: RESULT.FAIL, note: `deployed build has uncommitted changes (${actual})` }; + const a = actual.toLowerCase(); + const e = expected.toLowerCase(); + const ok = e.length >= 7 && (a.startsWith(e) || e.startsWith(a)); + return ok + ? { result: RESULT.PASS, note: `build commit ${actual} matches` } + : { result: RESULT.FAIL, note: `STALE OR WRONG BUILD: serves ${actual}, expected ${expected}` }; +} diff --git a/scripts/serve-isolated.mjs b/scripts/serve-isolated.mjs index 0c2a62cae..936f78497 100644 --- a/scripts/serve-isolated.mjs +++ b/scripts/serve-isolated.mjs @@ -1,13 +1,19 @@ #!/usr/bin/env node -// Serves the static export (out/) with the same cross-origin isolation headers -// as production (netlify.toml), so the multi-threaded FFmpeg core loads exactly -// as it does on the live site. Used by the Playwright tests. +// Serves the static export (out/) with the same headers as production: the +// security and cross-origin isolation headers declared for every host +// (vercel.json, kept identical to netlify.toml and public/_headers by +// deployConfig.test.ts), immutable caching for hashed assets and the hosts' +// trailing-slash redirect. So the multi-threaded FFmpeg core loads exactly as +// on the live site, and scripts/deploy/check-deploy.mjs can check a local +// build. Used by the Playwright tests and deploy-smoke.yml. // // node scripts/serve-isolated.mjs [port] (default 4173) // ISOLATED=0 node scripts/serve-isolated.mjs (no COOP/COEP: single-threaded) import { createServer } from "node:http"; import { readFile, stat } from "node:fs/promises"; -import { extname, join, normalize, resolve } from "node:path"; +import { readFileSync } from "node:fs"; +import { dirname, extname, join, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; const root = resolve(process.cwd(), "out"); const port = Number(process.argv[2] ?? process.env.PORT ?? 4173); @@ -44,12 +50,25 @@ async function resolveFile(urlPath) { return null; } +const vercelJson = resolve(dirname(fileURLToPath(import.meta.url)), "../vercel.json"); +const declared = JSON.parse(readFileSync(vercelJson, "utf8")).headers.find((rule) => rule.source === "/(.*)").headers; + createServer(async (req, res) => { - const headers = { "X-Content-Type-Options": "nosniff" }; - if (isolated) { - headers["Cross-Origin-Opener-Policy"] = "same-origin"; - headers["Cross-Origin-Embedder-Policy"] = "require-corp"; + const headers = Object.fromEntries(declared.map(({ key, value }) => [key, value])); + if (!isolated) { + delete headers["Cross-Origin-Opener-Policy"]; + delete headers["Cross-Origin-Embedder-Policy"]; + } + const path = (req.url ?? "/").split("?")[0]; + // Netlify and Vercel both drop a trailing slash on pages. + if (path.length > 1 && path.endsWith("/")) { + res.writeHead(301, { Location: path.slice(0, -1) }); + res.end(); + return; } + headers["Cache-Control"] = path.startsWith("/_next/static/") + ? "public, max-age=31536000, immutable" + : "public, max-age=0, must-revalidate"; const file = await resolveFile(req.url ?? "/"); if (!file) { const notFound = await resolveFile("/404"); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d15a5478b..f5f7dc036 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -65,6 +65,11 @@ export const metadata: Metadata = { icon: [{ url: "/favicon.svg", type: "image/svg+xml" }], shortcut: "/favicon.svg", }, + // The commit this build was made from (next.config.ts), so + // scripts/deploy/check-deploy.mjs --expect-commit can catch a stale deploy. + other: { + "reframe-build": process.env.NEXT_PUBLIC_RELEASE ?? "dev", + }, }; export default function RootLayout({ diff --git a/src/lib/tests/deployCheck.test.ts b/src/lib/tests/deployCheck.test.ts new file mode 100644 index 000000000..db995d30c --- /dev/null +++ b/src/lib/tests/deployCheck.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment node +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import http from "node:http"; +import { execFile } from "node:child_process"; +import path from "node:path"; +import fs from "node:fs"; +import type { AddressInfo } from "node:net"; +import * as rules from "../../../scripts/deploy/lib/deployCheck.mjs"; + +const root = path.resolve(__dirname, "../../.."); +const expected: { key: string; value: string }[] = rules.expectedHeaders(fs.readFileSync(path.join(root, "vercel.json"), "utf8")); +const security = Object.fromEntries(expected.map(({ key, value }) => [key.toLowerCase(), value])); + +describe("deploy check rules", () => { + const ok = (kind: string, headers: Record, status = 200, location?: string) => + rules.checkResponse({ kind, url: "u", status, headers, expected, location }); + + it("passes a correct page, asset, font, worker and 404", () => { + expect(ok("document", { ...security, "content-type": "text/html; charset=utf-8", "cache-control": "public,max-age=0,must-revalidate" }).result).toBe("pass"); + const immutable = { ...security, "cache-control": "public,max-age=31536000,immutable" }; + expect(ok("script", { ...immutable, "content-type": "application/javascript" }).result).toBe("pass"); + expect(ok("worker", { ...immutable, "content-type": "text/javascript" }).result).toBe("pass"); + expect(ok("font", { ...immutable, "content-type": "font/woff2" }).result).toBe("pass"); + expect(ok("notFound", { ...security, "content-type": "text/html" }, 404).result).toBe("pass"); + }); + + it("fails each missing header, wrong MIME type or cache policy by name", () => { + const { result, problems } = ok("script", { "content-type": "text/plain", "cache-control": "public, max-age=0" }); + expect(result).toBe("fail"); + expect(problems.join("\n")).toMatch(/Cross-Origin-Embedder-Policy: \(missing\)/); + expect(problems.join("\n")).toMatch(/Content-Type: text\/plain/); + expect(problems.join("\n")).toMatch(/expected immutable/); + // HTML cached as immutable would pin users to an old build. + expect(ok("document", { ...security, "content-type": "text/html", "cache-control": "public,max-age=31536000,immutable" }).result).toBe("fail"); + }); + + it("treats auth walls as protected, not as failures", () => { + expect(ok("document", {}, 401).result).toBe("protected"); + expect(ok("document", {}, 302, "https://vercel.com/sso/login?next=x").result).toBe("protected"); + }); + + it("accepts a trailing-slash redirect only to the page itself", () => { + expect(ok("redirect", {}, 308, "/privacy").result).toBe("pass"); + expect(ok("redirect", {}, 301, "/").result).toBe("fail"); + }); + + it("requires CORS on the cross-origin WASM, not the site's own headers", () => { + expect(ok("wasm", { "content-type": "application/wasm", "access-control-allow-origin": "*" }).result).toBe("pass"); + expect(ok("wasm", { "content-type": "application/wasm" }).result).toBe("fail"); + }); + + it("ranks fail > unavailable > protected > pass", () => { + expect(rules.overallResult(["pass", "protected", "unavailable", "fail"])).toBe("fail"); + expect(rules.overallResult(["pass", "protected", "unavailable"])).toBe("unavailable"); + expect(rules.overallResult(["pass", "protected"])).toBe("protected"); + expect(rules.overallResult(["pass"])).toBe("pass"); + expect([0, 1, 3, 4]).toEqual(["pass", "fail", "protected", "unavailable"].map(rules.exitCode)); + }); + + it("detects stale, wrong and uncommitted builds", () => { + expect(rules.commitCheck("abc1234def", "abc1234").result).toBe("pass"); + expect(rules.commitCheck("abc1234def", "fff0000").note).toMatch(/STALE OR WRONG BUILD/); + expect(rules.commitCheck("abc1234def+dirty", "abc1234").result).toBe("fail"); + expect(rules.commitCheck(null, "abc1234").result).toBe("fail"); + expect(rules.commitCheck("abc", "ab").result).toBe("fail"); + }); + + it("finds the page's resources and the worker chunk", () => { + const html = ``; + expect(rules.discoverFromHtml(html)).toMatchObject({ font: "/_next/static/media/x.p.woff2", runtime: "/_next/static/chunks/webpack-1.js", commit: "abc1234" }); + expect(rules.lazyChunksFromRuntime(`{27:"dde50262d37ca159",554:"0a02c043a2ac5e2e"}`)).toEqual([ + "/_next/static/chunks/27.dde50262d37ca159.js", + "/_next/static/chunks/554.0a02c043a2ac5e2e.js", + ]); + expect(rules.wasmUrlFromWorker(`"https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/umd"`)).toBe( + "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/umd/ffmpeg-core.wasm" + ); + }); +}); + +// โ”€โ”€ The CLI against a real local HTTP server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +type Mode = "good" | "noCoep" | "auth"; +let mode: Mode = "good"; +let server: http.Server; +let origin = ""; + +const PAGE = ``; +const FILES: Record = { + "/": [PAGE, "text/html; charset=utf-8"], + "/_next/static/chunks/webpack-1.js": [`var m={27:"0000000000000001"};`, "application/javascript"], + "/_next/static/chunks/27.0000000000000001.js": [`fetch("https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd")`, "application/javascript"], + "/_next/static/media/f.woff2": ["wOF2", "font/woff2"], + "/privacy": ["", "text/html"], +}; + +beforeAll(async () => { + server = http.createServer((req, res) => { + if (mode === "auth") return void res.writeHead(401).end(); + const p = new URL(req.url!, "http://x").pathname; + const h: Record = { ...Object.fromEntries(expected.map(({ key, value }) => [key, value])) }; + if (mode === "noCoep") delete h["Cross-Origin-Embedder-Policy"]; + if (p === "/privacy/") return void res.writeHead(301, { Location: "/privacy" }).end(); + const file = FILES[p]; + if (!file) return void res.writeHead(404, { ...h, "Content-Type": "text/html" }).end("nf"); + h["Content-Type"] = file[1]; + h["Cache-Control"] = p.startsWith("/_next/static/") ? "public, max-age=31536000, immutable" : "public, max-age=0, must-revalidate"; + res.writeHead(200, h).end(file[0]); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`; +}); +afterAll(() => new Promise((r) => server.close(() => r()))); + +function cli(...args: string[]): Promise<{ code: number; out: string }> { + return new Promise((resolve) => { + execFile("node", [path.join(root, "scripts/deploy/check-deploy.mjs"), ...args], { env: { ...process.env, VERCEL_AUTOMATION_BYPASS_SECRET: "" } }, (err, stdout) => + resolve({ code: err ? (err as { code?: number }).code ?? 1 : 0, out: stdout }) + ); + }); +} + +describe("check-deploy.mjs against HTTP responses", () => { + it("passes a correctly served site and checks every resource kind", async () => { + mode = "good"; + const { code, out } = await cli(origin, "--expect-commit", "abc1234", "--json"); + const report = JSON.parse(out); + expect(code).toBe(0); + expect(report.checks.map((c: { kind: string }) => c.kind)).toEqual(["document", "script", "font", "worker", "redirect", "notFound"]); + }); + + it("fails on a missing header", async () => { + mode = "noCoep"; + const { code, out } = await cli(origin); + expect(code).toBe(1); + expect(out).toMatch(/Cross-Origin-Embedder-Policy: \(missing\)/); + }); + + it("fails clearly on a stale deployment", async () => { + mode = "good"; + const { code, out } = await cli(origin, "--expect-commit", "fff0000"); + expect(code).toBe(1); + expect(out).toMatch(/STALE OR WRONG BUILD: serves abc1234def, expected fff0000/); + }); + + it("reports a protected deployment separately", async () => { + mode = "auth"; + const { code, out } = await cli(origin); + expect(code).toBe(3); + expect(out).toMatch(/requires authentication/); + }); + + it("reports an unreachable deployment separately", async () => { + const { code, out } = await cli("http://127.0.0.1:9/"); + expect(code).toBe(4); + expect(out).toMatch(/could not be reached/); + }); + + it("rejects bad usage", async () => { + expect((await cli("not-a-url")).code).toBe(2); + expect((await cli(origin, "--expect-commit")).code).toBe(2); + }); +}); diff --git a/src/lib/tests/deployConfig.test.ts b/src/lib/tests/deployConfig.test.ts index cfed8dedf..82c618d45 100644 --- a/src/lib/tests/deployConfig.test.ts +++ b/src/lib/tests/deployConfig.test.ts @@ -24,6 +24,8 @@ const REQUIRED = { "X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin-when-cross-origin", "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Cross-Origin-Resource-Policy": "same-origin", + "Content-Security-Policy": "base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'", }; /** "Key: value" / Key = "value" pairs from netlify.toml or public/_headers. */ @@ -50,8 +52,16 @@ describe("vercel.json", () => { expect(vercelHeaders["Strict-Transport-Security"]).toBeUndefined(); }); - it("does not add a CSP that could block the FFmpeg CDN or the RUM intake", () => { - expect(vercelHeaders["Content-Security-Policy"]).toBeUndefined(); + it("has a CSP with no fetch directives, so it can't block the FFmpeg CDN or the RUM intake", () => { + const directives = (vercelHeaders["Content-Security-Policy"] ?? "").split(";").map((d) => d.trim().split(/\s+/)[0]); + expect(directives).toEqual(["base-uri", "object-src", "frame-ancestors", "form-action"]); + }); + + it.each([ + ["netlify.toml", /for = "\/_next\/static\/\*"\s+\[headers\.values\]\s+Cache-Control = "public, max-age=31536000, immutable"/], + ["public/_headers", /\/_next\/static\/\*\s+Cache-Control: public, max-age=31536000, immutable/], + ])("%s marks hashed build assets immutable", (file, rule) => { + expect(read(file)).toMatch(rule); }); }); diff --git a/vercel.json b/vercel.json index 709d0f724..0396e3362 100644 --- a/vercel.json +++ b/vercel.json @@ -20,8 +20,20 @@ { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" + }, + { + "key": "Cross-Origin-Resource-Policy", + "value": "same-origin" + }, + { + "key": "Content-Security-Policy", + "value": "base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'" } ] + }, + { + "source": "/_next/static/(.*)", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] } ] } diff --git a/vitest.setup.ts b/vitest.setup.ts index 9d61fc0d5..c26fa0717 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,7 +1,8 @@ // Vitest setup: polyfills and global mocks import '@testing-library/jest-dom/vitest' -Object.defineProperty(window, 'matchMedia', { +// Node-environment tests (e.g. deployCheck.test.ts) have no window. +if (typeof window !== 'undefined') Object.defineProperty(window, 'matchMedia', { writable: true, value: (query: string) => ({ matches: false,