From 85c5baa3e561b400d39599a56fea3a17fe56679d Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Mon, 19 Oct 2026 23:15:05 +0300 Subject: [PATCH] feat: Implement rate limiting, request timeouts, and security headers for improved server protection --- README.md | 6 +++ server/errors.js | 1 + server/index.js | 36 ++++++++++++++--- server/limits.js | 21 +++++++--- server/protect.js | 77 +++++++++++++++++++++++++++++++++++++ test/api.test.js | 75 +++--------------------------------- test/protect.test.js | 74 +++++++++++++++++++++++++++++++++++ test/support/nextcloud.js | 81 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 291 insertions(+), 80 deletions(-) create mode 100644 server/protect.js create mode 100644 test/protect.test.js create mode 100644 test/support/nextcloud.js diff --git a/README.md b/README.md index 7ca88a6..f6b2e2f 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api` К каталогу университета сервер обращается встроенным в Node `fetch`: соединения переиспользуются, сертификаты проверяются, перенаправления не принимаются, ответ ограничен 10 МБ и 25 секундами. Обход каталога (около сотни папок) занимает несколько секунд. +**Защита открытого сервера.** +- **Лимит запросов.** К `/api` — не больше 300 запросов в минуту с одного адреса, сверх лимита ответ `429 too_many_requests` с `Retry-After`. Лимит щедрый, потому что студенты в общежитии или в сети университета выходят в интернет с одного адреса: он ловит только ботов и зациклившиеся клиенты. Проверки здоровья (`/api/health`, `/api/ready`) не считаются. Импорт Word ограничен отдельно: 10 файлов за 10 минут. +- **Время ответа.** Запрос, на который сервер не ответил за 30 секунд (импорт — за 90), получает `504 timeout`. Клиент должен прислать заголовки за 15 секунд, а весь запрос — за две минуты. +- **Заголовки безопасности.** Все ответы несут строгую `Content-Security-Policy`: только свои скрипты, стили, шрифты и запросы, а единственный inline-скрипт (тема до первой отрисовки) разрешён по хэшу, который сервер считает из собранного `dist/index.html`. Кроме неё: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`. За HTTPS-прокси, который передаёт `X-Forwarded-Proto`, добавляется `Strict-Transport-Security`. +- **Адрес клиента за прокси.** Лимит считается по настоящему адресу клиента. Сервер верит `X-Forwarded-For` только от прокси из локальной сети (`127.0.0.1`, `10.*`, `172.16–31.*`, `192.168.*`). Поле `client` в `/api/health` показывает, кем сервер тебя видит: если там адрес прокси, а не твой, все посетители делят один лимит, и прокси нужно настроить на передачу `X-Forwarded-For`. + `GET /api/ready` отвечает `200`, когда каталог, таблица недель и индекс всех документов загружены, а до этого — `503` с кодом `not_ready` и списком того, что ещё грузится (`waiting`). На него смотрят проверка здоровья Docker и прокси. `GET /api/health` всегда отвечает `200`, пока процесс жив (поле `ready` — то же, что `/api/ready`), и без обращения к сети показывает выбранный учебный год, возраст каталога, найденные файлы недель и звонков, семестры и предупреждения: папка нового года не найдена, сегодняшняя дата вне таблицы недель, источник не обновляется. ```sh diff --git a/server/errors.js b/server/errors.js index 5c15233..b99a1aa 100644 --- a/server/errors.js +++ b/server/errors.js @@ -14,6 +14,7 @@ * 502 source_invalid the catalog answered, but its folders or tables are not recognised * 503 not_ready /api/ready only: the server is still loading the catalog or its index * 504 source_timeout the university catalog answered too slowly + * 504 timeout the server did not answer within its time limit * 500 internal a fault of the server itself */ export class ApiError extends Error { diff --git a/server/index.js b/server/index.js index a4bd4f8..0cdb997 100644 --- a/server/index.js +++ b/server/index.js @@ -10,6 +10,7 @@ import { apiError, describe } from "./errors.js"; import { createHash } from "node:crypto"; import { timetable } from "./timetable.js"; import { catalogDiagnostics } from "./diagnostics.js"; +import { securityHeaders, requestTimeout } from "./protect.js"; import { getIndex, indexStatus, @@ -35,8 +36,23 @@ app.disable("x-powered-by"); // Behind Caddy or a tunnel on this host, the client's address comes from X-Forwarded-For; // only proxies on private networks are believed, so a client cannot forge its own. app.set("trust proxy", "loopback, linklocal, uniquelocal"); +const dist = fileURLToPath(new URL("../dist/", import.meta.url)); +app.use(securityHeaders(dist + "index.html")); // gzip or brotli: a semester of lessons is about 200 KB of JSON and shrinks tenfold. app.use(compression()); +// A generous limit per address: students behind one dormitory or university NAT share it, so +// it only stops scrapers and runaway clients. Health checks are not counted. +app.use( + "/api", + rateLimit({ + max: Number(process.env.PSU_API_RATE_LIMIT) || 300, + windowMs: 60 * 1000, + message: "Слишком много запросов с вашего адреса. Подождите минуту.", + skip: (req) => req.path === "/health" || req.path === "/ready", + }), +); +// Parsing an import may wait for a LibreOffice conversion and other imports. +app.use("/api", requestTimeout(Number(process.env.PSU_REQUEST_TIMEOUT_MS) || 30_000, { "/import": 90_000 })); app.use("/api", (_req, res, next) => { // Clients may keep a response but must ask again each time; Express's ETag turns an // unchanged answer into an empty 304, so a phone re-downloads a timetable only when it changed. @@ -49,8 +65,9 @@ app.use("/api", (_req, res, next) => { // 5 — error codes (`code` beside `error`, see server/errors.js), timetable 304 before any work; // 6 — the timetable carries the group's exams and credits from other catalog documents; // 7 — /api/diagnostics, cells the parser could not read with confidence; -// 8 — /api/ready, and `ready` in /api/health. -const API_VERSION = 8; +// 8 — /api/ready, and `ready` in /api/health; +// 9 — a request limit on /api (429 too_many_requests), a 30 s time limit (504 timeout), security headers. +const API_VERSION = 9; /** * What the server still waits for before it answers from memory: the catalog, the week table @@ -59,10 +76,13 @@ const API_VERSION = 8; const waitingFor = (sources, directory) => [!sources.catalog && "catalog", !sources.calendar && "calendar", !directory.builtAt && "index"].filter(Boolean); // Liveness plus what the server currently reads from the university, without network calls. -app.get("/api/health", async (_req, res) => { +app.get("/api/health", async (req, res) => { const sources = await sourceStatus().catch((e) => ({ error: e.message })); res.json({ ok: true, + // The caller's address as the server sees it: behind a proxy it must be the client's own, + // or every visitor shares one request limit. + client: req.ip, ready: waitingFor(sources, indexStatus()).length === 0, apiVersion: API_VERSION, parserVersion: PARSER_VERSION, @@ -264,7 +284,6 @@ app.post("/api/import", importLimit, upload.single("file"), async (req, res, nex next(e.status || e.code === "LIMIT_FILE_SIZE" ? e : apiError(422, "unreadable_file", e.message)); } }); -const dist = fileURLToPath(new URL("../dist/", import.meta.url)); app.use(express.static(dist, { maxAge: 0 })); app.get("/{*path}", (req, res, next) => req.path.startsWith("/api/") @@ -273,6 +292,8 @@ app.get("/{*path}", (req, res, next) => ); app.use((error, req, res, _next) => { const { status, code, message } = describe(error); + // Answered already, by the time limit: the late error is only logged. + if (res.headersSent) return console.error(`${req.method} ${req.originalUrl} после ответа: ${error.message}`); // Faults of the server keep their stack; expected errors need a line. if (status >= 500 && code === "internal") console.error(req.method, req.originalUrl, error); else console.error(`${req.method} ${req.originalUrl} → ${status} ${code}: ${error.message}`); @@ -282,12 +303,17 @@ export { app }; /** Starts listening and loads the week table and the whole catalog in the background. */ export function start(port = Number(process.env.PORT || 3001), host = process.env.HOST || "127.0.0.1") { - return app.listen(port, host, () => { + const server = app.listen(port, host, () => { console.log(`API и PWA: http://127.0.0.1:${port}`); // So the first visit and the first search do not wait for the university. getCalendar().catch((e) => console.error(`Таблица недель: ${e.message}`)); getIndex().catch((e) => console.error(`Индекс: ${e.message}`)); }); + // A client must send its headers within 15 s and the whole request (a Word upload of up to + // 10 MB on a slow phone) within two minutes, so slow connections cannot pile up. + server.headersTimeout = 15_000; + server.requestTimeout = 120_000; + return server; } // `node server/index.js` serves; tests import the app without listening. if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) start(); diff --git a/server/limits.js b/server/limits.js index df2722c..84bf0b4 100644 --- a/server/limits.js +++ b/server/limits.js @@ -5,16 +5,25 @@ import { apiError } from "./errors.js"; * conversion and seconds of CPU, so one client must not be able to occupy the server. */ -/** At most `max` requests per client address in each `windowMs`; the rest get 429. */ -export function rateLimit({ max, windowMs, message }) { +/** + * At most `max` requests per client address in each `windowMs`; the rest get 429. + * `skip(req)` exempts a request, such as a health check, from counting. + */ +export function rateLimit({ max, windowMs, message, skip = () => false }) { const windows = new Map(); + let swept = 0; return (req, res, next) => { + if (skip(req)) return next(); const now = Date.now(); - // Finished windows are dropped on the way, so the map holds only active clients. - for (const [key, w] of windows) if (now - w.start >= windowMs) windows.delete(key); - const w = windows.get(req.ip) ?? { start: now, count: 0 }; + // Finished windows are dropped, at most once a second, so the map holds only active clients + // and a crowd of addresses does not make every request walk the whole map. + if (now - swept >= 1000) { + swept = now; + for (const [key, w] of windows) if (now - w.start >= windowMs) windows.delete(key); + } + let w = windows.get(req.ip); + if (!w || now - w.start >= windowMs) windows.set(req.ip, (w = { start: now, count: 0 })); w.count++; - windows.set(req.ip, w); if (w.count <= max) return next(); res.set("Retry-After", String(Math.ceil((w.start + windowMs - now) / 1000))); res.status(429).json({ error: message, code: "too_many_requests" }); diff --git a/server/protect.js b/server/protect.js new file mode 100644 index 0000000..3b3e044 --- /dev/null +++ b/server/protect.js @@ -0,0 +1,77 @@ +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(); + }; +} diff --git a/test/api.test.js b/test/api.test.js index eabe4e5..a6ceca0 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -1,79 +1,15 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; -import { createServer } from "node:http"; -import { readFileSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { academicStartYear, todayIso } from "../shared/weeks.js"; +import { startApi, fixture } from "./support/nextcloud.js"; // The API over HTTP, as the Android and iOS clients see it: the real server against a stand-in // for the university's Nextcloud that serves the fixtures as a small catalog. -const SHARE = "/public.php/dav/files/no5zKbMrns6j5jQ/"; -const fixture = (name) => readFileSync(new URL(`./fixtures/${name}`, import.meta.url)); -const year = academicStartYear(todayIso()); -const YEAR = `${year}-${year + 1}`; -const FOLDER = `${SHARE}${YEAR}/Финансово-экономический факультет/Дневная форма/`; -const MODIFIED = "Mon, 14 Sep 2026 13:05:15 GMT"; -const files = { - [`${SHARE}Расписание белых и зеленых недель.docx`]: fixture("weeks.docx"), - [`${SHARE}Расписание звонков.docx`]: fixture("bells.docx"), - [`${FOLDER}23-ЛГ_23-БА.docx`]: fixture("fef-23-ba.docx"), - [`${FOLDER}24-ИТ.docx`]: fixture("fit-daily.docx"), - // The university fails on one document and is too slow on another. - [`${FOLDER}Сломанный.docx`]: 500, - [`${FOLDER}Медленный.docx`]: "slow", -}; -const folders = [SHARE, `${SHARE}${YEAR}/`, `${SHARE}${YEAR}/Финансово-экономический факультет/`, FOLDER]; - -const encode = (path) => path.split("/").map(encodeURIComponent).join("/"); -const listing = (folder) => { - const children = [...folders.filter((f) => f !== folder && f.startsWith(folder) && !f.slice(folder.length, -1).includes("/")), - ...Object.keys(files).filter((f) => f.startsWith(folder) && !f.slice(folder.length).includes("/"))]; - const entry = (path) => { - const folder = path.endsWith("/"); - const size = Buffer.isBuffer(files[path]) ? files[path].length : 1000; - return `${encode(path)}${ - folder ? "" : `${size}` - }${MODIFIED}HTTP/1.1 200 OK`; - }; - return `${[folder, ...children].map(entry).join("")}`; -}; - -let source, server, base, data; -const stats = { requests: 0, connections: 0 }; - +let api, base, stats; before(async () => { - source = createServer((req, res) => { - stats.requests++; - const path = decodeURIComponent(req.url); - if (req.method === "PROPFIND" && folders.includes(path)) { - res.writeHead(207, { "Content-Type": "application/xml" }); - return res.end(listing(path)); - } - const body = files[path]; - if (body === "slow") return setTimeout(() => res.end("late"), 2000); - if (typeof body === "number" || !body) return res.writeHead(body || 404).end(); - res.end(body); - }); - source.on("connection", () => stats.connections++); - await new Promise((resolve) => source.listen(0, "127.0.0.1", resolve)); - data = mkdtempSync(join(tmpdir(), "psu-api-")); - process.env.PSU_SOURCE_ORIGIN = `http://127.0.0.1:${source.address().port}`; - process.env.PSU_DATA_DIR = data; - process.env.PSU_SOURCE_TIMEOUT_MS = "500"; - const { app } = await import("../server/index.js"); - server = app.listen(0, "127.0.0.1"); - await new Promise((resolve) => server.on("listening", resolve)); - base = `http://127.0.0.1:${server.address().port}`; -}); - -after(() => { - server?.close(); - server?.closeAllConnections(); - source?.closeAllConnections(); - source?.close(); - rmSync(data, { recursive: true, force: true }); + api = await startApi(); + ({ base, stats } = api); }); +after(() => api?.stop()); const get = (path, headers = {}) => fetch(base + path, { headers }); const json = async (path) => { @@ -88,6 +24,7 @@ test("API: до загрузки индекса сервер жив, но не assert.equal(health.status, 200); assert.equal(health.body.ok, true); assert.equal(health.body.ready, false); + assert.equal(health.body.client, "127.0.0.1"); assert.equal(typeof health.body.apiVersion, "number"); const ready = await json("/api/ready"); assert.equal(ready.status, 503); diff --git a/test/protect.test.js b/test/protect.test.js new file mode 100644 index 0000000..b97cf2d --- /dev/null +++ b/test/protect.test.js @@ -0,0 +1,74 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { startApi } from "./support/nextcloud.js"; + +// Protection of a server open to the internet, with the limits lowered so the tests reach them: +// five API requests a minute and an 800 ms time limit. +let api, base; +let sent = 0; +before(async () => { + api = await startApi({ PSU_API_RATE_LIMIT: "5", PSU_REQUEST_TIMEOUT_MS: "800", PSU_SOURCE_TIMEOUT_MS: "5000" }); + base = api.base; +}); +after(() => api?.stop()); +const apiGet = (path) => { + sent++; + return fetch(base + path); +}; + +test("Защита: заголовки безопасности у страницы и API", async () => { + for (const path of ["/", "/api/health"]) { + const response = await fetch(base + path); + await response.arrayBuffer(); + const csp = response.headers.get("content-security-policy"); + assert.match(csp, /default-src 'self'/, path); + assert.match(csp, /frame-ancestors 'none'/, path); + assert.match(csp, /object-src 'none'/, path); + assert.equal(response.headers.get("x-content-type-options"), "nosniff", path); + assert.equal(response.headers.get("x-frame-options"), "DENY", path); + assert.ok(response.headers.get("referrer-policy"), path); + assert.ok(response.headers.get("permissions-policy"), path); + // Plain HTTP: no HSTS. + assert.equal(response.headers.get("strict-transport-security"), null, path); + } +}); + +test("Защита: CSP разрешает ровно inline-скрипт темы из собранной страницы", { skip: !existsSync(new URL("../dist/index.html", import.meta.url)) && "нет dist/, сначала npm run build" }, async () => { + const html = readFileSync(new URL("../dist/index.html", import.meta.url), "utf8"); + const inline = html.match(/