diff --git a/functions/admin/stats.ts b/functions/admin/stats.ts index d5b8d014..43b739ca 100644 --- a/functions/admin/stats.ts +++ b/functions/admin/stats.ts @@ -17,6 +17,17 @@ const AGENT = "sw_agent_log"; const MCP = "sw_mcp_log"; const REPORT = "sw_report_log"; +// SRI for /admin-stats.js. Hardcoded (this is an edge Function, not an Astro +// render); scripts/check-integrity.mjs verifies it matches public/admin-stats.js +// on every build. +const ADMIN_STATS_SRI = + "sha384-XUWxtbHAb/brkCdgXgjE9OLsRhBFCUFd5U2oRGnojav+W3oVhEbH80w/ji0pEn44"; + +/** Cache-busting slice of an SRI hash; mirrors hashVersion() in src/lib/integrity.ts. */ +function hashVersion(integrity: string): string { + return integrity.slice(7, 19).replace(/[^A-Za-z0-9]/g, "_"); +} + // Include direct .md URLs and the legacy Accept-header flag in historical data. const AGENT_MARKDOWN = "(blob10 IN ('markdown', '1') OR blob4 LIKE '%.md')"; const AGENT_MIME = `if(${AGENT_MARKDOWN}, 'markdown', 'html')`; @@ -938,10 +949,9 @@ function renderDashboard( - - + + `; } diff --git a/package-lock.json b/package-lock.json index a38e7109..e1d65b42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -780,7 +780,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -797,7 +796,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -814,7 +812,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -831,7 +828,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -848,7 +844,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -865,7 +860,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -882,7 +876,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -899,7 +892,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -916,7 +908,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -933,7 +924,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -950,7 +940,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -967,7 +956,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -984,7 +972,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1001,7 +988,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1018,7 +1004,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1035,7 +1020,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1052,7 +1036,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1069,7 +1052,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1086,7 +1068,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1103,7 +1084,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1120,7 +1100,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1137,7 +1116,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1154,7 +1132,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1171,7 +1148,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1188,7 +1164,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1205,7 +1180,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1515,7 +1489,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1538,7 +1511,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1558,7 +1530,6 @@ "version": "0.35.5", "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1581,7 +1552,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1598,7 +1568,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1615,7 +1584,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1632,7 +1600,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1649,7 +1616,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1666,7 +1632,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1683,7 +1648,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1700,7 +1664,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1717,7 +1680,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1734,7 +1696,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1751,7 +1712,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1774,7 +1734,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1797,7 +1756,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1820,7 +1778,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1843,7 +1800,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1866,7 +1822,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1889,7 +1844,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1912,7 +1866,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1932,7 +1885,6 @@ "version": "0.35.5", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -1949,7 +1901,6 @@ "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1963,7 +1914,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1983,7 +1933,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2003,7 +1952,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2023,7 +1971,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ diff --git a/public/_headers b/public/_headers index 5185d22e..e938a2e4 100644 --- a/public/_headers +++ b/public/_headers @@ -28,7 +28,8 @@ # # Integrity-Policy-Report-Only: every first-party script now carries an SRI # `integrity` attribute (src/lib/integrity.ts, the pagefind*/webmcp hashes, and -# the admin-stats.js literal), so this report-only policy should fire *no* +# the admin-stats.js literal), and its URL a `?v=` slice of that hash so a +# deploy never pairs a new hash with a stale cached copy, so this report-only policy should fire *no* # violations in normal browsing — it is a regression tripwire: a report to # /reports means a script shipped without integrity. Flip to enforcing # `Integrity-Policy` once the /reports stream confirms a clean run. Worked @@ -57,12 +58,11 @@ tdm-reservation: 0 Link: ; rel="describedby"; type="text/markdown"; title="Site index for LLMs", ; rel="alternate"; type="text/markdown"; title="Full content as Markdown", ; rel="api-catalog"; type="application/linkset+json", ; rel="mcp"; type="application/json"; title="MCP server card", ; rel="related"; title="MCP endpoint", ; rel="service-desc"; type="application/json"; title="A2A agent card", ; rel="related"; title="A2A endpoint", ; rel="agent-skills"; type="application/json"; title="Agent Skills discovery index", ; rel="ai-catalog"; type="application/ai-catalog+json"; title="Agentic Resource Discovery catalog", ; rel="sitemap"; type="application/xml", ; rel="alternate"; type="application/rss+xml"; title="Feed of spec changes", ; rel="alternate"; type="application/rss+xml"; title="Changelog feed", ; rel="security"; type="text/plain" -# Self-hosted Plausible tracker. Not fingerprinted and refreshed in place by -# the daily job, so keep the cache short: the HTML's SRI hash updates in the -# same deploy, and a long-lived stale copy would fail the integrity check. +# Self-hosted Plausible tracker, refreshed in place by the daily job. No short +# cache needed: like every SRI-pinned script, the HTML loads it as +# /js/plausible.js?v=, so new bytes get a new URL. /js/plausible.js Content-Type: text/javascript; charset=utf-8 - Cache-Control: public, max-age=600, must-revalidate # Feeds — WebSub discovery. # diff --git a/public/search.js b/public/search.js index d29b8e3b..b3609593 100644 --- a/public/search.js +++ b/public/search.js @@ -22,15 +22,20 @@ link.setAttribute("data-pagefind-css", ""); document.head.appendChild(link); } - var script = document.createElement("script"); - script.type = "module"; - script.src = "/pagefind/pagefind-component-ui.js"; // SRI + CORS mode so this injected script satisfies Integrity-Policy // (which blocks both un-hashed and no-cors script requests). The hash must // match pagefindComponentUiIntegrity in src/lib/integrity.ts; both are - // verified against the built file by scripts/check-integrity.mjs. - script.integrity = + // verified against the built file by scripts/check-integrity.mjs. The URL + // carries a slice of it (hashVersion() in integrity.ts) so a Pagefind bump + // can't collide with a stale cached copy. + var integrity = "sha384-sgK6d6muVu9zgs2S4M4GuLq6M9fV5OMX7x2wloOZiDmbOgYhQa+tf8Ukevo/kjeq"; + var script = document.createElement("script"); + script.type = "module"; + script.src = + "/pagefind/pagefind-component-ui.js?v=" + + integrity.slice(7, 19).replace(/[^A-Za-z0-9]/g, "_"); + script.integrity = integrity; script.crossOrigin = "anonymous"; script.onload = function () { // The custom elements register asynchronously after the module diff --git a/scripts/check-integrity.mjs b/scripts/check-integrity.mjs index f3d13411..886ec495 100644 --- a/scripts/check-integrity.mjs +++ b/scripts/check-integrity.mjs @@ -13,7 +13,12 @@ // A stale hash would break search once Integrity-Policy is enforced (and today // silently disables the check). On mismatch this prints the value to paste and // exits non-zero, failing the build. -import { readFileSync } from "node:fs"; +// +// It also walks the built HTML and checks that every SRI-pinned script URL +// carries `?v=` (hashVersion() in src/lib/integrity.ts) and +// that the hash matches the file it points at, so a changed file always gets a +// new URL rather than colliding with a visitor's stale cached copy. +import { readFileSync, readdirSync } from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; @@ -109,10 +114,42 @@ if (!guard) { } } +// Every SRI-pinned script in the built HTML must use a versioned URL that +// matches the bytes actually served at that path. +const hashVersion = (integrity) => + integrity.slice(7, 19).replace(/[^A-Za-z0-9]/g, "_"); +const htmlFiles = readdirSync(join(root, "dist"), { recursive: true }).filter( + (f) => String(f).endsWith(".html"), +); +const seen = new Map(); +for (const rel of htmlFiles) { + const html = readFileSync(join(root, "dist", String(rel)), "utf8"); + for (const [tag] of html.matchAll(/]*>/g)) { + const integrity = (tag.match(/\b(?:data-webmcp-sri|integrity)="([^"]+)"/) || + [])[1]; + if (!integrity) continue; + const src = (tag.match(/\b(?:data-webmcp-src|src)="([^"]+)"/) || [])[1]; + if (!src) continue; + const [path, query = ""] = src.split("?"); + if (query !== `v=${hashVersion(integrity)}`) { + fail( + `${rel}: ${src} is not versioned by its hash.\n Expected: ${path}?v=${hashVersion(integrity)}`, + ); + continue; + } + if (seen.has(src)) continue; + seen.set(src, integrity); + if (built384(join("dist", path)) !== integrity) { + fail(`${rel}: integrity for ${path} does not match dist${path}.`); + } + } +} +if (seen.size === 0) fail("no SRI-pinned scripts found in dist/**/*.html."); + if (failed) { console.error("\ncheck-integrity: FAILED"); process.exit(1); } console.log( - "✓ check-integrity: pagefind.js + component-ui + WebMCP guard pinned and CSP-allowed.", + `✓ check-integrity: pagefind.js + component-ui + WebMCP guard pinned and CSP-allowed; ${seen.size} script URLs versioned by hash.`, ); diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 393f53aa..69cc4038 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,7 +1,7 @@ --- import "~/styles/global.css"; import { site } from "~/lib/site"; -import { sri, pagefindImportMap } from "~/lib/integrity"; +import { pinned, versioned, pagefindImportMap } from "~/lib/integrity"; import { WEBMCP_GUARD, webmcpIntegrity } from "~/lib/webmcp"; import HeadMeta from "~/components/HeadMeta.astro"; import SiteHeader from "~/components/SiteHeader.astro"; @@ -62,12 +62,10 @@ const finalJsonLd = jsonLd : websiteJsonLd; // SRI for /webmcp.js — hashed from its generated bytes (memoised, computed once). +// Every script URL carries a slice of its own hash (see src/lib/integrity.ts), +// so a deploy changes the URL instead of colliding with a stale cached copy. const webmcpSri = await webmcpIntegrity(); -// The telemetry bundle is not fingerprinted, so its URL carries a slice of its -// own hash: a new deploy changes the URL instead of colliding with a cached -// copy that would fail the new integrity check. -const diagnosticsSri = sri("/vendor/diagnostics.js"); -const diagnosticsSrc = `/vendor/diagnostics.js?v=${diagnosticsSri.slice(7, 19).replace(/[^A-Za-z0-9]/g, "_")}`; +const webmcpSrc = versioned("/webmcp.js", webmcpSri); --- @@ -99,15 +97,13 @@ const diagnosticsSrc = `/vendor/diagnostics.js?v=${diagnosticsSri.slice(7, 19).r ) @@ -139,8 +134,7 @@ const diagnosticsSrc = `/vendor/diagnostics.js?v=${diagnosticsSri.slice(7, 19).r defer data-domain={site.plausibleDomain} data-api={`${site.plausibleHost}/api/event`} - src="/js/plausible.js" - integrity={sri("/js/plausible.js")} + {...pinned("/js/plausible.js")} crossorigin="anonymous" /> ) @@ -215,16 +209,14 @@ const diagnosticsSrc = `/vendor/diagnostics.js?v=${diagnosticsSri.slice(7, 19).r { /* WebMCP: feature-detect before fetching. The payload is only useful to a @@ -234,7 +226,7 @@ const diagnosticsSrc = `/vendor/diagnostics.js?v=${diagnosticsSri.slice(7, 19).r } diff --git a/src/lib/integrity.ts b/src/lib/integrity.ts index fe9e6d18..ea932d49 100644 --- a/src/lib/integrity.ts +++ b/src/lib/integrity.ts @@ -11,6 +11,16 @@ // scripts/check-integrity.mjs. Version-stable (no site data). // webmcp — generated per-build from spec content (see src/lib/webmcp.ts), // hashed from its exact generated bytes. +// +// None of these files are fingerprinted, and Cloudflare caches them for hours. +// After a deploy that changes one, a returning visitor's cached copy would no +// longer match the new hash in the HTML and the browser would block it. So each +// URL also carries a slice of its own hash (versioned(), pinned()): new bytes, +// new URL, no collision with the stale copy. The query is ignored when serving. +// +// The exception is /pagefind/pagefind.js: Pagefind's UI imports it by a fixed +// path, which is also the import-map key carrying its hash. Its bytes only move +// on a Pagefind version bump. import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; @@ -35,6 +45,29 @@ export function sri(servedPath: string): string { return value; } +/** + * Cache-busting query value derived from an SRI hash: 12 base64 chars of the + * digest, with `+` and `/` swapped for `_` so it needs no URL escaping. Mirrored + * in public/search.js and functions/admin/stats.ts, which can't import this. + */ +export function hashVersion(integrity: string): string { + return integrity.slice(7, 19).replace(/[^A-Za-z0-9]/g, "_"); +} + +/** `servedPath?v=` — a URL that changes whenever the bytes do. */ +export function versioned(servedPath: string, integrity: string): string { + return `${servedPath}?v=${hashVersion(integrity)}`; +} + +/** + * `{ src, integrity }` for a committed file under public/, ready to spread onto + * a )} diff --git a/src/pages/search.astro b/src/pages/search.astro index 9b845a81..5d84b4a1 100644 --- a/src/pages/search.astro +++ b/src/pages/search.astro @@ -1,7 +1,11 @@ --- import BaseLayout from "~/layouts/BaseLayout.astro"; import Breadcrumbs from "~/components/Breadcrumbs.astro"; -import { sri, pagefindComponentUiIntegrity } from "~/lib/integrity"; +import { + pinned, + versioned, + pagefindComponentUiIntegrity, +} from "~/lib/integrity"; ---