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>
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 | ||||