a929affd7e
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.
128 lines
5.2 KiB
JavaScript
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\(/);
|
|
});
|
|
});
|