|
11 | 11 | */ |
12 | 12 |
|
13 | 13 | import { execFileSync } from 'node:child_process' |
| 14 | +import { hash as cryptoHash } from 'node:crypto' |
14 | 15 | import { |
15 | 16 | appendFileSync, |
16 | 17 | copyFileSync, |
17 | 18 | createReadStream, |
18 | 19 | existsSync, |
| 20 | + mkdirSync, |
19 | 21 | readFileSync, |
20 | 22 | readdirSync, |
21 | 23 | statSync, |
@@ -150,6 +152,81 @@ function applyBasePath(html: string, basePath: string, slug: string): string { |
150 | 152 | return out |
151 | 153 | } |
152 | 154 |
|
| 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 | + |
153 | 230 | async function generate( |
154 | 231 | refresh: boolean, |
155 | 232 | minify: boolean, |
@@ -361,6 +438,14 @@ async function generate( |
361 | 438 | html = applyBasePath(html, basePath, slug) |
362 | 439 | } |
363 | 440 |
|
| 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 | + |
364 | 449 | writeFileSync(htmlPath, html) |
365 | 450 | } |
366 | 451 |
|
|
0 commit comments