diff --git a/README.md b/README.md index 57075f7..0980219 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ Version metadata: - Current values: `api_version=0.3.0`, `schema_version=1.0.0`. - Compatibility policy (current): additive, backward-compatible fields may be introduced in the same API minor version. +Operational limits: +- `MAX_BODY_BYTES` (default `2097152`) limits request payload size. +- `MAX_REQUESTS_PER_MINUTE` (default `120`) applies per-client throttling for POST API endpoints. + ## REST API ### `POST /compile` diff --git a/src/server.js b/src/server.js index c383174..1bb7d78 100644 --- a/src/server.js +++ b/src/server.js @@ -8,9 +8,12 @@ import { validateModel } from "./validate.js"; const PORT = Number(process.env.PORT ?? "8787"); const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES ?? 2 * 1024 * 1024); const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "*"; +const MAX_REQUESTS_PER_MINUTE = Number(process.env.MAX_REQUESTS_PER_MINUTE ?? 120); const FRONTEND_ROOT = join(process.cwd(), "frontend"); const API_VERSION = "0.3.0"; const SCHEMA_VERSION = "1.0.0"; +const RATE_WINDOW_MS = 60_000; +const rateByClient = new Map(); const MIME_TYPES = { ".html": "text/html; charset=utf-8", @@ -133,6 +136,42 @@ function withEnvelopeMeta(payload) { }; } +function clientKey(req) { + const forwarded = req.headers["x-forwarded-for"]; + if (typeof forwarded === "string" && forwarded.trim()) { + return forwarded.split(",")[0].trim(); + } + return req.socket?.remoteAddress ?? "unknown"; +} + +function pruneClientRate(now, list) { + const minTs = now - RATE_WINDOW_MS; + let idx = 0; + while (idx < list.length && list[idx] < minTs) { + idx += 1; + } + if (idx > 0) { + list.splice(0, idx); + } +} + +function withinRateLimit(req) { + if (!(MAX_REQUESTS_PER_MINUTE > 0)) { + return true; + } + const key = clientKey(req); + const now = Date.now(); + const list = rateByClient.get(key) ?? []; + pruneClientRate(now, list); + if (list.length >= MAX_REQUESTS_PER_MINUTE) { + rateByClient.set(key, list); + return false; + } + list.push(now); + rateByClient.set(key, list); + return true; +} + const server = createServer(async (req, res) => { if (!req.url || !req.method) { return json(res, 400, errorEnvelope("invalid_request", "Invalid request.")); @@ -172,6 +211,9 @@ const server = createServer(async (req, res) => { } if (req.method === "POST" && pathname === "/analyze") { + if (!withinRateLimit(req)) { + return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); + } try { const body = await readBody(req); const parsed = parsePayloadOptions(body); @@ -188,6 +230,9 @@ const server = createServer(async (req, res) => { } if (req.method === "POST" && pathname === "/compile") { + if (!withinRateLimit(req)) { + return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); + } try { const body = await readBody(req); const parsed = parsePayloadOptions(body); @@ -204,6 +249,9 @@ const server = createServer(async (req, res) => { } if (req.method === "POST" && pathname === "/layout/auto") { + if (!withinRateLimit(req)) { + return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); + } try { const body = await readBody(req); const parsed = parsePayloadOptions(body); @@ -229,6 +277,9 @@ const server = createServer(async (req, res) => { } if (req.method === "POST" && pathname === "/layout/tidy") { + if (!withinRateLimit(req)) { + return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly.")); + } try { const body = await readBody(req); const parsed = parsePayloadOptions(body);