feat(settings): expose the API rate limiter in the Security tab (#1338)

* feat(settings): expose the API rate limiter in the Security tab

The general per-IP limiter had six settings in app_settings and a
backend route to write them, and no screen. Installs ran on the code
fallback — 300 requests per 15 minutes per IP — with no way to see it,
which is how issue 1287 played out: a 546-photo gallery exhausted the
budget for one viewer and the operator learned about the setting from
a grep of the backend log.

Security tab: a card with the six settings, the validation ranges the
route enforces, a one-line explanation per field, and a note that the
unit is the client IP — an office or household behind one NAT shares
a budget, and behind a proxy TRUST_PROXY has to cover the proxy or
every visitor shares its address. The tab's Save button saves the
limiter through its own route. The limiter values are checked against
the route's ranges before anything is written and the limiter is
written first, so a rejected value cannot leave the password/session
settings half-saved behind a failure toast.

Backend, three things the screen needed:

- The settings read fills the six keys with the code defaults when
  they have no row, so the form shows the budget in force rather than
  an empty field; the defaults live in one exported constant the
  limiter itself reads.
- The write route upserts instead of updating: on a fresh install,
  which has no rows, the old UPDATE matched nothing and the route
  answered 200 while changing nothing.
- The live limiter instances move into rateLimitService and the
  write route rebuilds them. express-rate-limit fixes windowMs when an
  instance is built — max and skip re-read the settings per request,
  the window does not — so a saved window used to apply only after a
  restart. The gates in server.js resolve the instance per request
  through the service's getters. A rebuild starts fresh counters,
  which on a settings change is acceptable. The limiters get explicit
  MemoryStores and a rebuild shuts the superseded ones down, because a
  store keeps a cleanup interval alive for as long as it exists and
  dropping the reference alone would leak one timer per save.

Tests: the read surfaces defaults and honours the key filter; the
write creates rows on a fresh database, the limiter sees the values
immediately and hands the gates a fresh instance; existing rows are
updated not duplicated; out-of-range values are rejected. The tab
renders the values, edits through the hook state, carries the ranges,
saves with the tab's button, and the pre-write validation accepts the
bounds and rejects outside them and cleared fields.

Relates to issue 1337

* docs(security): point the rate limiter doc at the Security tab and the upsert

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-07 20:02:40 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 834111fc66
commit 8017370271
12 changed files with 486 additions and 23 deletions
@@ -0,0 +1,123 @@
/**
* The general API rate limiter's settings, read and written by the admin
* (#1337).
*
* Until now the six rate_limit_* keys had a write route and no screen, and
* the write route used a plain UPDATE — on a fresh install, which has no
* rows, it answered 200 and changed nothing. The settings read did not
* mention the keys at all when they had no row, so the budget in force
* (300 per 15 minutes per IP) was invisible. Pins: the read surfaces the
* defaults, the write creates rows, validation holds, and the limiter picks
* the new values up at once.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-ratelimit-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'ratelimit-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-ratelimit-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
const { clearPermissionCache } = require('../../src/middleware/permissions');
const rateLimitService = require('../../src/services/rateLimitService');
const { MemoryStore } = require('express-rate-limit');
describe('admin rate limiter settings', () => {
let db; let cleanup; let app; let tok; let general;
const auth = (req) => req.set('Authorization', `Bearer ${tok}`);
const rows = () => db('app_settings').where('setting_key', 'like', 'rate_limit_%').orderBy('setting_key');
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
tok = mintAdminToken(adminId);
await db('app_settings').where('setting_key', 'like', 'rate_limit_%').delete();
clearPermissionCache();
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('surfaces the code defaults in the settings read when no row exists', async () => {
expect(await rows()).toHaveLength(0);
const res = await auth(request(app).get('/api/admin/settings'));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
rate_limit_enabled: true, rate_limit_window_minutes: 15, rate_limit_max_requests: 300,
rate_limit_auth_max_requests: 5, rate_limit_skip_authenticated: true, rate_limit_public_endpoints_only: false,
});
// and only when asked for, with a key filter
const filtered = await auth(request(app).get('/api/admin/settings?keys=rate_limit_max_requests,general_site_url'));
expect(filtered.body.rate_limit_max_requests).toBe(300);
expect(filtered.body).not.toHaveProperty('rate_limit_window_minutes');
});
it('creates the rows on a fresh install and the limiter sees the change at once', async () => {
const before = await rateLimitService.getRateLimitSettings();
expect(before.maxRequests).toBe(300);
const res = await auth(request(app).put('/api/admin/settings/security/rate-limit')).send({
rate_limit_enabled: true, rate_limit_window_minutes: 10, rate_limit_max_requests: 5000,
rate_limit_auth_max_requests: 8, rate_limit_skip_authenticated: true, rate_limit_public_endpoints_only: false,
});
expect(res.status).toBe(200);
const stored = await rows();
expect(stored.map((r) => r.setting_key)).toEqual([
'rate_limit_auth_max_requests', 'rate_limit_enabled', 'rate_limit_max_requests',
'rate_limit_public_endpoints_only', 'rate_limit_skip_authenticated', 'rate_limit_window_minutes',
]);
expect(stored.every((r) => r.setting_type === 'security')).toBe(true);
const read = await auth(request(app).get('/api/admin/settings'));
expect(read.body.rate_limit_max_requests).toBe(5000);
expect(read.body.rate_limit_window_minutes).toBe(10);
// The route clears the limiter's 60-second cache, so the new budget applies now.
// The window is fixed per limiter instance, so the route rebuilds them too.
expect(rateLimitService.getGeneralLimiter()).toEqual(expect.any(Function));
expect(rateLimitService.getAuthLimiter()).toEqual(expect.any(Function));
general = rateLimitService.getGeneralLimiter();
const after = await rateLimitService.getRateLimitSettings();
expect(after.maxRequests).toBe(5000);
expect(after.windowMinutes).toBe(10);
expect(after.authMaxRequests).toBe(8);
});
it('updates existing rows rather than duplicating them', async () => {
// A rebuild must not leak the previous stores' cleanup intervals.
const shutdown = jest.spyOn(MemoryStore.prototype, 'shutdown');
await auth(request(app).put('/api/admin/settings/security/rate-limit')).send({
rate_limit_enabled: false, rate_limit_window_minutes: 15, rate_limit_max_requests: 300,
rate_limit_auth_max_requests: 5, rate_limit_skip_authenticated: false, rate_limit_public_endpoints_only: true,
});
expect(await rows()).toHaveLength(6);
const after = await rateLimitService.getRateLimitSettings();
expect(after).toMatchObject({ enabled: false, maxRequests: 300, skipAuthenticated: false, publicEndpointsOnly: true });
// and every save hands the gates a fresh instance, shutting the old stores down
expect(rateLimitService.getGeneralLimiter()).not.toBe(general);
expect(shutdown).toHaveBeenCalledTimes(2);
shutdown.mockRestore();
});
it('rejects values outside the documented ranges', async () => {
for (const bad of [
{ rate_limit_window_minutes: 0 }, { rate_limit_window_minutes: 61 },
{ rate_limit_max_requests: 9 }, { rate_limit_max_requests: 10001 },
{ rate_limit_auth_max_requests: 0 }, { rate_limit_enabled: 'yes' },
]) {
const res = await auth(request(app).put('/api/admin/settings/security/rate-limit')).send({
rate_limit_enabled: true, rate_limit_window_minutes: 15, rate_limit_max_requests: 300,
rate_limit_auth_max_requests: 5, rate_limit_skip_authenticated: true, rate_limit_public_endpoints_only: false,
...bad,
});
expect(res.status).toBe(400);
}
});
});
+1 -1
View File
@@ -87,7 +87,7 @@ When rate limits are exceeded, the following is logged:
## Configuration Settings
The general limiter reads these keys from `app_settings` (cached for 60 seconds). There is no admin screen for them yet; they are set through `PUT /api/admin/settings/security/rate-limit` (all six fields required) or directly in the table, JSON-encoded. The defaults below are what applies when a key has no row — a fresh install has none. Note that the route only updates rows that already exist: on a fresh install it answers 200 without writing anything, so insert the six rows directly first.
The general limiter reads these keys from `app_settings` (cached for 60 seconds). They are edited in the admin panel under Settings → Security (the API rate limiter card), which saves through `PUT /api/admin/settings/security/rate-limit` (all six fields required). The route upserts, so a fresh install needs no rows first, and the live limiters are rebuilt on save, so a new window applies without a restart. The defaults below are what applies when a key has no row; the settings read fills them in, so the form shows the budget in force.
| Setting | Default | Range | Description |
|---------|---------|-------|-------------|
+7 -7
View File
@@ -88,7 +88,7 @@ const backgroundProcessor = require('./src/services/backgroundProcessor');
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 rateLimitService = require('./src/services/rateLimitService');
const { createApiRateLimitGate } = require('./src/middleware/apiRateLimitGate');
const { createAuthRateLimitGate } = require('./src/middleware/authRateLimitGate');
const { getPublicSitePayload } = require('./src/services/publicSiteService');
@@ -293,8 +293,6 @@ app.get(['/health', '/api/health'], async (req, res) => {
});
// Initialize rate limiters (they will be created dynamically)
let generalRateLimiter;
let authRateLimiter;
function composeInlineStyles(payload) {
const { branding } = payload;
@@ -487,8 +485,10 @@ async function handlePublicSiteRequest(req, res, next) {
// Function to initialize rate limiters
async function initializeRateLimiters() {
generalRateLimiter = await createRateLimiter();
authRateLimiter = await createAuthRateLimiter();
// The instances live in rateLimitService so the settings route can
// rebuild them when the window changes (#1337); the gates below read
// them per request through the service's getters.
await rateLimitService.initializeRateLimiters();
// Neither limiter is registered here — an app.use() at this point runs after
// the routers, the /api 404 handler and the error handler are already on the
@@ -511,13 +511,13 @@ async function initializeRateLimiters() {
// 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(createApiRateLimitGate(rateLimitService.getGeneralLimiter));
// Per-IP limit for credential-verification endpoints only, on its own bucket.
// Registered after the general gate so that an IP already over the /api budget
// is rejected there first; see authRateLimitGate for the exact endpoint table
// and why it must stay unmounted.
app.use(createAuthRateLimitGate(() => authRateLimiter));
app.use(createAuthRateLimitGate(rateLimitService.getAuthLimiter));
// Body limits. 50mb is only needed by the authenticated admin and API-token
// surfaces (restore manifests, CMS and email templates, bulk operations);
+24 -3
View File
@@ -10,7 +10,7 @@ const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission, userHasAnyPermission } = require('../middleware/permissions');
const { clearMaintenanceCache } = require('../middleware/maintenance');
const { clearSettingsCache } = require('../services/rateLimitService');
const { clearSettingsCache, initializeRateLimiters, RATE_LIMIT_DEFAULTS } = require('../services/rateLimitService');
const {
DEFAULT_PUBLIC_SITE_HTML,
DEFAULT_PUBLIC_SITE_CSS,
@@ -282,6 +282,14 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
settingsObject.analytics_rybbit_api_key = '••••••••';
}
// The general API rate limiter falls back to code defaults when a key has
// no row, which is every fresh install. Surface those so the Security tab
// shows the budget actually in force instead of an empty field (#1337).
for (const [key, value] of Object.entries(RATE_LIMIT_DEFAULTS)) {
if (settingsObject[key] === undefined && (!keysFilter || keysFilter.includes(key))) {
settingsObject[key] = value;
}
}
res.json(settingsObject);
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch settings');
@@ -2067,10 +2075,19 @@ router.put('/security/rate-limit', adminAuth, requirePermission('settings.securi
{ key: 'rate_limit_public_endpoints_only', value: rate_limit_public_endpoints_only }
];
// Upsert, not update: a fresh install has no rate_limit_* rows, and a
// plain update matched nothing there — the route answered 200 and
// changed nothing (#1337).
for (const { key, value } of settings) {
await db('app_settings')
.where('setting_key', key)
.update({
.insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'security',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
@@ -2078,6 +2095,10 @@ router.put('/security/rate-limit', adminAuth, requirePermission('settings.securi
// Clear the rate limit settings cache to apply changes immediately
clearSettingsCache();
// max and skip re-read the settings per request, the window is fixed
// per limiter instance: rebuild so a changed window applies now rather
// than after a restart (#1337). Counters start fresh.
await initializeRateLimiters();
// Log activity
await logActivity('settings_updated',
+57 -8
View File
@@ -1,9 +1,23 @@
const rateLimit = require('express-rate-limit');
const { MemoryStore } = require('express-rate-limit');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
// What applies when app_settings has no row for a key — a fresh install has
// none. Keyed by setting name so the admin settings read can surface the
// same values (#1337): the Security tab must show the budget that is in
// force, not an empty field that hides it.
const RATE_LIMIT_DEFAULTS = Object.freeze({
rate_limit_enabled: true,
rate_limit_window_minutes: 15,
rate_limit_max_requests: 300,
rate_limit_auth_max_requests: 5,
rate_limit_skip_authenticated: true,
rate_limit_public_endpoints_only: false
});
// Cache for rate limit settings
let settingsCache = null;
let cacheExpiry = 0;
@@ -41,12 +55,12 @@ async function getRateLimitSettings() {
// roughly twenty guests per window. An explicit app_settings value still
// wins over this fallback.
const config = {
enabled: true,
windowMinutes: 15,
maxRequests: 300,
authMaxRequests: 5,
skipAuthenticated: true,
publicEndpointsOnly: false
enabled: RATE_LIMIT_DEFAULTS.rate_limit_enabled,
windowMinutes: RATE_LIMIT_DEFAULTS.rate_limit_window_minutes,
maxRequests: RATE_LIMIT_DEFAULTS.rate_limit_max_requests,
authMaxRequests: RATE_LIMIT_DEFAULTS.rate_limit_auth_max_requests,
skipAuthenticated: RATE_LIMIT_DEFAULTS.rate_limit_skip_authenticated,
publicEndpointsOnly: RATE_LIMIT_DEFAULTS.rate_limit_public_endpoints_only
};
settings.forEach(setting => {
@@ -211,10 +225,11 @@ function shouldSkipRateLimit(req, config) {
/**
* Create dynamic rate limiter
*/
async function createRateLimiter() {
async function createRateLimiter(store = new MemoryStore()) {
const config = await getRateLimitSettings();
return rateLimit({
store,
windowMs: config.windowMinutes * 60 * 1000,
max: async (req) => {
// Refresh config for each request
@@ -278,10 +293,11 @@ async function createRateLimiter() {
* makes a 5-per-window per-IP budget safe behind NAT — a room full of guests on
* one venue IP who all type the correct gallery password consume nothing.
*/
async function createAuthRateLimiter() {
async function createAuthRateLimiter(store = new MemoryStore()) {
const config = await getRateLimitSettings();
return rateLimit({
store,
windowMs: config.windowMinutes * 60 * 1000,
// Read per request, like the general limiter, so a change to
// rate_limit_auth_max_requests in admin Settings takes effect within the
@@ -330,7 +346,40 @@ async function createAuthRateLimiter() {
});
}
// The live limiter instances. express-rate-limit fixes windowMs when an
// instance is built — max and skip re-read the settings per request, the
// window does not — so a saved window only takes effect on a rebuild. The
// gates in server.js resolve the instance per request through the getters
// below, and the settings route rebuilds after a save (#1337). Rebuilding
// starts fresh counters; on a settings change that is acceptable.
//
// The stores are held explicitly because a MemoryStore runs a cleanup
// interval for as long as it exists: dropping the limiter reference alone
// would leave one more live timer and one more retained store per save.
const current = { general: null, auth: null, stores: [] };
async function initializeRateLimiters() {
const stores = [new MemoryStore(), new MemoryStore()];
const general = await createRateLimiter(stores[0]);
const auth = await createAuthRateLimiter(stores[1]);
const superseded = current.stores;
current.general = general;
current.auth = auth;
current.stores = stores;
for (const store of superseded) {
if (typeof store.shutdown === 'function') store.shutdown();
}
return current;
}
const getGeneralLimiter = () => current.general;
const getAuthLimiter = () => current.auth;
module.exports = {
RATE_LIMIT_DEFAULTS,
initializeRateLimiters,
getGeneralLimiter,
getAuthLimiter,
getRateLimitSettings,
clearSettingsCache,
createRateLimiter,