78 lines
2.7 KiB
JavaScript
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();
|
|
};
|
|
}
|