diff --git a/backend/__tests__/middleware/apiRateLimitGate.test.js b/backend/__tests__/middleware/apiRateLimitGate.test.js index 707ee828..e1b2e22a 100644 --- a/backend/__tests__/middleware/apiRateLimitGate.test.js +++ b/backend/__tests__/middleware/apiRateLimitGate.test.js @@ -54,6 +54,15 @@ describe('apiRateLimitGate — delegation', () => { expect(limiterCalls).toEqual(['/api/admin/events']); }); + it('still limits an upper-cased /api path, which Express routes the same', async () => { + // Express's `case sensitive routing` is off by default, so /API/admin/events + // reaches the same handler. A case-sensitive prefix test in the gate was a + // free bypass of the limiter. + const res = await request(buildApp()).get('/API/admin/events'); + expect(res.status).toBe(429); + expect(limiterCalls).toEqual(['/API/admin/events']); + }); + it('leaves non-/api requests alone', async () => { const res = await request(buildApp()).get('/photos/x.jpg'); expect(res.status).toBe(200); diff --git a/backend/src/middleware/apiRateLimitGate.js b/backend/src/middleware/apiRateLimitGate.js index 4e23710b..b3843ec2 100644 --- a/backend/src/middleware/apiRateLimitGate.js +++ b/backend/src/middleware/apiRateLimitGate.js @@ -56,9 +56,14 @@ const AUTH_ENDPOINT_RE = /\/(auth|login|gallery\/[^/]+\/verify)$/; */ function createApiRateLimitGate(getLimiter) { return function apiRateLimitGate(req, res, next) { - if (!req.path.startsWith('/api/')) return next(); - if (EXEMPT_PREFIXES.some((prefix) => req.path.startsWith(prefix))) return next(); - if (AUTH_ENDPOINT_RE.test(req.path)) return next(); + // Lower-cased for matching: Express's `case sensitive routing` is off by + // default, so `/API/admin/events` reaches the same handler as + // `/api/admin/events`. A case-sensitive prefix test here would have been a + // free bypass of the limiter (verified against a real Express app). + const path = req.path.toLowerCase(); + if (!path.startsWith('/api/')) return next(); + if (EXEMPT_PREFIXES.some((prefix) => path.startsWith(prefix))) return next(); + if (AUTH_ENDPOINT_RE.test(path)) return next(); const limiter = getLimiter(); // Boot window: the database is not up yet, so there is nothing to delegate diff --git a/backend/src/services/rateLimitService.js b/backend/src/services/rateLimitService.js index 701fb328..8e55ff29 100644 --- a/backend/src/services/rateLimitService.js +++ b/backend/src/services/rateLimitService.js @@ -138,8 +138,11 @@ function shouldSkipRateLimit(req, config) { // Check if we only rate limit public endpoints if (config.publicEndpointsOnly) { - const isPublicEndpoint = req.path.startsWith('/api/public/') || - req.path.startsWith('/api/gallery/') || + // Lower-cased: Express routing is case-insensitive by default, so an + // upper-cased path reaches the same handler and must classify the same way. + const lowerPath = req.path.toLowerCase(); + const isPublicEndpoint = lowerPath.startsWith('/api/public/') || + lowerPath.startsWith('/api/gallery/') || isAuthEndpoint; return !isPublicEndpoint; }