Skip to content

Commit 679b5d2

Browse files
committed
feat: add CSP generation and serving scripts for enhanced security in built docs
1 parent 7907ca9 commit 679b5d2

3 files changed

Lines changed: 155 additions & 1 deletion

File tree

docs/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
"scripts": {
66
"docusaurus": "docusaurus",
77
"start": "docusaurus start",
8-
"build": "docusaurus build",
8+
"build": "docusaurus build && node scripts/generate-csp.mjs",
99
"swizzle": "docusaurus swizzle",
1010
"deploy": "docusaurus deploy",
1111
"clear": "docusaurus clear",
1212
"serve": "docusaurus serve",
13+
"serve:csp": "node scripts/serve-csp.mjs",
1314
"write-translations": "docusaurus write-translations",
1415
"write-heading-ids": "docusaurus write-heading-ids",
1516
"typecheck": "tsc"

docs/scripts/generate-csp.mjs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Generates a Content-Security-Policy for the built docs site.
3+
*
4+
* Docusaurus emits a small number of inline <script> tags (the color-mode/theme bootstrap and
5+
* the base-url-issue banner). Allowing them via 'unsafe-inline' defeats the purpose of a CSP,
6+
* so instead this script scans the build output, computes the sha256 hash of every executable
7+
* inline script, and produces a policy that allows exactly those scripts and nothing else.
8+
*
9+
* Run automatically as part of `npm run build`. Outputs:
10+
* 1. `build/_headers` — applied automatically when hosting on Cloudflare Pages
11+
* 2. `build/serve.json` — applies the same header when testing locally via `npx serve build`
12+
* 3. The policy printed to stdout — paste into a Cloudflare Response Header Transform Rule
13+
* if the header is managed at the zone level instead
14+
*
15+
* Hashes are derived from the build output itself, so Docusaurus upgrades or config changes
16+
* that alter the inline scripts are picked up automatically on the next build.
17+
*/
18+
import { createHash } from 'node:crypto';
19+
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
20+
import { join } from 'node:path';
21+
import { fileURLToPath } from 'node:url';
22+
23+
const BUILD_DIR = fileURLToPath(new URL('../build', import.meta.url));
24+
25+
// Script types that browsers execute and CSP script-src applies to.
26+
// Anything else (e.g. application/ld+json structured data) is inert and needs no hash.
27+
const EXECUTABLE_SCRIPT_TYPES = new Set(['', 'module', 'text/javascript', 'application/javascript']);
28+
29+
const SCRIPT_TAG_REGEX = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
30+
31+
function collectHtmlFiles(dir) {
32+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
33+
const fullPath = join(dir, entry.name);
34+
if (entry.isDirectory()) {
35+
return collectHtmlFiles(fullPath);
36+
}
37+
return entry.name.endsWith('.html') ? [fullPath] : [];
38+
});
39+
}
40+
41+
function getInlineScriptHashes() {
42+
const hashes = new Set();
43+
for (const htmlFile of collectHtmlFiles(BUILD_DIR)) {
44+
const html = readFileSync(htmlFile, 'utf-8');
45+
for (const [, attrs, body] of html.matchAll(SCRIPT_TAG_REGEX)) {
46+
const hasSrc = /\bsrc\s*=/i.test(attrs);
47+
const type = (attrs.match(/\btype\s*=\s*["']?([^"'\s>]*)/i)?.[1] ?? '').toLowerCase();
48+
if (hasSrc || !EXECUTABLE_SCRIPT_TYPES.has(type) || !body.trim()) {
49+
continue;
50+
}
51+
// CSP hashes are computed over the exact script content, byte for byte
52+
hashes.add(`'sha256-${createHash('sha256').update(body).digest('base64')}'`);
53+
}
54+
}
55+
return [...hashes].sort();
56+
}
57+
58+
const scriptHashes = getInlineScriptHashes();
59+
60+
const csp = [
61+
`default-src 'self'`,
62+
`script-src 'self' ${scriptHashes.join(' ')}`,
63+
`style-src 'self' 'unsafe-inline'`,
64+
`img-src 'self' data:`,
65+
`font-src 'self' data:`,
66+
`connect-src 'self'`,
67+
`frame-ancestors 'none'`,
68+
`base-uri 'self'`,
69+
`form-action 'self'`,
70+
`object-src 'none'`,
71+
`upgrade-insecure-requests`,
72+
].join('; ');
73+
74+
const headers = {
75+
'Content-Security-Policy': csp,
76+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
77+
'Referrer-Policy': 'strict-origin-when-cross-origin',
78+
'X-Content-Type-Options': 'nosniff',
79+
};
80+
81+
const headerLines = Object.entries(headers)
82+
.map(([key, value]) => ` ${key}: ${value}`)
83+
.join('\n');
84+
writeFileSync(join(BUILD_DIR, '_headers'), `/*\n${headerLines}\n`);
85+
86+
const serveConfig = {
87+
headers: [{ source: '**', headers: Object.entries(headers).map(([key, value]) => ({ key, value })) }],
88+
};
89+
writeFileSync(join(BUILD_DIR, 'serve.json'), `${JSON.stringify(serveConfig, null, 2)}\n`);
90+
91+
console.log(`Found ${scriptHashes.length} inline script hash(es):`);
92+
scriptHashes.forEach((hash) => console.log(` ${hash}`));
93+
console.log('\nWrote build/_headers with the following policy:\n');
94+
console.log(csp);

docs/scripts/serve-csp.mjs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Serves the built docs site locally with the security headers from `build/_headers` applied
3+
* to every response, so header changes can be tested in a real browser before deploy.
4+
* (`docusaurus serve` cannot set custom headers, hence this script.)
5+
*
6+
* Usage: node scripts/serve-csp.mjs [port] (default port 3939)
7+
*/
8+
import { existsSync, readFileSync } from 'node:fs';
9+
import { createServer } from 'node:http';
10+
import { extname, join, normalize } from 'node:path';
11+
import { fileURLToPath } from 'node:url';
12+
13+
const BUILD_DIR = fileURLToPath(new URL('../build', import.meta.url));
14+
const PORT = Number(process.argv[2]) || 3939;
15+
16+
const headersFile = readFileSync(join(BUILD_DIR, '_headers'), 'utf-8');
17+
const securityHeaders = [...headersFile.matchAll(/^\s+([\w-]+):\s*(.+)$/gm)].map(([, key, value]) => [key, value.trim()]);
18+
if (!securityHeaders.some(([key]) => key === 'Content-Security-Policy')) {
19+
console.error('No Content-Security-Policy found in build/_headers — run a build first');
20+
process.exit(1);
21+
}
22+
23+
const CONTENT_TYPES = {
24+
'.html': 'text/html; charset=utf-8',
25+
'.js': 'text/javascript',
26+
'.css': 'text/css',
27+
'.json': 'application/json',
28+
'.svg': 'image/svg+xml',
29+
'.png': 'image/png',
30+
'.jpg': 'image/jpeg',
31+
'.gif': 'image/gif',
32+
'.ico': 'image/x-icon',
33+
'.xml': 'application/xml',
34+
'.txt': 'text/plain',
35+
'.woff': 'font/woff',
36+
'.woff2': 'font/woff2',
37+
};
38+
39+
// Mirrors static host routing: exact file, then `page.html`, then `page/index.html`, then 404
40+
function resolveFile(urlPath) {
41+
const safePath = normalize(decodeURIComponent(urlPath)).replace(/^(\.\.[/\\])+/, '');
42+
const candidates = [
43+
join(BUILD_DIR, safePath, safePath.endsWith('/') || safePath === '' ? 'index.html' : ''),
44+
join(BUILD_DIR, safePath),
45+
join(BUILD_DIR, `${safePath}.html`),
46+
join(BUILD_DIR, safePath, 'index.html'),
47+
];
48+
return candidates.find((candidate) => existsSync(candidate) && extname(candidate) !== '') ?? join(BUILD_DIR, '404.html');
49+
}
50+
51+
createServer((req, res) => {
52+
const filePath = resolveFile(new URL(req.url, 'http://localhost').pathname.slice(1));
53+
res.setHeader('Content-Type', CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream');
54+
securityHeaders.forEach(([key, value]) => res.setHeader(key, value));
55+
res.end(readFileSync(filePath));
56+
}).listen(PORT, () => {
57+
console.log(`Serving build/ with headers from build/_headers at http://localhost:${PORT}`);
58+
securityHeaders.forEach(([key, value]) => console.log(` ${key}: ${value}`));
59+
});

0 commit comments

Comments
 (0)