Files
picpeak/backend/__tests__/middleware/apiRateLimitGate.test.js
T
Paul Nothaft a929affd7e fix(security): close the case-sensitivity bypass in the API rate limiter
Express's `case sensitive routing` is off by default, so /API/admin/events
reaches the same handler as /api/admin/events. Both the gate's `/api/` prefix
test and rateLimitService's public-endpoint classification compared the raw
path, so simply upper-casing a letter skipped the limiter entirely.

Verified against a real Express app before fixing: /api/admin/events routes
and hits the gate; /API/admin/events and /Api/Admin/Events route and miss it.

Both now match on a lower-cased path. The auth gate added alongside was
already immune -- its patterns carry the `i` flag for exactly this reason.

Not changed: rateLimitSecurity.hasValidAdminToken's /api/admin/ test has the
same shape, but there the case-sensitive comparison fails safe -- an
upper-cased path simply does not get the admin skip, so it is rate limited
rather than exempted. Making it case-insensitive would widen a skip, so it is
left alone. maintenance.js's isAdminRoute is fail-safe for the same reason.
2026-09-02 09:43:11 +02:00

128 lines
5.2 KiB
JavaScript

/**
* The app-wide /api rate limiter has to actually be on the stack.
*
* It used to be registered from inside initializeRateLimiters(), which runs
* after the database is up — by which time every router, the /api 404 handler
* and the error handler are already mounted. Express dispatches middleware in
* registration order, so `app.use('/api/', generalRateLimiter)` landed below
* everything that answers a request and never executed for a matched route:
* the limit was silently inert on every deployment.
*
* Two halves here. The behavioural half pins what the gate delegates and what
* it deliberately lets past. The source half pins the thing that was actually
* broken — registration DEPTH — because that only exists in server.js and no
* unit test of the gate itself can catch a regression of it.
*/
const fs = require('fs');
const path = require('path');
const express = require('express');
const request = require('supertest');
const { createApiRateLimitGate } = require('../../src/middleware/apiRateLimitGate');
describe('apiRateLimitGate — delegation', () => {
let limiterCalls;
let limiter;
// Mirrors the real stack: health above the gate, gate above the routers.
const buildApp = () => {
const app = express();
app.get(['/health', '/api/health'], (req, res) => res.json({ status: 'ok' }));
app.use(createApiRateLimitGate(() => limiter));
app.get('/api/admin/events', (req, res) => res.json({ ok: true }));
app.get('/api/public/transfer-upload/:token', (req, res) => res.json({ ok: true }));
app.post('/api/admin/auth/login', (req, res) => res.json({ ok: true }));
app.get('/api/gallery/:slug/verify', (req, res) => res.json({ ok: true }));
app.get('/photos/x.jpg', (req, res) => res.json({ ok: true }));
return app;
};
beforeEach(() => {
limiterCalls = [];
limiter = (req, res) => {
limiterCalls.push(req.path);
res.status(429).json({ error: 'Too many requests, please try again later.' });
};
});
it('sends a plain /api request through the limiter', async () => {
const res = await request(buildApp()).get('/api/admin/events');
expect(res.status).toBe(429);
// The full path reaches the limiter — the gate must not be mounted on
// '/api', or Express would strip the prefix and break the limiter's own
// public-endpoint and gallery-slug checks.
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);
expect(limiterCalls).toEqual([]);
});
it('never counts the health probes, which poll every few seconds', async () => {
const app = buildApp();
expect((await request(app).get('/health')).status).toBe(200);
expect((await request(app).get('/api/health')).status).toBe(200);
expect(limiterCalls).toEqual([]);
});
it('exempts bulk client transfer, which has its own per-minute limiters', async () => {
const res = await request(buildApp()).get('/api/public/transfer-upload/abc');
expect(res.status).toBe(200);
expect(limiterCalls).toEqual([]);
});
it('exempts login and gallery-verify, which would 429 on the shared bucket', async () => {
const app = buildApp();
expect((await request(app).post('/api/admin/auth/login')).status).toBe(200);
expect((await request(app).get('/api/gallery/some-slug/verify')).status).toBe(200);
expect(limiterCalls).toEqual([]);
});
it('passes through during the boot window, before the limiter exists', async () => {
limiter = undefined;
const res = await request(buildApp()).get('/api/admin/events');
expect(res.status).toBe(200);
});
});
describe('server.js — the gate is registered above the routers', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../../server.js'), 'utf8');
const lines = source.split('\n');
const lineOf = (re) => {
const i = lines.findIndex((l) => re.test(l));
expect(i).toBeGreaterThan(-1);
return i;
};
it('registers the gate before the first router mount', () => {
expect(lineOf(/createApiRateLimitGate\(/))
.toBeLessThan(lineOf(/^app\.use\('\/api\/setup'/));
});
it('registers the health routes above the gate so probes are never counted', () => {
expect(lineOf(/app\.get\(\[.\/health., .\/api\/health.\]/))
.toBeLessThan(lineOf(/createApiRateLimitGate\(/));
});
it('no longer registers the general limiter from initializeRateLimiters', () => {
// This is the regression: an app.use() there runs after the error handler
// and can never see a request.
expect(source).not.toMatch(/app\.use\('\/api\/?',\s*generalRateLimiter\)/);
});
it('registers the gate unmounted, so req.path keeps its /api prefix', () => {
expect(source).toMatch(/app\.use\(createApiRateLimitGate\(/);
});
});