Add per-client API rate limiting for hosted hardening

This commit is contained in:
Rbanh 2026-02-17 00:46:56 -05:00
parent 60f52742ad
commit 9ee97ffa8e
2 changed files with 55 additions and 0 deletions

View File

@ -28,6 +28,10 @@ Version metadata:
- Current values: `api_version=0.3.0`, `schema_version=1.0.0`. - 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. - 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 ## REST API
### `POST /compile` ### `POST /compile`

View File

@ -8,9 +8,12 @@ import { validateModel } from "./validate.js";
const PORT = Number(process.env.PORT ?? "8787"); const PORT = Number(process.env.PORT ?? "8787");
const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES ?? 2 * 1024 * 1024); const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES ?? 2 * 1024 * 1024);
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "*"; 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 FRONTEND_ROOT = join(process.cwd(), "frontend");
const API_VERSION = "0.3.0"; const API_VERSION = "0.3.0";
const SCHEMA_VERSION = "1.0.0"; const SCHEMA_VERSION = "1.0.0";
const RATE_WINDOW_MS = 60_000;
const rateByClient = new Map();
const MIME_TYPES = { const MIME_TYPES = {
".html": "text/html; charset=utf-8", ".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) => { const server = createServer(async (req, res) => {
if (!req.url || !req.method) { if (!req.url || !req.method) {
return json(res, 400, errorEnvelope("invalid_request", "Invalid request.")); 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 (req.method === "POST" && pathname === "/analyze") {
if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
}
try { try {
const body = await readBody(req); const body = await readBody(req);
const parsed = parsePayloadOptions(body); const parsed = parsePayloadOptions(body);
@ -188,6 +230,9 @@ const server = createServer(async (req, res) => {
} }
if (req.method === "POST" && pathname === "/compile") { if (req.method === "POST" && pathname === "/compile") {
if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
}
try { try {
const body = await readBody(req); const body = await readBody(req);
const parsed = parsePayloadOptions(body); const parsed = parsePayloadOptions(body);
@ -204,6 +249,9 @@ const server = createServer(async (req, res) => {
} }
if (req.method === "POST" && pathname === "/layout/auto") { if (req.method === "POST" && pathname === "/layout/auto") {
if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
}
try { try {
const body = await readBody(req); const body = await readBody(req);
const parsed = parsePayloadOptions(body); const parsed = parsePayloadOptions(body);
@ -229,6 +277,9 @@ const server = createServer(async (req, res) => {
} }
if (req.method === "POST" && pathname === "/layout/tidy") { if (req.method === "POST" && pathname === "/layout/tidy") {
if (!withinRateLimit(req)) {
return json(res, 429, errorEnvelope("rate_limited", "Rate limit exceeded. Try again shortly."));
}
try { try {
const body = await readBody(req); const body = await readBody(req);
const parsed = parsePayloadOptions(body); const parsed = parsePayloadOptions(body);