app.use('/api/', generalRateLimiter) lives inside initializeRateLimiters(),
which is defined at line 463 but not called until 1048 -- by which point the
routers (767+), the /api notFoundHandler (1002) and errorHandler (1029) are
already on the stack. All six app.use() calls in it therefore append BELOW the
error handler and can never see a request. generalRateLimiter had no other
registration path.
So the entire /api surface had no IP-based request limit, except the handful
of routes carrying their own inline rateLimit() (public quotes, contracts,
payment-check, transfers, the analytics proxy). The admin Settings
rate-limiting UI -- rate_limit_enabled, rate_limit_max_requests -- was writing
to a control that did nothing.
Fixed with a stable gate registered above the routers that resolves the
limiter per request, so there is no boot delay: it is a pass-through until
initializeRateLimiters() resolves, exactly matching prior behaviour.
Registered unmounted (app.use(gate), not app.use('/api', gate)) because
Express strips the mount path from req.url and rateLimitService's own logic is
written against the full path -- req.path.startsWith('/api/public/') and the
/api/(gallery|secure-images)/:slug regex it uses to find a gallery token to
skip on. Mounting it would have silently broken both.
Deliberately excluded, each for a concrete reason:
- /health and /api/health, mounted above the gate: a 2s probe is 450
req/window and would 429 the container healthcheck.
- /api/public/transfer and transfer-upload: one request per file from a link
holder with no JWT, so never skipped as authenticated; a large transfer
would be cut off mid-way. Both already have tighter per-minute limiters.
- login and gallery-verify: the limiter returns authMaxRequests (5) as their
budget but counts them into the SAME per-IP bucket as every other /api call,
so the branding and settings fetches a login page makes before anyone types
a password would 429 the login itself for a full window. Giving these a real
per-IP limit means giving them their own bucket.
Bulk gallery and admin traffic is unaffected: skip_authenticated defaults true
and cookie tokens are promoted to Authorization before the gate runs, and
skipped requests do not increment the counter.
Also adds /api/health as an alias of /health -- one handler, identical
exposure -- which silences a ~2s probe warning. Registered above the API
middleware chain deliberately: left at its original position it would have
passed through apiRequestLogger and through maintenanceMiddleware, whose
skip-list contains /health but not /api/health, so it would have 503'd during
maintenance while /health returned 200.
The tests pin registration depth by source inspection as well as behaviour,
because depth is what was broken and no unit test of the gate can catch it.
Refs testplan REPORT.md, /api/health warning; rate-limiter gap found while
fixing it.
119 lines
4.8 KiB
JavaScript
119 lines
4.8 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('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\(/);
|
|
});
|
|
});
|