SPB Git

spb/market-atlas

Public

fix: rate limiter applied to proxied web pages/assets → visitors got RATE_LIMITED; limit /v1 only, 20 req/s burst 300

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 13, 2026) parent e071382 Browse files

4 changed files +11 −9

modified apps/api/src/api/routes/public.ts +2 −2
@@ -51,11 +51,11 @@ export function resolveInstrument(idOrSymbol: string) {
51 } 51 }
52 52
53 export async function registerPublicRoutes(app: FastifyInstance) { 53 export async function registerPublicRoutes(app: FastifyInstance) {
54− app.get("/v1/health", async () => { 54+ app.get("/v1/health", async (req) => {
55 const db = await pool.query("select 1").then(() => "ok").catch(() => "down"); 55 const db = await pool.query("select 1").then(() => "ok").catch(() => "down");
56 const healthy = connectorManager.list().filter((r) => health.state(r.def.metadata.id) === "HEALTHY").length; 56 const healthy = connectorManager.list().filter((r) => health.state(r.def.metadata.id) === "HEALTHY").length;
57 const status = db === "ok" ? "ok" : "degraded"; 57 const status = db === "ok" ? "ok" : "degraded";
58− return { status, version: config.version, role: config.role, db, connectors: { total: connectorManager.list().length, healthy }, quotes: quoteStore.size(), uptime_s: Math.round(process.uptime()) }; 58+ return { status, version: config.version, role: config.role, db, connectors: { total: connectorManager.list().length, healthy }, quotes: quoteStore.size(), uptime_s: Math.round(process.uptime()), client_ip: req.ip };
59 }); 59 });
60 60
61 app.get("/v1/status", async () => { 61 app.get("/v1/status", async () => {
modified apps/api/src/api/server.ts +7 −5
@@ -38,21 +38,23 @@ export async function buildServer(): Promise<FastifyInstance> {
38 await app.register(cors, { origin: true, methods: ["GET", "POST", "OPTIONS"], allowedHeaders: ["content-type", "x-ma-admin-token"] }); 38 await app.register(cors, { origin: true, methods: ["GET", "POST", "OPTIONS"], allowedHeaders: ["content-type", "x-ma-admin-token"] });
39 await app.register(websocket, { options: { maxPayload: 64 * 1024 } }); 39 await app.register(websocket, { options: { maxPayload: 64 * 1024 } });
40 40
41− // Simple per-IP token bucket (public REST). 240 req/min sustained, 60 burst. 41+ // Per-IP token bucket on the public REST API only (never on proxied web pages/assets, streams or loopback SSR).
42+ // 20 req/s sustained, 300 burst — a rich page issues dozens of /v1 calls at once.
43+ const BURST = 300;
44+ const PER_SEC = 20;
42 const buckets = new Map<string, { tokens: number; last: number }>(); 45 const buckets = new Map<string, { tokens: number; last: number }>();
43 app.addHook("onRequest", async (req, reply) => { 46 app.addHook("onRequest", async (req, reply) => {
44 (req as FastifyRequest & { startedAt: number }).startedAt = Date.now(); 47 (req as FastifyRequest & { startedAt: number }).startedAt = Date.now();
45− if (req.url.startsWith("/v1/stream") || req.url.startsWith("/v1/sse")) return; 48+ if (!req.url.startsWith("/v1/") || req.url.startsWith("/v1/stream") || req.url.startsWith("/v1/sse")) return;
46 const ip = req.ip; 49 const ip = req.ip;
47− // Server-side renders of the web app arrive from loopback without a client IP — never throttle them.
48 if (ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1") return; 50 if (ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1") return;
49 const now = Date.now(); 51 const now = Date.now();
50 let b = buckets.get(ip); 52 let b = buckets.get(ip);
51 if (!b) { 53 if (!b) {
52− b = { tokens: 60, last: now }; 54+ b = { tokens: BURST, last: now };
53 buckets.set(ip, b); 55 buckets.set(ip, b);
54 } 56 }
55− b.tokens = Math.min(60, b.tokens + ((now - b.last) / 1000) * 4); 57+ b.tokens = Math.min(BURST, b.tokens + ((now - b.last) / 1000) * PER_SEC);
56 b.last = now; 58 b.last = now;
57 if (b.tokens < 1) { 59 if (b.tokens < 1) {
58 telemetry.inc("api_rate_limited_total"); 60 telemetry.inc("api_rate_limited_total");
modified docs/API.md +1 −1
@@ -2,7 +2,7 @@
2 2
3 Base: `https://www.market-atlas.co/v1` (same origin as the web app; in development `http://127.0.0.1:8391/v1`). 3 Base: `https://www.market-atlas.co/v1` (same origin as the web app; in development `http://127.0.0.1:8391/v1`).
4 Envelope: `{ "data": …, "meta": { "request_id", "timestamp", … } }`; errors: `{ "error": { "code", "message" } }`. 4 Envelope: `{ "data": …, "meta": { "request_id", "timestamp", … } }`; errors: `{ "error": { "code", "message" } }`.
5−Public rate limit: 240 requests/min per IP (burst 60), `429` + `retry-after`. Admin routes need `x-ma-admin-token`. 5+Public rate limit on /v1 only: 20 requests/s per IP (burst 300), `429` + `retry-after`. Admin routes need `x-ma-admin-token`.
6 6
7 Quote object (`publicQuote`): `instrument_id, symbol, name, asset_class, exchange_id, country, price, open, high, low, previous_close, 7 Quote object (`publicQuote`): `instrument_id, symbol, name, asset_class, exchange_id, country, price, open, high, low, previous_close,
8 change, change_percent, volume, bid, ask, currency, source_count, dispersion_bps, confidence, freshness_ms, data_status, market_state, 8 change, change_percent, volume, bid, ask, currency, source_count, dispersion_bps, confidence, freshness_ms, data_status, market_state,
modified docs/ARCHITECTURE.md +1 −1
@@ -72,6 +72,6 @@ SOURCE_DIVERGENCE (> 100 bps between included sources, hourly per instrument). C
72 72
73 ## Security 73 ## Security
74 74
75−Admin API behind `x-ma-admin-token` (timing-safe compare); public REST rate-limited per IP (240/min); security headers; strict 75+Admin API behind `x-ma-admin-token` (timing-safe compare); public REST rate-limited per IP (20 req/s, burst 300; web pages and streams exempt); security headers; strict
76 parameter validation (zod); SSRF guard on discovery URLs (private ranges, localhost, cluster hostnames, DNS resolution check); 76 parameter validation (zod); SSRF guard on discovery URLs (private ranges, localhost, cluster hostnames, DNS resolution check);
77 secrets redacted in logs and raw archives; no stack traces in responses. 77 secrets redacted in logs and raw archives; no stack traces in responses.
78 78
Menu

Theme