Skip to content

Commit f91fcc4

Browse files
committed
feat(sri): generate Subresource Integrity hashes for CDN scripts
Every `<script src=https://unpkg.com/...>` and `<link rel=stylesheet href=https://unpkg.com/...>` tag in the generated HTML now carries a matching `integrity="sha384-..." crossorigin="anonymous"` attribute. A tampered CDN response (different bytes than we built against) is rejected by the browser's SRI check — the script/stylesheet never executes. Hashes are generated, not hand-typed: - Per-build, each unique unpkg URL is fetched once. - sha384 computed via Node's `crypto.hash()` one-shot API (Node 21+). - SRI string `sha384-<base64>` written to `.cache/sri/<base64url-url>.txt`. - Repeat builds reuse the cached hash; version bumps invalidate the cache key automatically (URL includes the @Version segment). Idempotent — tags that already carry `integrity=` are left alone. Regex excludes `/` from the attrs capture so self-closing `<link />` doesn't pull the trailing slash into the attrs string mid-rewrite.
1 parent 725bff5 commit f91fcc4

1 file changed

Lines changed: 85 additions & 0 deletions

File tree

scripts/walkthrough.mts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
*/
1212

1313
import { execFileSync } from 'node:child_process'
14+
import { hash as cryptoHash } from 'node:crypto'
1415
import {
1516
appendFileSync,
1617
copyFileSync,
1718
createReadStream,
1819
existsSync,
20+
mkdirSync,
1921
readFileSync,
2022
readdirSync,
2123
statSync,
@@ -150,6 +152,81 @@ function applyBasePath(html: string, basePath: string, slug: string): string {
150152
return out
151153
}
152154

155+
/**
156+
* Compute / look up the SRI hash for a CDN URL. Disk-cached under
157+
* `.cache/sri/<base64url(url)>.txt` so repeat builds don't refetch.
158+
* Version bumps invalidate automatically since the cache key is the
159+
* full URL (including the @version segment).
160+
*
161+
* Returns a ready-to-paste `sha384-<base64>` string.
162+
*/
163+
async function sriForUrl(url: string, cacheDir: string): Promise<string> {
164+
const key = Buffer.from(url).toString('base64url')
165+
const cachePath = path.join(cacheDir, `${key}.txt`)
166+
if (existsSync(cachePath)) {
167+
return readFileSync(cachePath, 'utf8').trim()
168+
}
169+
const res = await fetch(url)
170+
if (!res.ok) {
171+
throw new Error(`SRI fetch ${url} → HTTP ${res.status}`)
172+
}
173+
const bytes = new Uint8Array(await res.arrayBuffer())
174+
// `crypto.hash` is the Node 21+ one-shot helper — base64-encodes
175+
// the sha384 digest in a single call. Equivalent to the
176+
// createHash(...).update(...).digest('base64') dance but shorter.
177+
// SRI format itself is just `<algo>-<base64hash>`; no stdlib helper
178+
// does the string prefix, so we build that ourselves.
179+
const integrity = `sha384-${cryptoHash('sha384', bytes, 'base64')}`
180+
mkdirSync(cacheDir, { recursive: true })
181+
writeFileSync(cachePath, integrity + '\n')
182+
return integrity
183+
}
184+
185+
/**
186+
* Scan HTML for `<script src=https://unpkg.com/...>` and
187+
* `<link rel=stylesheet href=https://unpkg.com/...>` tags, fetch +
188+
* hash each resource, and inject `integrity="sha384-..." crossorigin
189+
* ="anonymous"` so the browser rejects any tampered CDN response.
190+
*
191+
* Idempotent — tags that already carry `integrity=` are left alone
192+
* so hand-authored hashes survive.
193+
*/
194+
async function injectSri(html: string, cacheDir: string): Promise<string> {
195+
// Collect unique URLs first so we fetch each once even if it's
196+
// referenced in multiple tags.
197+
const urlRe =
198+
/<(?:script\s[^>]*\bsrc|link\s[^>]*\bhref)="(https:\/\/unpkg\.com\/[^"]+)"/gi
199+
const urls = new Set<string>()
200+
for (const m of html.matchAll(urlRe)) {
201+
urls.add(m[1]!)
202+
}
203+
if (urls.size === 0) {
204+
return html
205+
}
206+
const integrityByUrl = new Map<string, string>()
207+
for (const url of urls) {
208+
integrityByUrl.set(url, await sriForUrl(url, cacheDir))
209+
}
210+
// Rewrite each tag. Skip tags that already carry `integrity=`.
211+
// The `attrs` capture intentionally excludes `/` so a self-closing
212+
// `<link ... />` doesn't pull the trailing slash into the attrs
213+
// string (which we'd then echo back before our injected attrs,
214+
// producing `href="..." / integrity="...">`).
215+
const tagRe =
216+
/<(script|link)\s([^>/]*?\b(?:src|href)="(https:\/\/unpkg\.com\/[^"]+)"[^>/]*)(\s*\/?\s*>)/gi
217+
return html.replace(tagRe, (full, tag, attrs, url, close) => {
218+
if (/\bintegrity=/i.test(attrs)) {
219+
return full
220+
}
221+
const integrity = integrityByUrl.get(url)
222+
if (!integrity) {
223+
return full
224+
}
225+
const trimmedAttrs = attrs.trimEnd()
226+
return `<${tag} ${trimmedAttrs} integrity="${integrity}" crossorigin="anonymous"${close}`
227+
})
228+
}
229+
153230
async function generate(
154231
refresh: boolean,
155232
minify: boolean,
@@ -361,6 +438,14 @@ async function generate(
361438
html = applyBasePath(html, basePath, slug)
362439
}
363440

441+
// Subresource Integrity on CDN scripts (marked, highlight.js,
442+
// highlight.js CSS theme). Fetches each URL once per build,
443+
// sha384-hashes the bytes, injects `integrity="sha384-..."
444+
// crossorigin="anonymous"` so the browser rejects any tampered
445+
// response. Disk-cached under .cache/sri/ so repeat builds are
446+
// free; a version bump (new URL) invalidates automatically.
447+
html = await injectSri(html, path.join(repoRoot, '.cache', 'sri'))
448+
364449
writeFileSync(htmlPath, html)
365450
}
366451

0 commit comments

Comments
 (0)