Skip to content

Commit 37a18db

Browse files
committed
csp: replace 'https://unpkg.com' origin with per-script SRI hashes
Previous CSP listed https://unpkg.com in script-src / style-src, which allows ANY script served from unpkg. That trusts the CDN more than we want — if unpkg were compromised and served a different marked/highlight bundle, the origin allowlist would let it through and we'd rely solely on SRI to catch it. Pull the sha384 hashes already computed during the SRI pass off the rendered <script src> / <link rel=stylesheet href> tags and emit them as CSP allowlist entries. Two layers of byte-matching: 1. CSP refuses to load a script whose hash isn't allowlisted. 2. SRI refuses to execute a loaded script whose hash doesn't match. Same-origin tags don't need explicit CSP hashes (covered by 'self' + their own SRI attribute). CSP hash-matching is cross-origin-only.
1 parent a1bdea2 commit 37a18db

1 file changed

Lines changed: 29 additions & 10 deletions

File tree

scripts/walkthrough.mts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -173,30 +173,49 @@ function computeIntegrity(bytes: Uint8Array): string {
173173
* default-src self (fallback for anything not listed)
174174
*/
175175
function buildCspMeta(html: string, commentBackend: string): string {
176-
// Collect each inline script body, hash it, prefix with sha256-.
176+
// Collect each inline script body, hash it as sha256.
177177
// Meander + our post-processor both emit scripts with `<script>...`
178178
// (no src attr) — match those, skip `<script src=...>`.
179179
const inlineRe = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi
180-
const scriptHashes = new Set<string>()
180+
const inlineScriptHashes = new Set<string>()
181181
for (const m of html.matchAll(inlineRe)) {
182182
const body = m[1]!
183-
// CSP hashes the script body verbatim, including surrounding
184-
// whitespace. We don't normalize — browsers hash what they get.
185183
const hash = cryptoHash('sha256', body, 'base64')
186-
scriptHashes.add(`'sha256-${hash}'`)
184+
inlineScriptHashes.add(`'sha256-${hash}'`)
187185
}
188-
const scriptSources = ["'self'", 'https://unpkg.com', ...scriptHashes].join(
189-
' ',
190-
)
186+
187+
// Pull the already-computed SRI hashes off cross-origin <script src>
188+
// and <link rel=stylesheet href> tags. CSP accepts sha256/384/512
189+
// hashes directly as allowlist entries — this is stricter than
190+
// listing the CDN origin, because a compromised unpkg serving a
191+
// different bundle fails the CSP check *before* SRI even runs.
192+
// Same-origin tags don't need hashes here (covered by `'self'` +
193+
// their own SRI attribute).
194+
const cdnScriptHashes = new Set<string>()
195+
const cdnStyleHashes = new Set<string>()
196+
const scriptIntegrityRe =
197+
/<script[^>]*\bsrc="https:[^"]*"[^>]*\bintegrity="(sha\d+-[^"]+)"/gi
198+
const styleIntegrityRe =
199+
/<link[^>]*\brel="stylesheet"[^>]*\bhref="https:[^"]*"[^>]*\bintegrity="(sha\d+-[^"]+)"/gi
200+
for (const m of html.matchAll(scriptIntegrityRe)) {
201+
cdnScriptHashes.add(`'${m[1]!}'`)
202+
}
203+
for (const m of html.matchAll(styleIntegrityRe)) {
204+
cdnStyleHashes.add(`'${m[1]!}'`)
205+
}
206+
207+
const scriptSources = ["'self'", ...inlineScriptHashes, ...cdnScriptHashes]
208+
const styleSources = ["'self'", ...cdnStyleHashes]
209+
191210
const connectSources = ["'self'"]
192211
if (commentBackend) {
193212
const origin = new URL(commentBackend).origin
194213
connectSources.push(origin)
195214
}
196215
const directives = [
197216
`default-src 'self'`,
198-
`script-src ${scriptSources}`,
199-
`style-src 'self' https://unpkg.com`,
217+
`script-src ${scriptSources.join(' ')}`,
218+
`style-src ${styleSources.join(' ')}`,
200219
`img-src 'self' data:`,
201220
`connect-src ${connectSources.join(' ')}`,
202221
`worker-src 'self'`,

0 commit comments

Comments
 (0)