Add per-client API rate limiting for hosted hardening
This commit is contained in:
parent
60f52742ad
commit
9ee97ffa8e
@ -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`
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user