Skip to content

Commit c82ca9c

Browse files
committed
feat(sri): extend to same-origin scripts, stylesheet, and preload tags
SRI was only covering the 3 CDN resources. Extend to: - Our own walkthrough.css (stylesheet). - walkthrough-drag.js / walkthrough-comments.js (both <script> and their <link rel=preload> hints — matching hashes). Browsers ignore `integrity` on <link rel=icon> / apple-touch-icon, so skip those (no point emitting attributes the browser throws away). `supportsSri()` gates both the hash-resolution pass and the rewrite pass. Ordering moved: SRI injection now runs AFTER minifyEmittedAssets so the local-file hashes reflect the exact bytes shipped. Previously with --minify the HTML carried pre-minify hashes, which the browser would then reject. crossorigin="anonymous" only attached for https:// refs — same-origin paths shouldn't carry it (would trigger CORS unnecessarily).
1 parent 66b61ce commit c82ca9c

1 file changed

Lines changed: 106 additions & 37 deletions

File tree

scripts/walkthrough.mts

Lines changed: 106 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -182,47 +182,106 @@ async function sriForUrl(url: string, cacheDir: string): Promise<string> {
182182
}
183183

184184
/**
185-
* Scan HTML for `<script src=https://unpkg.com/...>` and
186-
* `<link rel=stylesheet href=https://unpkg.com/...>` tags, fetch +
187-
* hash each resource, and inject `integrity="sha384-..." crossorigin
188-
* ="anonymous"` so the browser rejects any tampered CDN response.
185+
* Scan HTML for `<script src=...>`, `<link rel=stylesheet href=...>`,
186+
* and `<link rel=preload as=script href=...>` tags, hash each resource,
187+
* and inject `integrity="sha384-..."` so the browser rejects tampered
188+
* responses (CDN or our own origin).
189189
*
190-
* Idempotent — tags that already carry `integrity=` are left alone
191-
* so hand-authored hashes survive.
190+
* Sources:
191+
* - `https://unpkg.com/...` → fetched + disk-cached (see sriForUrl).
192+
* - `/walkthrough.css` etc. → read from `walkthroughDir` directly.
193+
* - `basePath`-prefixed same-origin paths → stripped to the bare
194+
* file name, then read from `walkthroughDir`.
195+
*
196+
* CDN tags also get `crossorigin="anonymous"` (required for the SRI
197+
* check to run on cross-origin responses). Same-origin tags don't
198+
* need it and shouldn't have it (would trigger CORS unnecessarily).
199+
*
200+
* Idempotent — tags that already carry `integrity=` are left alone.
192201
*/
193-
async function injectSri(html: string, cacheDir: string): Promise<string> {
194-
// Collect unique URLs first so we fetch each once even if it's
195-
// referenced in multiple tags.
196-
const urlRe =
197-
/<(?:script\s[^>]*\bsrc|link\s[^>]*\bhref)="(https:\/\/unpkg\.com\/[^"]+)"/gi
198-
const urls = new Set<string>()
199-
for (const m of html.matchAll(urlRe)) {
200-
urls.add(m[1]!)
202+
async function injectSri(
203+
html: string,
204+
walkthroughDir: string,
205+
basePath: string,
206+
cacheDir: string,
207+
): Promise<string> {
208+
// Map of URL/path → SRI hash. Populated lazily as we walk the tag
209+
// stream so we resolve each URL at most once per file.
210+
const integrityByRef = new Map<string, string>()
211+
212+
const resolveIntegrity = async (ref: string): Promise<string | null> => {
213+
if (integrityByRef.has(ref)) {
214+
return integrityByRef.get(ref)!
215+
}
216+
let integrity: string | null = null
217+
if (ref.startsWith('https://unpkg.com/')) {
218+
integrity = await sriForUrl(ref, cacheDir)
219+
} else if (ref.startsWith('/')) {
220+
// Strip leading basePath if present so we can read the bare
221+
// filename out of the output dir.
222+
const bareRef =
223+
basePath && ref.startsWith(basePath + '/')
224+
? ref.slice(basePath.length)
225+
: ref
226+
const localPath = path.join(walkthroughDir, bareRef)
227+
if (existsSync(localPath)) {
228+
integrity = computeIntegrity(readFileSync(localPath))
229+
}
230+
}
231+
integrityByRef.set(ref, integrity ?? '')
232+
return integrity
201233
}
202-
if (urls.size === 0) {
203-
return html
234+
235+
// Match tags carrying src/href pointing at either unpkg.com or a
236+
// same-origin absolute path. Exclude `/` from the attrs capture so
237+
// a self-closing `<link .../>` doesn't drag the slash mid-rewrite.
238+
const tagRe =
239+
/<(script|link)\s([^>/]*?\b(?:src|href)="((?:https:\/\/unpkg\.com\/|\/)[^"]+)"[^>/]*)(\s*\/?\s*>)/gi
240+
241+
// Browsers only honor `integrity` on:
242+
// <script>
243+
// <link rel=stylesheet>
244+
// <link rel=preload> / <link rel=modulepreload>
245+
// `<link rel=icon>` / `<link rel=apple-touch-icon>` ignore it, so
246+
// skip them — no point emitting hash bytes the browser throws away.
247+
const supportsSri = (tag: string, attrs: string): boolean => {
248+
if (tag.toLowerCase() === 'script') {
249+
return true
250+
}
251+
const relMatch = attrs.match(/\brel="([^"]+)"/i)
252+
if (!relMatch) {
253+
return false
254+
}
255+
return /\b(?:stylesheet|preload|modulepreload)\b/i.test(relMatch[1]!)
204256
}
205-
const integrityByUrl = new Map<string, string>()
206-
for (const url of urls) {
207-
integrityByUrl.set(url, await sriForUrl(url, cacheDir))
257+
258+
// Two-pass: first collect + resolve all refs the browser honors,
259+
// then rewrite. `matchAll` is sync so we walk once to gather refs,
260+
// resolve them, then `replace` using the populated map.
261+
for (const m of html.matchAll(tagRe)) {
262+
if (supportsSri(m[1]!, m[2]!)) {
263+
await resolveIntegrity(m[3]!)
264+
}
208265
}
209-
// Rewrite each tag. Skip tags that already carry `integrity=`.
210-
// The `attrs` capture intentionally excludes `/` so a self-closing
211-
// `<link ... />` doesn't pull the trailing slash into the attrs
212-
// string (which we'd then echo back before our injected attrs,
213-
// producing `href="..." / integrity="...">`).
214-
const tagRe =
215-
/<(script|link)\s([^>/]*?\b(?:src|href)="(https:\/\/unpkg\.com\/[^"]+)"[^>/]*)(\s*\/?\s*>)/gi
216-
return html.replace(tagRe, (full, tag, attrs, url, close) => {
266+
267+
return html.replace(tagRe, (full, tag, attrs, ref, close) => {
217268
if (/\bintegrity=/i.test(attrs)) {
218269
return full
219270
}
220-
const integrity = integrityByUrl.get(url)
271+
if (!supportsSri(tag, attrs)) {
272+
return full
273+
}
274+
const integrity = integrityByRef.get(ref)
221275
if (!integrity) {
222276
return full
223277
}
278+
// crossorigin=anonymous only makes sense (and is required for SRI)
279+
// on cross-origin requests. Same-origin tags shouldn't carry it.
280+
const crossorigin = ref.startsWith('https://')
281+
? ' crossorigin="anonymous"'
282+
: ''
224283
const trimmedAttrs = attrs.trimEnd()
225-
return `<${tag} ${trimmedAttrs} integrity="${integrity}" crossorigin="anonymous"${close}`
284+
return `<${tag} ${trimmedAttrs} integrity="${integrity}"${crossorigin}${close}`
226285
})
227286
}
228287

@@ -437,14 +496,6 @@ async function generate(
437496
html = applyBasePath(html, basePath, slug)
438497
}
439498

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

@@ -461,6 +512,24 @@ async function generate(
461512
if (minify) {
462513
await minifyEmittedAssets()
463514
}
515+
516+
// Subresource Integrity pass — runs LAST so the local-file hashes
517+
// we compute match the exact bytes that ship (post-minify). CDN
518+
// hashes are disk-cached under .cache/sri/; local ones hash the
519+
// files in `walkthroughDir` directly. Any `<script>` / `<link>`
520+
// (CDN or same-origin) ends up with `integrity="sha384-..."`.
521+
const sriCacheDir = path.join(repoRoot, '.cache', 'sri')
522+
for (const entry of readdirSync(walkthroughDir)) {
523+
if (!entry.endsWith('.html')) {
524+
continue
525+
}
526+
const htmlPath = path.join(walkthroughDir, entry)
527+
const html = readFileSync(htmlPath, 'utf8')
528+
const withSri = await injectSri(html, walkthroughDir, basePath, sriCacheDir)
529+
if (withSri !== html) {
530+
writeFileSync(htmlPath, withSri)
531+
}
532+
}
464533
}
465534

466535
/**

0 commit comments

Comments
 (0)