Skip to content

Commit a1bdea2

Browse files
committed
feat(csp): inject per-page Content-Security-Policy meta tag
Tight CSP with no 'unsafe-inline'. Each inline <script> body is sha256-hashed and allowlisted individually, so we avoid blanket permissiveness while still supporting: - meander's hljs bootstrap (<script>marked.setOptions...</script>) - our SW register block - the __defIndex + socketWalkthrough config blocks Directives: script-src self + unpkg + per-inline hashes style-src self + unpkg (no inline styles) img-src self + data: (CSS validity icons use data URIs) connect-src self + val backend (when configured) worker-src self base-uri self form-action self frame-ancestors none (clickjacking protection) default-src self (catchall) Runs in the post-minify SRI pass — same file-walking structure, same per-file writeback. Per-page because __defIndex varies per part, so the inline-script hash set differs per HTML file.
1 parent c82ca9c commit a1bdea2

1 file changed

Lines changed: 64 additions & 4 deletions

File tree

scripts/walkthrough.mts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,57 @@ function computeIntegrity(bytes: Uint8Array): string {
157157
return `sha384-${cryptoHash('sha384', bytes, 'base64')}`
158158
}
159159

160+
/**
161+
* Build a `<meta http-equiv="Content-Security-Policy">` tag for a
162+
* specific HTML page. Inline `<script>` blocks (meander's hljs
163+
* bootstrap, our SW register, __defIndex, socketWalkthrough config)
164+
* are individually sha256-hashed and allowlisted so we can avoid
165+
* `'unsafe-inline'`. All remaining directives are tight:
166+
* script-src self + unpkg + per-script hashes
167+
* style-src self + unpkg (no inline styles generated)
168+
* connect-src self + val backend (when configured)
169+
* img-src self + data: (CSS validity icons use data URIs)
170+
* worker-src self (service worker)
171+
* base-uri, form-action self
172+
* frame-ancestors none (clickjacking protection)
173+
* default-src self (fallback for anything not listed)
174+
*/
175+
function buildCspMeta(html: string, commentBackend: string): string {
176+
// Collect each inline script body, hash it, prefix with sha256-.
177+
// Meander + our post-processor both emit scripts with `<script>...`
178+
// (no src attr) — match those, skip `<script src=...>`.
179+
const inlineRe = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi
180+
const scriptHashes = new Set<string>()
181+
for (const m of html.matchAll(inlineRe)) {
182+
const body = m[1]!
183+
// CSP hashes the script body verbatim, including surrounding
184+
// whitespace. We don't normalize — browsers hash what they get.
185+
const hash = cryptoHash('sha256', body, 'base64')
186+
scriptHashes.add(`'sha256-${hash}'`)
187+
}
188+
const scriptSources = ["'self'", 'https://unpkg.com', ...scriptHashes].join(
189+
' ',
190+
)
191+
const connectSources = ["'self'"]
192+
if (commentBackend) {
193+
const origin = new URL(commentBackend).origin
194+
connectSources.push(origin)
195+
}
196+
const directives = [
197+
`default-src 'self'`,
198+
`script-src ${scriptSources}`,
199+
`style-src 'self' https://unpkg.com`,
200+
`img-src 'self' data:`,
201+
`connect-src ${connectSources.join(' ')}`,
202+
`worker-src 'self'`,
203+
`base-uri 'self'`,
204+
`form-action 'self'`,
205+
`frame-ancestors 'none'`,
206+
]
207+
const content = directives.join('; ')
208+
return `<meta http-equiv="Content-Security-Policy" content="${content}" />`
209+
}
210+
160211
/**
161212
* Compute / look up the SRI hash for a CDN URL. Disk-cached under
162213
* `.cache/sri/<base64url(url)>.txt` so repeat builds don't refetch.
@@ -524,10 +575,19 @@ async function generate(
524575
continue
525576
}
526577
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)
578+
let html = readFileSync(htmlPath, 'utf8')
579+
const originalHtml = html
580+
html = await injectSri(html, walkthroughDir, basePath, sriCacheDir)
581+
// CSP meta — must run AFTER SRI injection so the hash of each
582+
// inline <script> reflects its final body (no further rewrites).
583+
// Per-file because __defIndex varies per-part; pages also differ
584+
// in which inline blocks land (index vs part pages).
585+
if (!html.includes('http-equiv="Content-Security-Policy"')) {
586+
const cspTag = buildCspMeta(html, commentBackend)
587+
html = html.replace('</head>', ` ${cspTag}\n</head>`)
588+
}
589+
if (html !== originalHtml) {
590+
writeFileSync(htmlPath, html)
531591
}
532592
}
533593
}

0 commit comments

Comments
 (0)