From 18bea3a852216315f59ef2f1b27d2fcb7f27e049 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 21:43:55 +0900 Subject: [PATCH] fix: verify hosting response content types --- config/hosting/cache-policy.json | 4 ++++ config/hosting/response-headers.fixture.json | 6 +++++- docs/operations/release-cache-rollback.md | 4 ++++ scripts/verify-hosting-headers.mjs | 22 ++++++++++++++++---- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/config/hosting/cache-policy.json b/config/hosting/cache-policy.json index 4730f86..45b79bf 100644 --- a/config/hosting/cache-policy.json +++ b/config/hosting/cache-policy.json @@ -4,21 +4,25 @@ "index": { "path": "/", "cacheControl": "no-cache", + "contentTypes": ["text/html"], "securityHeaders": true }, "runtimeConfig": { "path": "/config.json", "cacheControl": "no-store", + "contentTypes": ["application/json"], "securityHeaders": true }, "releaseManifest": { "path": "/release-manifest.json", "cacheControl": "no-store", + "contentTypes": ["application/json"], "securityHeaders": true }, "hashedAsset": { "pathPattern": "/assets/*", "cacheControl": "public, max-age=31536000, immutable", + "contentTypes": ["text/javascript", "application/javascript"], "securityHeaders": false }, "sourceMap": { diff --git a/config/hosting/response-headers.fixture.json b/config/hosting/response-headers.fixture.json index a7add6e..88ece61 100644 --- a/config/hosting/response-headers.fixture.json +++ b/config/hosting/response-headers.fixture.json @@ -3,6 +3,7 @@ "responses": { "index": { "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", "strict-transport-security": "max-age=31536000; includeSubDomains", "x-frame-options": "DENY", @@ -12,6 +13,7 @@ }, "runtimeConfig": { "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", "strict-transport-security": "max-age=31536000; includeSubDomains", "x-frame-options": "DENY", @@ -21,6 +23,7 @@ }, "releaseManifest": { "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", "strict-transport-security": "max-age=31536000; includeSubDomains", "x-frame-options": "DENY", @@ -29,7 +32,8 @@ "permissions-policy": "camera=(), microphone=(), geolocation=()" }, "hashedAsset": { - "cache-control": "public, max-age=31536000, immutable" + "cache-control": "public, max-age=31536000, immutable", + "content-type": "text/javascript; charset=utf-8" } } } diff --git a/docs/operations/release-cache-rollback.md b/docs/operations/release-cache-rollback.md index 15ca32a..de4ebad 100644 --- a/docs/operations/release-cache-rollback.md +++ b/docs/operations/release-cache-rollback.md @@ -20,6 +20,10 @@ The provider-independent cache defaults are: - public source maps: 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. Set `HOSTING_BASE_URL` to probe deployed responses; production promotion requires the artifact to report `mode: "live"`. diff --git a/scripts/verify-hosting-headers.mjs b/scripts/verify-hosting-headers.mjs index ee1fc15..c539170 100644 --- a/scripts/verify-hosting-headers.mjs +++ b/scripts/verify-hosting-headers.mjs @@ -15,13 +15,13 @@ let mode; if (baseUrl) { mode = "live"; const assets = await readdir("dist/assets"); - const hashedAsset = assets.find((file) => !file.endsWith(".map")); - if (!hashedAsset) throw new Error("No built hashed asset found."); + const hashedJavaScript = assets.find((file) => file.endsWith(".js")); + if (!hashedJavaScript) throw new Error("No built hashed JavaScript found."); const paths = { index: "/", runtimeConfig: "/config.json", releaseManifest: "/release-manifest.json", - hashedAsset: `/assets/${hashedAsset}`, + hashedAsset: `/assets/${hashedJavaScript}`, }; responses = {}; for (const [surface, pathname] of Object.entries(paths)) { @@ -51,6 +51,18 @@ for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) { observed, 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) { for (const [header, expected] of Object.entries(securityPolicy.headers)) { const observedSecurity = responses[surface]?.[header.toLowerCase()]; @@ -100,7 +112,9 @@ await writeFile( ); 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.stdout.write(