srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/serve.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'web/serve.mjs')
-rw-r--r--web/serve.mjs43
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);
}