From c089e749d0943f3c2eb49fe19a1c55f305978aaa Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 22:25:04 +0900 Subject: [PATCH] fix: require genuine live hosting evidence --- docs/operations/release-cache-rollback.md | 5 +- scripts/lib/hosting-probe.mjs | 68 +++++++++++++++++++++ scripts/verify-hosting-headers.mjs | 72 +++++++++++++++++++---- tests/unit/hosting-probe.test.js | 27 +++++++++ 4 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 scripts/lib/hosting-probe.mjs create mode 100644 tests/unit/hosting-probe.test.js diff --git a/docs/operations/release-cache-rollback.md b/docs/operations/release-cache-rollback.md index de4ebad..6dde83c 100644 --- a/docs/operations/release-cache-rollback.md +++ b/docs/operations/release-cache-rollback.md @@ -26,4 +26,7 @@ to the declared allowlist; a cache-correct response with a mismatched `corepack pnpm verify:hosting-headers` uses a deterministic fixture locally. Set `HOSTING_BASE_URL` to probe deployed responses; production promotion -requires the artifact to report `mode: "live"`. +requires the artifact to report `mode: "live"`. The live target must be its +canonical, non-loopback HTTPS root URL. Each required surface must return HTTP +200 without leaving that origin before its cache, content-type, and security +headers can count as deployment evidence. diff --git a/scripts/lib/hosting-probe.mjs b/scripts/lib/hosting-probe.mjs new file mode 100644 index 0000000..9db9380 --- /dev/null +++ b/scripts/lib/hosting-probe.mjs @@ -0,0 +1,68 @@ +const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/; + +/** + * A release gate must not promote a local preview server as live hosting + * evidence. + * + * @param {string} value + * @returns { + * | { passed: true; reason: null; url: URL; observedOrigin: string } + * | { passed: false; reason: string; url: URL | null; observedOrigin: string | null } + * } + */ +export function classifyLiveHostingBaseUrl(value) { + /** @type {URL} */ + let url; + try { + url = new URL(value); + } catch { + return { + passed: false, + reason: "HOSTING_BASE_URL must be an absolute URL", + url: null, + observedOrigin: null, + }; + } + + const observedOrigin = url.origin; + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (url.protocol !== "https:") { + return { + passed: false, + reason: "live hosting evidence requires HTTPS", + url, + observedOrigin, + }; + } + if (url.username || url.password) { + return { + passed: false, + reason: "HOSTING_BASE_URL must not contain credentials", + url, + observedOrigin, + }; + } + if ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === "::1" || + hostname === "0.0.0.0" || + LOOPBACK_IPV4.test(hostname) + ) { + return { + passed: false, + reason: "local or loopback hosts are not live deployment evidence", + url, + observedOrigin, + }; + } + if (url.pathname !== "/" || url.search || url.hash) { + return { + passed: false, + reason: "HOSTING_BASE_URL must be the canonical root URL", + url, + observedOrigin, + }; + } + return { passed: true, reason: null, url, observedOrigin }; +} diff --git a/scripts/verify-hosting-headers.mjs b/scripts/verify-hosting-headers.mjs index d7d23dd..40f802c 100644 --- a/scripts/verify-hosting-headers.mjs +++ b/scripts/verify-hosting-headers.mjs @@ -1,5 +1,7 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.mjs"; + const cachePolicy = JSON.parse( await readFile("config/hosting/cache-policy.json", "utf8"), ); @@ -7,6 +9,7 @@ const securityPolicy = JSON.parse( await readFile("config/hosting/security-headers.json", "utf8"), ); const baseUrl = process.env.HOSTING_BASE_URL; +const liveTarget = baseUrl ? classifyLiveHostingBaseUrl(baseUrl) : null; const distFiles = (await readdir("dist", { recursive: true })).map(String); const publicSourceMaps = distFiles.filter((file) => file.endsWith(".map")); const publicServiceWorkers = distFiles.filter((file) => @@ -14,10 +17,19 @@ const publicServiceWorkers = distFiles.filter((file) => ); /** @type {Record>} */ -let responses; +let responses = {}; let mode; +/** @type {Array<{ + * surface: string; + * header: string; + * expected: unknown; + * observed: unknown; + * reason?: string; + * passed: boolean; + * }>} */ +const probeResults = []; -if (baseUrl) { +if (liveTarget?.passed) { mode = "live"; const assets = await readdir("dist/assets"); const hashedJavaScript = assets.find((file) => file.endsWith(".js")); @@ -30,14 +42,52 @@ if (baseUrl) { }; responses = {}; for (const [surface, pathname] of Object.entries(paths)) { - const response = await fetch(new URL(pathname, baseUrl)); - responses[surface] = Object.fromEntries( - [...response.headers.entries()].map(([name, value]) => [ - name.toLowerCase(), - value, - ]), - ); + const requestedUrl = new URL(pathname, liveTarget.url); + try { + const response = await fetch(requestedUrl, { redirect: "follow" }); + const finalUrl = new URL(response.url); + probeResults.push( + { + surface, + header: "http-status", + expected: 200, + observed: response.status, + passed: response.status === 200, + }, + { + surface, + header: "final-origin", + expected: liveTarget.url.origin, + observed: finalUrl.origin, + passed: finalUrl.origin === liveTarget.url.origin, + }, + ); + responses[surface] = Object.fromEntries( + [...response.headers.entries()].map(([name, value]) => [ + name.toLowerCase(), + value, + ]), + ); + } catch (error) { + probeResults.push({ + surface, + header: "transport", + expected: "reachable", + observed: error instanceof Error ? error.name : "UnknownError", + passed: false, + }); + } } +} else if (liveTarget) { + mode = "invalid-live"; + probeResults.push({ + surface: "deployment", + header: "base-url", + expected: "canonical non-loopback HTTPS root URL", + observed: liveTarget.observedOrigin, + reason: liveTarget.reason, + passed: false, + }); } else { mode = "fixture"; responses = JSON.parse( @@ -45,7 +95,7 @@ if (baseUrl) { ).responses; } -const results = []; +const results = [...probeResults]; for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) { if (!("cacheControl" in policy)) continue; const observed = responses[surface]?.["cache-control"]; @@ -110,7 +160,7 @@ await writeFile( schemaVersion: 1, generatedAt: new Date().toISOString(), mode, - baseUrl: baseUrl ?? null, + baseUrl: liveTarget?.observedOrigin ?? null, providerVerificationRequired: mode !== "live", results, passed, diff --git a/tests/unit/hosting-probe.test.js b/tests/unit/hosting-probe.test.js new file mode 100644 index 0000000..1fd90de --- /dev/null +++ b/tests/unit/hosting-probe.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { classifyLiveHostingBaseUrl } from "../../scripts/lib/hosting-probe.mjs"; + +describe("live hosting evidence target", () => { + it("accepts a canonical production HTTPS root", () => { + expect( + classifyLiveHostingBaseUrl("https://frontend.example.test/"), + ).toMatchObject({ + passed: true, + observedOrigin: "https://frontend.example.test", + }); + }); + + it.each([ + ["http://frontend.example.test/", "requires HTTPS"], + ["https://localhost:4173/", "not live deployment evidence"], + ["https://127.0.0.1/", "not live deployment evidence"], + ["https://frontend.example.test/app/", "canonical root URL"], + ["https://user:secret@frontend.example.test/", "must not contain credentials"], + ])("rejects %s", (url, reason) => { + expect(classifyLiveHostingBaseUrl(url)).toMatchObject({ + passed: false, + reason: expect.stringContaining(reason), + }); + }); +});