refactor(security): centralize rate-limit config into declarative policies (#366) - #549
Open
Vyacheslav-Tomashevskiy wants to merge 1 commit into
Conversation
…icies (Northgate-Systems#366) Routes each carried their own limit, window, message and a copy of the X-Forwarded-For parsing. Replace that with named policies in src/lib/security.ts plus enforceRateLimit()/getClientIp(). Fixes two defects the duplication was hiding: - the leftmost X-Forwarded-For entry is client-controlled, so rotating the header handed every request a fresh bucket and bypassed the limit - expired buckets were never evicted, so those keys leaked memory
|
Someone is attempting to deploy a commit to the codex723's projects Team on Vercel. A member of the Team first needs to authorize it. |
6 tasks
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 #366
What this changes
Every rate-limited route carried its own copy of the same four things: a limit, a window, a 429 message, and this line:
Six routes, six copies (
login,register,forgot-password,reset-password,analytics,stellar/send). This moves the config intosrc/lib/security.tsas declarative policies, exactly as the issue asks:Each route is now two lines:
All six limits, windows and messages are carried over unchanged — this refactor is behaviour-preserving except for the two fixes below.
Two defects the duplication was hiding
1. The rate limits could be bypassed outright.
X-Forwarded-Foris a list the client can seed — proxies only append to it. Readingsplit(",")[0]therefore reads an attacker-supplied value, so a caller gets a brand-new bucket on every request just by changing the header. Reproduced againstorigin/main's exact logic:That applies to all five IP-scoped endpoints, including login brute-force and
forgot-passwordemail spam.getClientIp()now prefersx-real-ip(proxy-set, single-valued, cannot be appended to) and otherwise reads the entryRATE_LIMIT_TRUSTED_PROXY_HOPSfrom the right — the address the nearest trusted proxy actually saw. Default is1(one reverse proxy / platform edge, which is what Vercel provides); documented in.env.examplefor setups that stack a CDN in front of their own proxy.2. The bucket map never shrank.
rateBucketsentries were only ever overwritten, never deleted, so every distinct key leaked — and per defect 1 those keys were free for a caller to generate. Expired buckets are now swept, with aMAX_RATE_BUCKETScap (10k) that evicts the entries closest to expiry if the sweep isn't enough.Smaller things
Retry-Afterheader. The value was already computed (retryAfterMs) and thrown away./api/analyticsreturned a bare{ success: false }on 429; it now useserrorResponse()like the other five, per the response-shape criterion. Additive — thesuccessfield is unchanged.rate_limit_unresolved_ipsecurity event once per process instead of failing quietly.rateLimit()is still exported with its original signature; nothing that used it directly has to change.Tests
src/lib/__tests__/rate-limit.test.ts, 15 tests, all green:getClientIp: prefersx-real-ip; ignores a client-seeded leftmost entry; single-entry header; padding/empty entries; returnsnullwhen no headerenforceRateLimit: allows exactlylimitthen blocks (happy path + failure path); 429 body matches theapi-responseshape and carriesRetry-After; rotatingX-Forwarded-Forno longer mints fresh buckets (regression test for defect 1); different callers stay isolated; policies stay independent for the same caller; user-scoped policies key by user id and not by address; throws if a user-scoped policy is used without a subject; unresolved-address callers are still limitedVerification
npx vitest run— 6 test files green / 79 tests pass. The 2 failures invalidations.test.tsare pre-existing onmain(a bad-checksum test address instellarSendSchema), unrelated to this change.npm run build—✓ Compiled successfully. The type-check step stops onsrc/lib/validations.ts:40: Cannot find name 'isValidStellarPublicKey', which is the known missing import already tracked in fix(send): validate the Stellar address checksum client-side (closes #424) #527/fix(validations): restore broken isValidStellarPublicKey import; feat: add /api/stellar/fee-estimate #529 and present on a cleanmain. With that import restored locally the full type check passes with no new errors; the import was not committed here.npx eslinton all seven touched files — clean.npm run dev+ curl against/api/auth/forgot-password(limit 3), rotating the spoofed leftmostX-Forwarded-Foron every request while keeping the same real client:A different real client and an
x-real-ipcaller both still passed the limiter while that bucket was exhausted, confirming isolation.Note on scope
#365 asks for a rate limit on
/api/stellar/submit, which still has none. I deliberately left that alone so it stays that issue's change — with this in place it's one policy entry plus the two-line call.