From f1abef50a935a5e8be0425be7d75ee4d598140cd Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Thu, 15 Oct 2026 16:20:18 +0200 Subject: [PATCH] feat(server): add security headers, compression and 404 for unknown routes Co-Authored-By: Claude Opus 5.5 --- scripts/static-server.mjs | 168 ++++++++++++++++++++++++++++++-------- 1 file changed, 136 insertions(+), 32 deletions(-) diff --git a/scripts/static-server.mjs b/scripts/static-server.mjs index a61dfa3..82c0afd 100644 --- a/scripts/static-server.mjs +++ b/scripts/static-server.mjs @@ -1,8 +1,10 @@ import { createReadStream } from 'node:fs'; -import { access, stat } from 'node:fs/promises'; +import { stat } from 'node:fs/promises'; import { createServer } from 'node:http'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; +import { createBrotliCompress, createGzip } from 'node:zlib'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -11,11 +13,15 @@ const appRoot = path.resolve(__dirname, '..'); const distRoot = path.join(appRoot, 'dist'); const browserRoot = path.join(distRoot, 'browser'); const port = 3000; +const indexPath = path.join(browserRoot, 'index.html'); +// Keep in sync with src/app/app.routes.ts. +const appRoutes = new Set(['/', '/imprint']); const mimeTypes = new Map([ ['.css', 'text/css; charset=utf-8'], ['.html', 'text/html; charset=utf-8'], ['.ico', 'image/x-icon'], + ['.jpg', 'image/jpeg'], ['.js', 'text/javascript; charset=utf-8'], ['.json', 'application/json; charset=utf-8'], ['.map', 'application/json; charset=utf-8'], @@ -23,9 +29,29 @@ const mimeTypes = new Map([ ['.svg', 'image/svg+xml'], ['.txt', 'text/plain; charset=utf-8'], ['.webp', 'image/webp'], + ['.woff2', 'font/woff2'], ['.xml', 'application/xml; charset=utf-8'], ]); +const compressibleExtensions = new Set([ + '.css', + '.html', + '.js', + '.json', + '.map', + '.svg', + '.txt', + '.xml', +]); + +const securityHeaders = { + 'Permissions-Policy': 'camera=(), geolocation=(), microphone=()', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Strict-Transport-Security': 'max-age=31536000', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', +}; + function getContentType(filePath) { return mimeTypes.get(path.extname(filePath).toLowerCase()) ?? 'application/octet-stream'; } @@ -44,9 +70,20 @@ function getCacheControl(filePath) { } if ( - ['.css', '.ico', '.js', '.json', '.map', '.png', '.svg', '.txt', '.webp', '.xml'].includes( - extension, - ) + [ + '.css', + '.ico', + '.jpg', + '.js', + '.json', + '.map', + '.png', + '.svg', + '.txt', + '.webp', + '.woff2', + '.xml', + ].includes(extension) ) { return normalizedPath.includes('/browser/') ? 'public, max-age=604800, stale-while-revalidate=86400' @@ -56,55 +93,122 @@ function getCacheControl(filePath) { return 'public, max-age=3600'; } -async function pathExists(targetPath) { +async function isFile(targetPath) { try { - await access(targetPath); - return true; + return (await stat(targetPath)).isFile(); } catch { return false; } } +function resolveInside(root, relativePath) { + const candidate = path.resolve(root, relativePath); + return candidate === root || candidate.startsWith(`${root}${path.sep}`) ? candidate : null; +} + async function resolveFile(urlPath) { const normalized = path.posix.normalize(decodeURIComponent(urlPath)); - const relativePath = normalized.replace(/^(\.\.(\/|\\|$))+/, '').replace(/^\/+/, ''); + const relativePath = normalized.replace(/^\/+/, ''); - const rootCandidate = path.join(distRoot, relativePath); - if ((await pathExists(rootCandidate)) && (await stat(rootCandidate)).isFile()) { - return rootCandidate; - } + for (const root of [distRoot, browserRoot]) { + const candidate = resolveInside(root, relativePath); + if (candidate === null) { + return null; + } - const browserCandidate = path.join(browserRoot, relativePath); - if ((await pathExists(browserCandidate)) && (await stat(browserCandidate)).isFile()) { - return browserCandidate; - } - - if (!path.posix.extname(normalized)) { - return path.join(browserRoot, 'index.html'); + if (await isFile(candidate)) { + return candidate; + } } return null; } +function getEncoding(request, filePath) { + if (!compressibleExtensions.has(path.extname(filePath).toLowerCase())) { + return null; + } + + const acceptEncoding = request.headers['accept-encoding'] ?? ''; + if (/\bbr\b/.test(acceptEncoding)) { + return 'br'; + } + + return /\bgzip\b/.test(acceptEncoding) ? 'gzip' : null; +} + +async function sendFile(request, response, filePath, statusCode) { + const encoding = getEncoding(request, filePath); + const headers = { + ...securityHeaders, + 'Cache-Control': statusCode === 200 ? getCacheControl(filePath) : 'no-cache', + 'Content-Type': getContentType(filePath), + Vary: 'Accept-Encoding', + }; + + if (encoding !== null) { + headers['Content-Encoding'] = encoding; + } + + response.writeHead(statusCode, headers); + + if (request.method === 'HEAD') { + response.end(); + return; + } + + const source = createReadStream(filePath); + if (encoding === null) { + await pipeline(source, response); + return; + } + + await pipeline(source, encoding === 'br' ? createBrotliCompress() : createGzip(), response); +} + +function sendText(response, statusCode, message, extraHeaders = {}) { + response.writeHead(statusCode, { + ...securityHeaders, + ...extraHeaders, + 'Content-Type': 'text/plain; charset=utf-8', + }); + response.end(message); +} + const server = createServer(async (request, response) => { try { - const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`); - const filePath = await resolveFile(requestUrl.pathname); - - if (!filePath) { - response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); - response.end('Not found'); + if (request.method !== 'GET' && request.method !== 'HEAD') { + sendText(response, 405, 'Method not allowed', { Allow: 'GET, HEAD' }); return; } - response.writeHead(200, { - 'Cache-Control': getCacheControl(filePath), - 'Content-Type': getContentType(filePath), - }); - createReadStream(filePath).pipe(response); + const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`); + const filePath = await resolveFile(requestUrl.pathname); + + if (filePath) { + await sendFile(request, response, filePath, 200); + return; + } + + const routePath = requestUrl.pathname.replace(/(.)\/+$/, '$1'); + if (appRoutes.has(routePath)) { + await sendFile(request, response, indexPath, 200); + return; + } + + // Unknown routes get a 404 status but still boot the app, which redirects to the start page. + if (!path.posix.extname(requestUrl.pathname)) { + await sendFile(request, response, indexPath, 404); + return; + } + + sendText(response, 404, 'Not found'); } catch (error) { - response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); - response.end('Internal server error'); + if (!response.headersSent) { + sendText(response, 500, 'Internal server error'); + } else { + response.destroy(); + } console.error(error); } });