Repository navigation
feat: add rate limiting and request body size limits (#43) - #76
Merged
joelpeace48-cell merged 1 commit intoAug 29, 2026
Merged
joelpeace48-cell merged 1 commit into
joelpeace48-cell merged 1 commit into
Conversation
…b#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.
Contributor
Author
|
Hi @joelpeace48-cell , I've put together the rate limiting and body size guardrails for #43. The PR is ready for review and I'm happy to adjust the limits or the response shape if anything doesn't match what you had in mind. |
4 tasks
thebabalola
added a commit
to thebabalola/modeltrace-backend
that referenced
this pull request
Aug 29, 2026
… tests - Merged rate limiting (FinesseStudioLab#76) and auth (FinesseStudioLab#42) features in env.ts, index.ts, index.test.ts, .env.example, env.test.ts - Applied lazy config (getConfig) to prevent eager parseEnv at import time - Augmented FastifyContextConfig with public boolean for route auth opt-out - Fixed body limit tests to include auth credentials (401 was returned before body parsing could trigger 413/201) - All 84 tests passing, lint clean, 0 audit vulnerabilities
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #43
Summary of Changes
The API currently accepts requests of any size and at any rate. This change adds per-IP rate limiting and a maximum request body size using Fastify's built-in guardrails plus the official plugin. The defaults are conservative enough for development but configurable per environment.
What changed
Testing / Local Verification
Ran the exact CI commands from :
added 65 packages, and audited 66 packages in 4s
25 packages are looking for funding
run
npm fundfor detailsfound 0 vulnerabilities
✔ parses Soroban network and contract configuration (3.099609ms)
✔ STELLAR_NETWORK is required and has no default (3.643305ms)
✔ Soroban RPC URL must be a URL (1.339062ms)
✔ contract IDs must be Soroban contract-shaped strkeys (1.226679ms)
✔ CORS_ORIGIN parses a comma-separated list into multiple origins (2.070259ms)
✔ resolveCorsOrigins falls back to the dev default outside production (0.508813ms)
✔ production requires CORS_ORIGIN to be set explicitly (0.988054ms)
✔ production rejects "*" as a CORS origin (1.247719ms)
✔ production accepts an explicit, non-wildcard CORS_ORIGIN (1.900825ms)
✔ .env.example carries every required schema key (2.105662ms)
✔ a miss fetches upstream and returns the value with an as-of timestamp (4.61042ms)
✔ a fresh entry is served from the cache without touching upstream (0.64035ms)
✔ an entry past its TTL is refetched (0.736039ms)
✔ a hundred concurrent misses produce one upstream call (31.381895ms)
✔ an expired entry is served as stale, labelled with its as-of, when upstream fails (1.583571ms)
✔ upstream failure with no cached entry propagates the error (1.655498ms)
✔ stale serving can be disabled for paths that cannot act on estimates (0.741257ms)
✔ each read after a stale serving retries upstream rather than serving stale forever (1.270564ms)
✔ coalesced waiters all receive the same stale result on upstream failure (1.307368ms)
✔ invalidate drops the entry so the next read goes upstream (2.145842ms)
✔ a read started after invalidate does not join the pre-event fetch (0.908087ms)
✔ invalidating an unknown key is a no-op (0.34211ms)
✔ emits structured events for hits, misses, coalescing, and invalidation (0.620408ms)
✔ stale serving emits the fetch-failed and stale-served events (0.557997ms)
✔ createContractReadCache wires each type to its documented TTL (0.696972ms)
✔ expired entries are swept once the cache exceeds maxEntries (0.80233ms)
✔ redaction keeps ordinary values (19.795715ms)
✔ redaction removes values under secret-looking key names (0.518008ms)
✔ redaction scrubs a Stellar seed that arrived under an innocuous name (5.022024ms)
✔ redaction survives a cycle rather than throwing (6.287981ms)
✔ redaction preserves error fields while scrubbing them (0.683635ms)
✔ the default provider is null and refuses to sign (2.891652ms)
✔ the env provider is refused outright in production (2.75283ms)
✔ the env provider warns loudly outside production (0.50258ms)
✔ providers refuse to start without the configuration they need (2.117007ms)
✔ a scope outside the key's permitted set is rejected before signing (1.834775ms)
{"level":40,"time":1787862562846,"pid":58656,"hostname":"pop-os","event":"signing.provider_selected","provider":"null","msg":"signing.provider_selected"}
{"level":40,"time":1787862563013,"pid":58656,"hostname":"pop-os","event":"signing.provider_selected","provider":"null","msg":"signing.provider_selected"}
✔ rate limit rejects requests above the configured threshold (622.91864ms)
{"level":40,"time":1787862563075,"pid":58656,"hostname":"pop-os","event":"signing.provider_selected","provider":"null","msg":"signing.provider_selected"}
✔ body limit rejects payloads larger than the configured size (59.043853ms)
{"level":30,"time":1787862562973,"pid":58656,"hostname":"pop-os","reqId":"req-1","req":{"method":"GET","url":"/health","host":"localhost","remoteAddress":"127.0.0.1","headers":{"user-agent":"lightMyRequest","host":"localhost:80"},"query":{}},"msg":"incoming request"}
{"level":30,"time":1787862562994,"pid":58656,"hostname":"pop-os","reqId":"req-1","res":{"statusCode":200},"responseTime":19.271891000000096,"msg":"request completed"}
{"level":30,"time":1787862562996,"pid":58656,"hostname":"pop-os","reqId":"req-2","req":{"method":"GET","url":"/health","host":"localhost","remoteAddress":"127.0.0.1","headers":{"user-agent":"lightMyRequest","host":"localhost:80"},"query":{}},"msg":"incoming request"}
{"level":30,"time":1787862562999,"pid":58656,"hostname":"pop-os","reqId":"req-2","res":{"statusCode":200},"responseTime":3.3243889999999965,"msg":"request completed"}
{"level":30,"time":1787862563000,"pid":58656,"hostname":"pop-os","reqId":"req-3","req":{"method":"GET","url":"/health","host":"localhost","remoteAddress":"127.0.0.1","headers":{"user-agent":"lightMyRequest","host":"localhost:80"},"query":{}},"msg":"incoming request"}
{"level":30,"time":1787862563002,"pid":58656,"hostname":"pop-os","reqId":"req-3","res":{"statusCode":429},"err":{"statusCode":429,"error":"Too Many Requests","message":"rate limit exceeded","type":"Error","stack":""},"msg":"rate limit exceeded"}
{"level":30,"time":1787862563003,"pid":58656,"hostname":"pop-os","reqId":"req-3","res":{"statusCode":429},"responseTime":1.9550610000000006,"msg":"request completed"}
{"level":30,"time":1787862563060,"pid":58656,"hostname":"pop-os","reqId":"req-1","req":{"method":"POST","url":"/api/v1/disputes","host":"localhost","remoteAddress":"127.0.0.1","headers":{"x-role":"buyer","x-user-id":"buyer-1","content-type":"application/json","user-agent":"lightMyRequest","host":"localhost:80","content-length":"142"},"query":{}},"msg":"incoming request"}
{"level":30,"time":1787862563062,"pid":58656,"hostname":"pop-os","reqId":"req-1","res":{"statusCode":413},"err":{"name":"FastifyError","message":"Request body is too large","stack":"FastifyError: Request body is too large\n at rawBody (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/content-type-parser.js:245:10)\n at ContentTypeParser.run (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/content-type-parser.js:209:5)\n at Object.handleRequest (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/handle-request.js:87:46)\n at runPreParsing (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/route.js:658:19)\n at next (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/hooks.js:236:9)\n at handleResolve (/home/thebabalola/Desktop/x-temp/GrantFox/babalola/modeltrace-backend/node_modules/fastify/lib/hooks.js:253:7)\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)","type":"FastifyError"},"msg":"Request body is too large"}
{"level":30,"time":1787862563064,"pid":58656,"hostname":"pop-os","reqId":"req-1","res":{"statusCode":413},"responseTime":3.823607000000038,"msg":"request completed"}
{"level":30,"time":1787862563099,"pid":58656,"hostname":"pop-os","reqId":"req-1","req":{"method":"POST","url":"/api/v1/disputes","host":"localhost","remoteAddress":"127.0.0.1","headers":{"x-role":"buyer","x-user-id":"buyer-1","content-type":"application/json","user-agent":"lightMyRequest","host":"localhost:80","content-length":"93"},"query":{}},"msg":"incoming request"}
✔ small payloads within the body limit are accepted (46.740831ms)
{"level":30,"time":1787862563115,"pid":58656,"hostname":"pop-os","reqId":"req-1","res":{"statusCode":201},"responseTime":16.018123999999943,"msg":"request completed"}
✔ version is sourced from package.json, not a literal (2.994133ms)
✔ build commit and time default to 'unknown' when not injected (6.254252ms)
✔ build commit and time are read from the environment when present (0.439704ms)
✔ reports the configured network and contract addresses (0.456905ms)
✔ description no longer reads as a scaffold (4.442531ms)
✔ a job handler runs exactly once even if run() is called twice (5.221547ms)
✔ idempotency survives a simulated crash-and-restart against the same durable file (1.771636ms)
✔ a job that keeps failing is retried up to maxAttempts then dead-lettered (4.471015ms)
✔ metrics report queue depth and failure rate (2.50509ms)
✔ a buyer can raise a dispute with evidence, hash-committed (197.225988ms)
✔ listing is role-scoped: a seller cannot see another party's dispute (16.368116ms)
✔ only a party to the dispute may add evidence (14.18441ms)
✔ only an adjudicator may resolve, and resolving fires a notification (32.344222ms)
✔ evidence is rejected once the window has closed (48.388091ms)
✔ requests without a recognized role/user are rejected (11.780066ms)
✔ GET /health/live returns HTTP 200 with liveness status (251.05272ms)
✔ GET /health returns HTTP 200 for backwards compatibility (15.94505ms)
✔ GET /health/ready returns HTTP 200 when Soroban RPC is healthy (50.858596ms)
✔ GET /health/ready caches RPC check result within TTL (9.969158ms)
✔ GET /health/ready returns HTTP 503 when Soroban RPC fails (11.902187ms)
✔ GET /health/ready returns HTTP 503 when Soroban RPC times out (20.50644ms)
✔ GET /health/ready returns HTTP 503 during graceful shutdown (11.023397ms)
✔ simulate retries transient transport errors and eventually succeeds (12.716589ms)
✔ simulate times out when the transport hangs (8.238264ms)
✔ circuit breaker opens after repeated failures and fails fast (4.812698ms)
✔ submit is never retried by the client itself (7.550394ms)
✔ pollUntilConfirmed treats a 429-then-recover getTransaction as one logical poll (10.744132ms)
ℹ tests 66
ℹ suites 0
ℹ pass 66
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1525.903524
found 0 vulnerabilities