Compare commits

...
7 changed files with 216 additions and 22 deletions
+4
View File
@@ -4,21 +4,25 @@
"index": { "index": {
"path": "/", "path": "/",
"cacheControl": "no-cache", "cacheControl": "no-cache",
"contentTypes": ["text/html"],
"securityHeaders": true "securityHeaders": true
}, },
"runtimeConfig": { "runtimeConfig": {
"path": "/config.json", "path": "/config.json",
"cacheControl": "no-store", "cacheControl": "no-store",
"contentTypes": ["application/json"],
"securityHeaders": true "securityHeaders": true
}, },
"releaseManifest": { "releaseManifest": {
"path": "/release-manifest.json", "path": "/release-manifest.json",
"cacheControl": "no-store", "cacheControl": "no-store",
"contentTypes": ["application/json"],
"securityHeaders": true "securityHeaders": true
}, },
"hashedAsset": { "hashedAsset": {
"pathPattern": "/assets/*", "pathPattern": "/assets/*",
"cacheControl": "public, max-age=31536000, immutable", "cacheControl": "public, max-age=31536000, immutable",
"contentTypes": ["text/javascript", "application/javascript"],
"securityHeaders": false "securityHeaders": false
}, },
"sourceMap": { "sourceMap": {
+5 -1
View File
@@ -3,6 +3,7 @@
"responses": { "responses": {
"index": { "index": {
"cache-control": "no-cache", "cache-control": "no-cache",
"content-type": "text/html; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests", "content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains", "strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY", "x-frame-options": "DENY",
@@ -12,6 +13,7 @@
}, },
"runtimeConfig": { "runtimeConfig": {
"cache-control": "no-store", "cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests", "content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains", "strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY", "x-frame-options": "DENY",
@@ -21,6 +23,7 @@
}, },
"releaseManifest": { "releaseManifest": {
"cache-control": "no-store", "cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests", "content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests",
"strict-transport-security": "max-age=31536000; includeSubDomains", "strict-transport-security": "max-age=31536000; includeSubDomains",
"x-frame-options": "DENY", "x-frame-options": "DENY",
@@ -29,7 +32,8 @@
"permissions-policy": "camera=(), microphone=(), geolocation=()" "permissions-policy": "camera=(), microphone=(), geolocation=()"
}, },
"hashedAsset": { "hashedAsset": {
"cache-control": "public, max-age=31536000, immutable" "cache-control": "public, max-age=31536000, immutable",
"content-type": "text/javascript; charset=utf-8"
} }
} }
} }
+8 -1
View File
@@ -20,6 +20,13 @@ The provider-independent cache defaults are:
- public source maps: disabled - public source maps: disabled
- service worker/offline cache: disabled - service worker/offline cache: disabled
HTML, JSON config/manifest, and hashed JavaScript MIME types are also compared
to the declared allowlist; a cache-correct response with a mismatched
`Content-Type` still fails the hosting gate.
`corepack pnpm verify:hosting-headers` uses a deterministic fixture locally. `corepack pnpm verify:hosting-headers` uses a deterministic fixture locally.
Set `HOSTING_BASE_URL` to probe deployed responses; production promotion 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.
+68
View File
@@ -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 };
}
+92 -19
View File
@@ -1,5 +1,7 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.mjs";
const cachePolicy = JSON.parse( const cachePolicy = JSON.parse(
await readFile("config/hosting/cache-policy.json", "utf8"), await readFile("config/hosting/cache-policy.json", "utf8"),
); );
@@ -7,32 +9,85 @@ const securityPolicy = JSON.parse(
await readFile("config/hosting/security-headers.json", "utf8"), await readFile("config/hosting/security-headers.json", "utf8"),
); );
const baseUrl = process.env.HOSTING_BASE_URL; 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) =>
/(?:^|\/)(?:service-worker|sw)(?:[.-][^/]*)?\.js$/i.test(file),
);
/** @type {Record<string, Record<string, string>>} */ /** @type {Record<string, Record<string, string>>} */
let responses; let responses = {};
let mode; 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"; mode = "live";
const assets = await readdir("dist/assets"); const assets = await readdir("dist/assets");
const hashedAsset = assets.find((file) => !file.endsWith(".map")); const hashedJavaScript = assets.find((file) => file.endsWith(".js"));
if (!hashedAsset) throw new Error("No built hashed asset found."); if (!hashedJavaScript) throw new Error("No built hashed JavaScript found.");
const paths = { const paths = {
index: "/", index: "/",
runtimeConfig: "/config.json", runtimeConfig: "/config.json",
releaseManifest: "/release-manifest.json", releaseManifest: "/release-manifest.json",
hashedAsset: `/assets/${hashedAsset}`, hashedAsset: `/assets/${hashedJavaScript}`,
}; };
responses = {}; responses = {};
for (const [surface, pathname] of Object.entries(paths)) { for (const [surface, pathname] of Object.entries(paths)) {
const response = await fetch(new URL(pathname, baseUrl)); const requestedUrl = new URL(pathname, liveTarget.url);
responses[surface] = Object.fromEntries( try {
[...response.headers.entries()].map(([name, value]) => [ const response = await fetch(requestedUrl, { redirect: "follow" });
name.toLowerCase(), const finalUrl = new URL(response.url);
value, 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 { } else {
mode = "fixture"; mode = "fixture";
responses = JSON.parse( responses = JSON.parse(
@@ -40,7 +95,7 @@ if (baseUrl) {
).responses; ).responses;
} }
const results = []; const results = [...probeResults];
for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) { for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) {
if (!("cacheControl" in policy)) continue; if (!("cacheControl" in policy)) continue;
const observed = responses[surface]?.["cache-control"]; const observed = responses[surface]?.["cache-control"];
@@ -51,6 +106,18 @@ for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) {
observed, observed,
passed: observed === policy.cacheControl, passed: observed === policy.cacheControl,
}); });
const observedContentType = responses[surface]?.["content-type"];
const observedMime = observedContentType
?.split(";", 1)[0]
.trim()
.toLowerCase();
results.push({
surface,
header: "content-type",
expected: policy.contentTypes,
observed: observedContentType,
passed: policy.contentTypes.includes(observedMime),
});
if (policy.securityHeaders) { if (policy.securityHeaders) {
for (const [header, expected] of Object.entries(securityPolicy.headers)) { for (const [header, expected] of Object.entries(securityPolicy.headers)) {
const observedSecurity = responses[surface]?.[header.toLowerCase()]; const observedSecurity = responses[surface]?.[header.toLowerCase()];
@@ -69,15 +136,19 @@ results.push({
surface: "sourceMap", surface: "sourceMap",
header: "public", header: "public",
expected: false, expected: false,
observed: cachePolicy.surfaces.sourceMap.public, observed: publicSourceMaps.length > 0,
passed: cachePolicy.surfaces.sourceMap.public === false, passed:
cachePolicy.surfaces.sourceMap.public === false &&
publicSourceMaps.length === 0,
}); });
results.push({ results.push({
surface: "serviceWorker", surface: "serviceWorker",
header: "enabled", header: "enabled",
expected: false, expected: false,
observed: cachePolicy.surfaces.serviceWorker.enabled, observed: publicServiceWorkers.length > 0,
passed: cachePolicy.surfaces.serviceWorker.enabled === false, passed:
cachePolicy.surfaces.serviceWorker.enabled === false &&
publicServiceWorkers.length === 0,
}); });
const passed = results.every((result) => result.passed); const passed = results.every((result) => result.passed);
@@ -89,7 +160,7 @@ await writeFile(
schemaVersion: 1, schemaVersion: 1,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
mode, mode,
baseUrl: baseUrl ?? null, baseUrl: liveTarget?.observedOrigin ?? null,
providerVerificationRequired: mode !== "live", providerVerificationRequired: mode !== "live",
results, results,
passed, passed,
@@ -100,7 +171,9 @@ await writeFile(
); );
if (!passed) { if (!passed) {
process.stderr.write("Hosting cache/security header verification failed.\n"); process.stderr.write(
"Hosting cache/content-type/security header verification failed.\n",
);
process.exit(1); process.exit(1);
} }
process.stdout.write( process.stdout.write(
+12 -1
View File
@@ -2,7 +2,10 @@ import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js"; import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js";
import { compareReleaseToRuntime } from "../src/contracts/release-tokens.js"; import {
compareReleaseToRuntime,
RELEASE_TOKEN_REGISTRY,
} from "../src/contracts/release-tokens.js";
const fixturesDocument = const fixturesDocument =
/** @type {{ /** @type {{
@@ -38,6 +41,14 @@ const actualAssetManifestHash = createHash("sha256")
const artifactComparison = compareReleaseToRuntime(release, runtimeConfig); const artifactComparison = compareReleaseToRuntime(release, runtimeConfig);
const artifactMismatches = [...artifactComparison.mismatches]; const artifactMismatches = [...artifactComparison.mismatches];
for (const token of Object.keys(RELEASE_TOKEN_REGISTRY)) {
if (typeof release[token] !== "string" || release[token].length === 0) {
artifactMismatches.push(`releaseToken:${token}`);
}
}
if (!Number.isFinite(Date.parse(release.builtAt))) {
artifactMismatches.push("releaseToken:builtAtFormat");
}
if (release.assetManifestHash !== actualAssetManifestHash) { if (release.assetManifestHash !== actualAssetManifestHash) {
artifactMismatches.push("assetManifestContent"); artifactMismatches.push("assetManifestContent");
} }
+27
View File
@@ -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),
});
});
});