diff --git a/README.md b/README.md index 337ee4f..1f60695 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,26 @@ Set a shell variable for the local base URL: BASE_URL=http://localhost:3001 ``` +### Running behind a proxy + +By default the backend does not trust `X-Forwarded-For`, so spoofed proxy +headers cannot change the rate-limit key. When the service is deployed behind a +load balancer or reverse proxy that you control, set `TRUST_PROXY` to the number +of trusted proxy hops: + +```bash +TRUST_PROXY=1 npm start +``` + +Truthy values such as `true`, `yes`, and `on` are treated as one trusted hop. +Leave `TRUST_PROXY` unset, `0`, or `false` for direct-to-node deployments. + +Rate limiting prefers a recognized `X-API-Key` over the client IP, so two valid +API-key tenants behind the same NAT do not throttle each other. Requests without +a recognized key continue to use Express' trusted client IP. Only enable +`TRUST_PROXY` behind a proxy that strips or overwrites inbound +`X-Forwarded-For`; otherwise clients can choose the address Express sees. + 1. Register a billable service. ```bash diff --git a/src/index.ts b/src/index.ts index 9ece0f2..6fab46a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import express from "express"; import { + configureTrustProxy, installPreRouteMiddleware, installRequestStateMiddleware, } from "./middleware/index.js"; @@ -22,6 +23,7 @@ const PORT = process.env.PORT ?? 3001; function createApp() { const app = express(); + configureTrustProxy(app); installPreRouteMiddleware(app); app.use(createAdminRouter()); diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 53859a7..754ca3d 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import express, { type Application, type NextFunction, @@ -25,6 +25,44 @@ export function installPreRouteMiddleware(app: Application): void { app.use(requestIdMiddleware); } +/** + * Converts TRUST_PROXY into Express' hop-count based trust proxy setting. + * Boolean-like truthy values intentionally map to one trusted proxy hop. + */ +export function resolveTrustProxySetting( + raw = process.env.TRUST_PROXY +): false | number { + if (raw === undefined) return false; + const normalized = raw.trim().toLowerCase(); + if ( + normalized === "" || + normalized === "false" || + normalized === "0" || + normalized === "off" || + normalized === "no" + ) { + return false; + } + if ( + normalized === "true" || + normalized === "1" || + normalized === "on" || + normalized === "yes" + ) { + return 1; + } + const hopCount = Number(normalized); + if (Number.isInteger(hopCount) && hopCount > 0) { + return hopCount; + } + return false; +} + +/** Applies the process trust-proxy setting before request middleware runs. */ +export function configureTrustProxy(app: Application): void { + app.set("trust proxy", resolveTrustProxySetting()); +} + /** * Installs middleware that originally ran after admin/config/metrics but before * the main API routes. @@ -120,12 +158,25 @@ function pauseGuardMiddleware(req: Request, res: Response, next: NextFunction): }); } -/** In-process IP rate limiter matching the original 60/min behavior. */ +/** + * Builds a stable rate-limit key from the authenticated API key when present, + * otherwise falling back to Express' trusted client IP. + */ +export function deriveRateLimitKey(req: Request): string { + const apiKey = (req as AgentPayRequest).apiKey; + if (apiKey) { + const digest = createHash("sha256").update(apiKey).digest("hex"); + return `api-key:${digest}`; + } + return `ip:${req.ip ?? req.socket.remoteAddress ?? "unknown"}`; +} + +/** In-process rate limiter keyed by API key or trusted client IP. */ function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): void { if (process.env.NODE_ENV === "test") return next(); - const ip = req.ip ?? req.socket.remoteAddress ?? "unknown"; + const key = deriveRateLimitKey(req); const now = Date.now(); - const bucket = (rateBuckets.get(ip) ?? []).filter( + const bucket = (rateBuckets.get(key) ?? []).filter( (t) => now - t < RATE_LIMIT_WINDOW_MS ); if (bucket.length >= RATE_LIMIT_PER_WINDOW) { @@ -138,7 +189,7 @@ function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): v return; } bucket.push(now); - rateBuckets.set(ip, bucket); + rateBuckets.set(key, bucket); next(); } diff --git a/src/ratelimit-key.test.ts b/src/ratelimit-key.test.ts new file mode 100644 index 0000000..393dbd8 --- /dev/null +++ b/src/ratelimit-key.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import assert from "node:assert"; +import request from "supertest"; +import { createApp } from "./index.js"; +import { apiKeyStore, RATE_LIMIT_PER_WINDOW, rateBuckets } from "./store/state.js"; + +const originalNodeEnv = process.env.NODE_ENV; +const originalTrustProxy = process.env.TRUST_PROXY; +const originalConsoleLog = console.log; + +function restoreEnv(): void { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + if (originalTrustProxy === undefined) { + delete process.env.TRUST_PROXY; + } else { + process.env.TRUST_PROXY = originalTrustProxy; + } +} + +void describe("rate-limit key derivation", () => { + beforeEach(() => { + process.env.NODE_ENV = "production"; + delete process.env.TRUST_PROXY; + apiKeyStore.clear(); + rateBuckets.clear(); + console.log = () => undefined; + }); + + afterEach(() => { + restoreEnv(); + apiKeyStore.clear(); + rateBuckets.clear(); + console.log = originalConsoleLog; + }); + + void it("does not let spoofed X-Forwarded-For values bypass the limiter when trust proxy is off", async () => { + const app = createApp(); + + for (let i = 0; i < RATE_LIMIT_PER_WINDOW; i += 1) { + const res = await request(app) + .get("/api/v1/version") + .set("X-Forwarded-For", `198.51.100.${i}`); + assert.strictEqual(res.status, 200); + } + + const limited = await request(app) + .get("/api/v1/version") + .set("X-Forwarded-For", "198.51.100.250"); + + assert.strictEqual(limited.status, 429); + assert.strictEqual(limited.body.error, "rate_limited"); + assert.strictEqual(limited.headers["retry-after"], "60"); + }); + + void it("honours the configured trusted proxy hop count for the client IP", async () => { + process.env.TRUST_PROXY = "1"; + const app = createApp(); + + for (let i = 0; i < RATE_LIMIT_PER_WINDOW + 1; i += 1) { + const res = await request(app) + .get("/api/v1/version") + .set("X-Forwarded-For", `203.0.113.${i}`); + assert.strictEqual(res.status, 200); + } + }); + + void it("keys authenticated callers by API key before falling back to IP", async () => { + const app = createApp(); + const firstKey = "apk_first_tenant"; + const secondKey = "apk_second_tenant"; + apiKeyStore.set(firstKey, { label: "first", createdAt: Date.now() }); + apiKeyStore.set(secondKey, { label: "second", createdAt: Date.now() }); + + for (let i = 0; i < RATE_LIMIT_PER_WINDOW; i += 1) { + const res = await request(app).get("/api/v1/version").set("X-API-Key", firstKey); + assert.strictEqual(res.status, 200); + } + + const isolated = await request(app) + .get("/api/v1/version") + .set("X-API-Key", secondKey); + assert.strictEqual(isolated.status, 200); + + const limited = await request(app) + .get("/api/v1/version") + .set("X-API-Key", firstKey); + assert.strictEqual(limited.status, 429); + assert.strictEqual(limited.body.error, "rate_limited"); + }); +}); diff --git a/src/store/state.ts b/src/store/state.ts index 6c573f4..118195c 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -41,7 +41,7 @@ export const servicesMetadata = new Map(); /** Registered webhooks and their event subscriptions. */ export const webhookStore = new Map(); -/** Rate-limiter windows keyed by source IP. */ +/** Rate-limiter windows keyed by authenticated API key digest or trusted IP. */ export const rateBuckets = new Map(); export const RATE_LIMIT_PER_WINDOW = 60;