From eea51b256f696aeb4d67eeb64a2fcc7948221696 Mon Sep 17 00:00:00 2001 From: James Harrison Date: Tue, 2 Jun 2026 08:47:11 +0100 Subject: [PATCH] chores: add rate_limiting --- apps/backend/package.json | 1 + .../src/__tests__/auth.integration.test.ts | 84 ++++++++++++++++++- apps/backend/src/routes/auth.ts | 23 ++++- pnpm-lock.yaml | 20 +++++ 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 89ae912d..67fede5a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -29,6 +29,7 @@ "dotenv": "^17.3.1", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "ioredis": "^5.11.0", "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", diff --git a/apps/backend/src/__tests__/auth.integration.test.ts b/apps/backend/src/__tests__/auth.integration.test.ts index dac6b640..3abd6bd3 100644 --- a/apps/backend/src/__tests__/auth.integration.test.ts +++ b/apps/backend/src/__tests__/auth.integration.test.ts @@ -34,6 +34,12 @@ vi.mock('@stellar/stellar-sdk', () => ({ // ── Import app after mocks are registered ───────────────────────────────── const { app } = await import('../app.js'); +const { challengeLimiter, verifyLimiter } = await import('../routes/auth.js'); + +function resetRateLimiters() { + challengeLimiter.resetKey('127.0.0.1'); + verifyLimiter.resetKey('127.0.0.1'); +} // ── Helpers ─────────────────────────────────────────────────────────────── @@ -51,7 +57,10 @@ function setupInsert(userId = 'new-user-id') { // ── Tests ───────────────────────────────────────────────────────────────── describe('POST /auth/challenge', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + resetRateLimiters(); + }); it('returns 200 with message and nonce for valid walletAddress', async () => { const res = await request(app).post('/auth/challenge').send({ walletAddress: WALLET }); @@ -83,7 +92,10 @@ describe('POST /auth/challenge', () => { }); describe('POST /auth/verify', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + resetRateLimiters(); + }); it('returns 200 with JWT token for valid new-user flow', async () => { mockConsumeNonce.mockReturnValue(true); @@ -164,4 +176,72 @@ describe('POST /auth/verify', () => { expect(res.status).toBe(401); expect(res.body).toHaveProperty('error'); }); +}); + +describe('Auth rate limiting', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetRateLimiters(); + mockConsumeNonce.mockReturnValue(true); + mockVerify.mockReturnValue(true); + mockFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET }); + }); + + it('allows up to 10 /auth/challenge requests per minute, blocks the 11th with 429', async () => { + for (let i = 0; i < 10; i++) { + const res = await request(app).post('/auth/challenge').send({ walletAddress: WALLET }); + expect(res.status).toBe(200); + } + + const blocked = await request(app).post('/auth/challenge').send({ walletAddress: WALLET }); + expect(blocked.status).toBe(429); + expect(blocked.headers['retry-after']).toBeDefined(); + }); + + it('allows up to 5 /auth/verify requests per minute, blocks the 6th with 429', async () => { + for (let i = 0; i < 5; i++) { + const res = await request(app) + .post('/auth/verify') + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE }); + expect(res.status).toBe(200); + } + + const blocked = await request(app) + .post('/auth/verify') + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE }); + expect(blocked.status).toBe(429); + expect(blocked.headers['retry-after']).toBeDefined(); + }); + + it('challenge and verify limiters are independent', async () => { + // Exhaust verify limit + for (let i = 0; i < 5; i++) { + await request(app) + .post('/auth/verify') + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE }); + } + const verifyBlocked = await request(app) + .post('/auth/verify') + .send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE }); + expect(verifyBlocked.status).toBe(429); + + // Challenge limit should still allow requests + const challengeRes = await request(app).post('/auth/challenge').send({ walletAddress: WALLET }); + expect(challengeRes.status).toBe(200); + }); + + it('does not affect authenticated routes (/me returns its normal status under heavy load)', async () => { + // Hammer /me well past the auth limits — it must not return 429 + for (let i = 0; i < 20; i++) { + const res = await request(app).get('/me'); + expect(res.status).not.toBe(429); + } + }); + + it('does not affect the /health endpoint under heavy load', async () => { + for (let i = 0; i < 20; i++) { + const res = await request(app).get('/health'); + expect(res.status).not.toBe(429); + } + }); }); \ No newline at end of file diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 0603dc18..ce6c7496 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { Router } from 'express'; import type { Request, Response, IRouter } from 'express'; +import rateLimit, { type RateLimitRequestHandler } from 'express-rate-limit'; import { Keypair } from '@stellar/stellar-sdk'; import { db } from '../db/index.js'; import { users, wallets } from '../db/schema.js'; @@ -12,8 +13,26 @@ import { ChallengeSchema, VerifySchema, type ChallengeBody, type VerifyBody } fr export const authRouter: IRouter = Router(); +const rateLimitedResponse = { error: 'Too many requests' }; + +export const challengeLimiter: RateLimitRequestHandler = rateLimit({ + windowMs: 60 * 1000, + limit: 10, + standardHeaders: 'draft-7', + legacyHeaders: false, + message: rateLimitedResponse, +}); + +export const verifyLimiter: RateLimitRequestHandler = rateLimit({ + windowMs: 60 * 1000, + limit: 5, + standardHeaders: 'draft-7', + legacyHeaders: false, + message: rateLimitedResponse, +}); + // Step 1: client requests a challenge nonce for a wallet address -authRouter.post('/challenge', validate(ChallengeSchema), (req: Request, res: Response) => { +authRouter.post('/challenge', challengeLimiter, validate(ChallengeSchema), (req: Request, res: Response) => { const { walletAddress } = req.body as ChallengeBody; const nonce = createNonce(walletAddress); @@ -23,7 +42,7 @@ authRouter.post('/challenge', validate(ChallengeSchema), (req: Request, res: Res }); // Step 2: client signs the message and submits the signature -authRouter.post('/verify', validate(VerifySchema), async (req: Request, res: Response) => { +authRouter.post('/verify', verifyLimiter, validate(VerifySchema), async (req: Request, res: Response) => { const { walletAddress, signature, nonce } = req.body as VerifyBody; // Validate and consume nonce diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c61a101..6aed7e04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + express-rate-limit: + specifier: ^8.5.2 + version: 8.5.2(express@5.2.1) ioredis: specifier: ^5.11.0 version: 5.11.0 @@ -2329,6 +2332,12 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -2555,6 +2564,10 @@ packages: resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} engines: {node: '>=12.22.0'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -5815,6 +5828,11 @@ snapshots: expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -6079,6 +6097,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-array-buffer@3.0.5: