fix(security): actually apply the general API rate limiter

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.
This commit is contained in:
Paul Nothaft
2026-09-02 09:43:10 +02:00
parent a89057df1d
commit b0f33c1744
3 changed files with 245 additions and 26 deletions
+55 -26
View File
@@ -89,6 +89,7 @@ const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler');
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
const { createApiRateLimitGate } = require('./src/middleware/apiRateLimitGate');
const { getPublicSitePayload } = require('./src/services/publicSiteService');
const cookieParser = require('cookie-parser');
const {
@@ -266,6 +267,38 @@ app.options('/api/*', cors(corsOptions));
// SSRF/path-allowlist model.
app.use('/api/analytics/tracker', require('./src/routes/analyticsTrackerProxy'));
// Health check endpoint. `pid` + `uptime` let monitors (and the local E2E
// watchdog) detect a silent process restart between two checks.
//
// Served at BOTH paths: /health is the canonical one, /api/health exists
// because probes reasonably assume the API lives under /api and otherwise
// produce a "route not found" warning on every poll. Same handler, same body,
// same (public) exposure — the alias adds no information.
//
// Mounted HERE, ahead of the API middleware chain, so a probe running every
// couple of seconds does not pay for — or pollute — the body parsers, the
// request logger, the maintenance gate (/health is on its skip list anyway)
// or the rate limiter's per-IP budget.
app.get(['/health', '/api/health'], async (req, res) => {
try {
await db.raw('SELECT 1');
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
pid: process.pid,
uptime: process.uptime()
});
} catch (error) {
logger.error('Health check failed:', error);
res.status(503).json({
status: 'error',
timestamp: new Date().toISOString(),
pid: process.pid,
uptime: process.uptime()
});
}
});
// Initialize rate limiters (they will be created dynamically)
let generalRateLimiter;
let authRateLimiter;
@@ -463,9 +496,20 @@ async function handlePublicSiteRequest(req, res, next) {
async function initializeRateLimiters() {
generalRateLimiter = await createRateLimiter();
authRateLimiter = await createAuthRateLimiter();
// Apply rate limiting
app.use('/api/', generalRateLimiter);
// The general limiter is NOT registered here — an app.use() at this point
// runs after the routers, the /api 404 handler and the error handler are
// already on the stack, so it can never see a request. It is reached through
// apiRateLimitGate below instead, which is registered ahead of the routers
// and reads this variable per request.
//
// These authRateLimiter registrations have the same problem and are equally
// inert. They are left as-is deliberately: `/api/auth` is a prefix, and the
// limiter's 5-per-window budget applies to every route under it — including
// GET /api/auth/session and POST /api/auth/password-strength, which the
// frontend calls far more often than five times per window. Activating them
// as written would lock legitimate users out, so wiring per-IP auth limiting
// up properly is a separate change.
app.use('/api/auth', authRateLimiter);
app.use('/api/gallery/:slug/verify', authRateLimiter);
app.use('/api/admin/auth/login', authRateLimiter);
@@ -473,7 +517,14 @@ async function initializeRateLimiters() {
app.use('/api/setup/verify-token', authRateLimiter);
}
// Note: Rate limiters will be initialized after database connection
// Rate limiting for /api. Registered HERE — above the routers — because
// Express dispatches in registration order; see apiRateLimitGate for the full
// story. The gate is a no-op until initializeRateLimiters() resolves, and it
// must stay unmounted (no path argument) so req.path keeps its /api prefix.
// /health and /api/health are mounted above this point and so are never
// counted, which matters because monitors poll them every couple of seconds.
app.use(createApiRateLimitGate(() => generalRateLimiter));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
@@ -741,28 +792,6 @@ app.get(
}
);
// Health check endpoint. `pid` + `uptime` let monitors (and the local E2E
// watchdog) detect a silent process restart between two checks.
app.get('/health', async (req, res) => {
try {
await db.raw('SELECT 1');
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
pid: process.pid,
uptime: process.uptime()
});
} catch (error) {
logger.error('Health check failed:', error);
res.status(503).json({
status: 'error',
timestamp: new Date().toISOString(),
pid: process.pid,
uptime: process.uptime()
});
}
});
// Routes
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
app.use('/api/auth', authRoutes);