diff options
Diffstat (limited to 'web/serve.mjs')
| -rw-r--r-- | web/serve.mjs | 43 |
1 files changed, 40 insertions, 3 deletions
diff --git a/web/serve.mjs b/web/serve.mjs index 64d6339..8f5644e 100644 --- a/web/serve.mjs +++ b/web/serve.mjs @@ -10,6 +10,43 @@ const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url))); const PORT = Number(process.env.PORT) || 4173; const HOST = process.env.HOST || '0.0.0.0'; +// Sent with every response. +// +// The API origin is separate during development, so connect-src has to allow +// it. In production the reverse proxy makes the API same-origin and +// API_ORIGIN can be left unset, which drops the extra source. +const API_ORIGIN = process.env.API_ORIGIN || (PORT === 4173 ? 'http://localhost:8787 ws://localhost:8787' : ''); + +const SECURITY_HEADERS = { + // No inline script: index.html loads every script from a file so this can + // stay strict. Style needs Google Fonts' stylesheet, and the font files + // come from gstatic. The favicon is a data: URI. + 'Content-Security-Policy': [ + "default-src 'self'", + // wasm-unsafe-eval is required to instantiate WebAssembly. It permits + // WASM compilation only, not eval() or inline script, and the typing + // engine is a WASM module - without it nothing types at all. + "script-src 'self' 'wasm-unsafe-eval'", + "style-src 'self' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data:", + `connect-src 'self'${API_ORIGIN ? ' ' + API_ORIGIN : ''}`, + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + "object-src 'none'", + ].join('; '), + // Stops a browser second-guessing a declared Content-Type, which is how a + // served file gets treated as script. + 'X-Content-Type-Options': 'nosniff', + // frame-ancestors covers this for modern browsers; the header is for the + // ones that do not implement it. + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + // Nothing here uses any of them. + 'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=(), usb=()', +}; + const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', @@ -51,7 +88,7 @@ async function resolveFile(urlPath) { const server = createServer(async (req, res) => { const filePath = (await resolveFile(req.url)) || (await resolveFile('/index.html')); if (!filePath) { - res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.writeHead(404, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' }); res.end('Not found'); return; } @@ -62,10 +99,10 @@ const server = createServer(async (req, res) => { // development - no cache headers at all left the browser free to // hold onto old JS/CSS indefinitely with no way to tell it changed, // which has already caused a real "why don't I see the fix" report. - res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache' }); + res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': type, 'Cache-Control': 'no-cache' }); res.end(body); } catch (err) { - res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.writeHead(500, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' }); res.end('Internal server error'); console.error(err); } |