import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; // Protection for a server open to the internet: security headers for the PWA and a time limit // for every request. The request rate limit lives in limits.js. /** * Hashes of the inline scripts in the built index.html (the theme applied before the first * paint), so the Content-Security-Policy allows exactly them and no other inline code. */ function inlineScriptHashes(indexHtml) { let html = ""; try { html = readFileSync(indexHtml, "utf8"); } catch {} return [...html.matchAll(/]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)].map( (m) => `'sha256-${createHash("sha256").update(m[1]).digest("base64")}'`, ); } /** * Everything the PWA uses comes from this server: scripts, styles, the Roboto Flex font, icons, * the manifest and the service worker. Links to the university's Nextcloud are plain links. */ export function securityHeaders(indexHtml) { const csp = [ "default-src 'self'", `script-src 'self' ${inlineScriptHashes(indexHtml).join(" ")}`.trim(), "style-src 'self'", "img-src 'self' data:", "font-src 'self'", "connect-src 'self'", "manifest-src 'self'", "worker-src 'self'", "object-src 'none'", "base-uri 'none'", "form-action 'self'", "frame-ancestors 'none'", ].join("; "); return (req, res, next) => { res.set({ "Content-Security-Policy": csp, "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin-when-cross-origin", "Cross-Origin-Opener-Policy": "same-origin", "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()", }); // Only over HTTPS (the proxy says so in X-Forwarded-Proto): browsers ignore it otherwise, // and on a plain http://localhost it would only get in the way. if (req.secure) res.set("Strict-Transport-Security", "max-age=15552000"); next(); }; } /** * A request that has not been answered in `ms` gets 504 `timeout` instead of hanging; whatever * the handler sends later is dropped. Waiting for the university is already capped at 25 s. */ export function requestTimeout(ms, longer = {}) { return (req, res, next) => { const limit = longer[req.path] || ms; const timer = setTimeout(() => { if (res.headersSent) return; res.set("Retry-After", "30"); res.status(504).json({ error: "Сервер не успел ответить. Попробуйте ещё раз через минуту.", code: "timeout", }); }, limit); timer.unref(); const clear = () => clearTimeout(timer); res.on("finish", clear); res.on("close", clear); next(); }; }