Files
psu-schedule/server/protect.js

78 lines
2.7 KiB
JavaScript

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(/<script(?![^>]*\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();
};
}