From 68256f6f41e06c26109975f65be0768df4744b9d Mon Sep 17 00:00:00 2001 From: thebabalola Date: Thu, 27 Aug 2026 21:28:37 +0100 Subject: [PATCH] feat: add rate limiting and request body size limits (#43) Install @fastify/rate-limit and wire it into the Fastify bootstrap, along with a configurable bodyLimit. This protects the API from abusive traffic and oversized payloads without changing route logic. - src/config/env.ts: add RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, BODY_LIMIT_BYTES with sensible defaults. - src/config/env.test.ts: include the new keys in the test env. - .env.example: document the new guardrail variables. - src/index.ts: export buildServer(opts?) so tests can override limits; register the rate-limit plugin globally; set bodyLimit on the Fastify instance; guard the top-level listen() so importing buildServer in tests does not start a real server. - src/index.test.ts: add tests for rate-limit 429, body-limit 413, and accepted small payloads. --- .env.example | 5 +++ package-lock.json | 57 +++++++++++++++++++++++++ package.json | 1 + src/config/env.test.ts | 3 ++ src/config/env.ts | 10 +++++ src/index.test.ts | 94 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 36 ++++++++++++---- 7 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 src/index.test.ts diff --git a/.env.example b/.env.example index a3f8796..ce9b0dd 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,8 @@ PAYMENT_ROUTER_CONTRACT_ID=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC SIGNING_PROVIDER=null # SIGNING_KMS_KEY_ID= # SIGNING_ENV_SECRET_KEY= + +# --- Operational guardrails --- +RATE_LIMIT_MAX=100 +RATE_LIMIT_WINDOW_MS=60000 +BODY_LIMIT_BYTES=1048576 diff --git a/package-lock.json b/package-lock.json index 284c711..820b295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@fastify/cors": "^10.0.2", + "@fastify/rate-limit": "^11.2.0", "dotenv": "^16.4.7", "fastify": "^5.2.1", "zod": "^3.24.1" @@ -592,6 +593,53 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.2.0.tgz", + "integrity": "sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^6.0.0", + "ip-address": "^10.2.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/rate-limit/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -916,6 +964,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", diff --git a/package.json b/package.json index 8207a1d..3616554 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@fastify/cors": "^10.0.2", + "@fastify/rate-limit": "^11.2.0", "dotenv": "^16.4.7", "fastify": "^5.2.1", "zod": "^3.24.1" diff --git a/src/config/env.test.ts b/src/config/env.test.ts index 93ea96d..54b70b4 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -15,6 +15,9 @@ const baseEnv = { USAGE_METER_CONTRACT_ID: "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", PAYMENT_ROUTER_CONTRACT_ID: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", SIGNING_PROVIDER: "null", + RATE_LIMIT_MAX: "100", + RATE_LIMIT_WINDOW_MS: "60000", + BODY_LIMIT_BYTES: "1048576", }; Object.assign(process.env, baseEnv); diff --git a/src/config/env.ts b/src/config/env.ts index 624154a..1101ded 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -30,6 +30,11 @@ const baseEnvSchema = z.object({ SIGNING_PROVIDER: z.enum(["null", "kms", "env"]).default("null"), SIGNING_KMS_KEY_ID: z.string().optional(), SIGNING_ENV_SECRET_KEY: z.string().optional(), + + // Operational guardrails — issue #43. + RATE_LIMIT_MAX: z.coerce.number().default(100), + RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60000), + BODY_LIMIT_BYTES: z.coerce.number().default(1048576), }); /** Exposed separately so callers that need `.shape` (e.g. tests) can get at @@ -100,4 +105,9 @@ export const config = { envSecret: raw.SIGNING_ENV_SECRET_KEY, nodeEnv: raw.NODE_ENV, }, + rateLimit: { + max: raw.RATE_LIMIT_MAX, + windowMs: raw.RATE_LIMIT_WINDOW_MS, + }, + bodyLimitBytes: raw.BODY_LIMIT_BYTES, }; diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..ad0159c --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +const validContractId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +function setBaseEnv() { + process.env.NODE_ENV = "test"; + process.env.PORT = "0"; + process.env.API_PREFIX = "/api/v1"; + process.env.CORS_ORIGIN = "http://localhost:3000"; + process.env.STELLAR_NETWORK = "testnet"; + process.env.SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org"; + process.env.AUDIT_REGISTRY_CONTRACT_ID = validContractId; + process.env.USAGE_METER_CONTRACT_ID = "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; + process.env.PAYMENT_ROUTER_CONTRACT_ID = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; + process.env.SIGNING_PROVIDER = "null"; +} + +async function buildApp(opts: { rateLimitMax?: number; bodyLimitBytes?: number } = {}) { + setBaseEnv(); + process.env.RATE_LIMIT_MAX = "100"; + process.env.RATE_LIMIT_WINDOW_MS = "60000"; + process.env.BODY_LIMIT_BYTES = "1048576"; + + const { buildServer } = await import("./index.js"); + const app = await buildServer({ + rateLimit: opts.rateLimitMax !== undefined ? { max: opts.rateLimitMax } : undefined, + bodyLimitBytes: opts.bodyLimitBytes, + }); + await app.ready(); + return app; +} + +test("rate limit rejects requests above the configured threshold", async () => { + const app = await buildApp({ rateLimitMax: 2 }); + + const first = await app.inject({ method: "GET", url: "/health" }); + assert.equal(first.statusCode, 200); + + const second = await app.inject({ method: "GET", url: "/health" }); + assert.equal(second.statusCode, 200); + + const blocked = await app.inject({ method: "GET", url: "/health" }); + assert.equal(blocked.statusCode, 429); + const body = blocked.json(); + assert.equal(body.error, "Too Many Requests"); + assert.equal(body.message, "rate limit exceeded"); + + await app.close(); +}); + +test("body limit rejects payloads larger than the configured size", async () => { + const app = await buildApp({ bodyLimitBytes: 64 }); + + const bigPayload = "x".repeat(128); + const res = await app.inject({ + method: "POST", + url: "/api/v1/disputes", + headers: { + "x-role": "buyer", + "x-user-id": "buyer-1", + "content-type": "application/json", + }, + payload: JSON.stringify({ content: bigPayload }), + }); + + assert.equal(res.statusCode, 413); + + await app.close(); +}); + +test("small payloads within the body limit are accepted", async () => { + const app = await buildApp({ bodyLimitBytes: 4096 }); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/disputes", + headers: { + "x-role": "buyer", + "x-user-id": "buyer-1", + "content-type": "application/json", + }, + payload: JSON.stringify({ + settlementId: "s1", + reason: "item not delivered", + buyerId: "buyer-1", + sellerId: "seller-1", + }), + }); + + assert.equal(res.statusCode, 201); + + await app.close(); +}); diff --git a/src/index.ts b/src/index.ts index 7572950..141790f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import Fastify, { type FastifyError, type FastifyRequest } from "fastify"; import cors from "@fastify/cors"; +import rateLimit from "@fastify/rate-limit"; import { config } from "./config/env.js"; import { healthRoutes } from "./routes/health.js"; import { v1Routes } from "./routes/v1/index.js"; @@ -16,8 +17,14 @@ declare module "fastify" { } } -async function buildServer() { +export interface BuildServerOptions { + bodyLimitBytes?: number; + rateLimit?: { max?: number; windowMs?: number }; +} + +export async function buildServer(opts: BuildServerOptions = {}) { const app = Fastify({ + bodyLimit: opts.bodyLimitBytes ?? config.bodyLimitBytes, logger: { // Redaction runs at the serializer boundary rather than at call sites: a // secret reaches the log by being nested in a request body or hanging off @@ -65,17 +72,28 @@ async function buildServer() { methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], }); + await app.register(rateLimit, { + max: opts.rateLimit?.max ?? config.rateLimit.max, + timeWindow: opts.rateLimit?.windowMs ?? config.rateLimit.windowMs, + keyGenerator: (req) => req.ip, + errorResponseBuilder: () => ({ + statusCode: 429, + error: "Too Many Requests", + message: "rate limit exceeded", + }), + }); + await app.register(healthRoutes); await app.register(v1Routes, { prefix: config.apiPrefix }); return app; } -buildServer() - .then((app) => - app.listen({ port: config.port, host: "0.0.0.0" }), - ) - .catch((err) => { - console.error(err); - process.exit(1); - }); +if (import.meta.url === new URL(process.argv[1], import.meta.url).href) { + buildServer() + .then((app) => app.listen({ port: config.port, host: "0.0.0.0" })) + .catch((err) => { + console.error(err); + process.exit(1); + }); +}