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,
@@ -0,0 +1,90 @@
/**
* The API rate limiter has a screen (#1337).
*
* Its six settings had a backend route and nothing in the UI, so installs
* ran on a 300-per-15-minutes budget nobody could see (issue 1287). The
* Security tab now shows the values in force, edits them through the hook's
* state, and saves them with the tab's Save button. The test i18n renders the
* English strings, so queries use those.
*/
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SecurityTab } from '../tabs/SecurityTab';
import { validateRateLimitSettings, type SecuritySettings, type RateLimitSettings } from '../hooks/useSettingsState';
const security: SecuritySettings = {
password_min_length: 8, password_complexity: 'strong', session_timeout_minutes: 60,
max_login_attempts: 5, attempt_window_minutes: 15, lockout_duration_minutes: 30,
enable_recaptcha: false, recaptcha_site_key: '', recaptcha_secret_key: '',
};
const rateLimit: RateLimitSettings = {
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,
};
function mount(overrides: Partial<RateLimitSettings> = {}) {
const setRateLimitSettings = vi.fn();
const mutate = vi.fn();
render(
<SecurityTab
securitySettings={security}
setSecuritySettings={vi.fn()}
rateLimitSettings={{ ...rateLimit, ...overrides }}
setRateLimitSettings={setRateLimitSettings}
saveSecurityMutation={{ mutate, isPending: false }}
/>
);
return { setRateLimitSettings, mutate };
}
describe('SecurityTab rate limiter card', () => {
it('shows the budget in force, defaults included', () => {
mount();
expect(screen.getByLabelText('Max requests per window')).toHaveValue(300);
expect(screen.getByLabelText('Window (minutes)')).toHaveValue(15);
expect(screen.getByLabelText('Failed logins per window')).toHaveValue(5);
expect(screen.getByLabelText('Enable the API rate limiter')).toBeChecked();
expect(screen.getByLabelText(/Do not count authenticated requests/)).toBeChecked();
expect(screen.getByLabelText(/Count public endpoints only/)).not.toBeChecked();
expect(screen.getByText(/The unit is the client IP/)).toBeInTheDocument();
});
it('edits go through the hook state as a functional update', () => {
const { setRateLimitSettings } = mount();
fireEvent.change(screen.getByLabelText('Max requests per window'), { target: { value: '5000' } });
expect(setRateLimitSettings).toHaveBeenCalledTimes(1);
const updater = setRateLimitSettings.mock.calls[0][0] as (prev: RateLimitSettings) => RateLimitSettings;
expect(updater(rateLimit)).toEqual({ ...rateLimit, rate_limit_max_requests: 5000 });
fireEvent.click(screen.getByLabelText(/Count public endpoints only/));
const toggle = setRateLimitSettings.mock.calls[1][0] as (prev: RateLimitSettings) => RateLimitSettings;
expect(toggle(rateLimit).rate_limit_public_endpoints_only).toBe(true);
});
it('the number fields carry the route\'s validation ranges', () => {
mount();
expect(screen.getByLabelText('Max requests per window')).toHaveAttribute('min', '10');
expect(screen.getByLabelText('Max requests per window')).toHaveAttribute('max', '10000');
expect(screen.getByLabelText('Window (minutes)')).toHaveAttribute('max', '60');
expect(screen.getByLabelText('Failed logins per window')).toHaveAttribute('max', '100');
});
it('the tab\'s Save button saves the limiter along with the rest', () => {
const { mutate } = mount();
fireEvent.click(screen.getByRole('button', { name: /save security settings/i }));
expect(mutate).toHaveBeenCalledTimes(1);
});
});
describe('rate limiter validation before save', () => {
it('accepts the defaults and the route\'s bounds, rejects outside and non-integers', () => {
expect(validateRateLimitSettings(rateLimit)).toBeNull();
expect(validateRateLimitSettings({ ...rateLimit, rate_limit_window_minutes: 60, rate_limit_max_requests: 10000, rate_limit_auth_max_requests: 100 })).toBeNull();
expect(validateRateLimitSettings({ ...rateLimit, rate_limit_window_minutes: 0 })).toBe('rate_limit_window_minutes');
expect(validateRateLimitSettings({ ...rateLimit, rate_limit_max_requests: 10001 })).toBe('rate_limit_max_requests');
expect(validateRateLimitSettings({ ...rateLimit, rate_limit_auth_max_requests: 0 })).toBe('rate_limit_auth_max_requests');
// a cleared number field arrives as NaN
expect(validateRateLimitSettings({ ...rateLimit, rate_limit_max_requests: Number('') })).toBe('rate_limit_max_requests');
});
});
@@ -57,6 +57,32 @@ export interface SecuritySettings {
recaptcha_secret_key: string;
}
/** The general per-IP API rate limiter (#1337). Keys match app_settings. */
export interface RateLimitSettings {
rate_limit_enabled: boolean;
rate_limit_window_minutes: number;
rate_limit_max_requests: number;
rate_limit_auth_max_requests: number;
rate_limit_skip_authenticated: boolean;
rate_limit_public_endpoints_only: boolean;
}
/** The ranges the backend route enforces; checked before anything is written. */
export const RATE_LIMIT_RANGES: Record<'rate_limit_window_minutes' | 'rate_limit_max_requests' | 'rate_limit_auth_max_requests', [number, number]> = {
rate_limit_window_minutes: [1, 60],
rate_limit_max_requests: [10, 10000],
rate_limit_auth_max_requests: [1, 100]
};
/** Returns the first out-of-range field, or null when everything is valid. */
export function validateRateLimitSettings(settings: RateLimitSettings): keyof typeof RATE_LIMIT_RANGES | null {
for (const [key, [min, max]] of Object.entries(RATE_LIMIT_RANGES) as Array<[keyof typeof RATE_LIMIT_RANGES, [number, number]]>) {
const value = settings[key];
if (!Number.isInteger(value) || value < min || value > max) return key;
}
return null;
}
export type TrackerProvider = 'none' | 'umami' | 'rybbit' | 'custom';
export interface AnalyticsSettings {
@@ -167,6 +193,17 @@ export function useSettingsState() {
recaptcha_secret_key: ''
});
// Rate limiter state. The fallbacks mirror the backend's defaults, but the
// settings read fills every key, so they only matter before the first load.
const [rateLimitSettings, setRateLimitSettings] = useState<RateLimitSettings>({
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
});
// Analytics settings state
const [analyticsSettings, setAnalyticsSettings] = useState<AnalyticsSettings>({
tracker_provider: 'none',
@@ -275,6 +312,15 @@ export function useSettingsState() {
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
});
setRateLimitSettings({
rate_limit_enabled: toBoolean(settings.rate_limit_enabled, true),
rate_limit_window_minutes: toNumber(settings.rate_limit_window_minutes, 15),
rate_limit_max_requests: toNumber(settings.rate_limit_max_requests, 300),
rate_limit_auth_max_requests: toNumber(settings.rate_limit_auth_max_requests, 5),
rate_limit_skip_authenticated: toBoolean(settings.rate_limit_skip_authenticated, true),
rate_limit_public_endpoints_only: toBoolean(settings.rate_limit_public_endpoints_only, false)
});
// Tracker provider: prefer explicit setting; fall back to legacy
// umami_enabled flag for installs that haven't picked yet (#663).
const explicitProvider = settings.analytics_tracker_provider;
@@ -401,14 +447,22 @@ export function useSettingsState() {
Object.entries(securitySettings).forEach(([key, value]) => {
settingsData[`security_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
// Nothing is written until the limiter values pass the same ranges the
// route enforces, and the limiter goes first: a 400 from its route
// would otherwise land after the password/session settings were
// already persisted, a half-applied save reported as failed (#1337).
if (validateRateLimitSettings(rateLimitSettings)) throw new Error('RATE_LIMIT_INVALID');
await settingsService.updateRateLimit(rateLimitSettings);
await settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
onError: (error: unknown) => {
toast.error(t(error instanceof Error && error.message === 'RATE_LIMIT_INVALID'
? 'settings.security.rateLimitInvalid'
: 'toast.saveError'));
}
});
@@ -653,6 +707,8 @@ export function useSettingsState() {
setGeneralSettings,
securitySettings,
setSecuritySettings,
rateLimitSettings,
setRateLimitSettings,
analyticsSettings,
setAnalyticsSettings,
eventSettings,
@@ -2,11 +2,13 @@ import React from 'react';
import { Save, Key, AlertCircle, ShieldCheck } from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { SecuritySettings } from '../hooks/useSettingsState';
import type { SecuritySettings, RateLimitSettings } from '../hooks/useSettingsState';
interface SecurityTabProps {
securitySettings: SecuritySettings;
setSecuritySettings: React.Dispatch<React.SetStateAction<SecuritySettings>>;
rateLimitSettings: RateLimitSettings;
setRateLimitSettings: React.Dispatch<React.SetStateAction<RateLimitSettings>>;
saveSecurityMutation: {
mutate: () => void;
isPending: boolean;
@@ -16,9 +18,29 @@ interface SecurityTabProps {
export const SecurityTab: React.FC<SecurityTabProps> = ({
securitySettings,
setSecuritySettings,
rateLimitSettings,
setRateLimitSettings,
saveSecurityMutation,
}) => {
const { t } = useTranslation();
const setRateLimit = <K extends keyof RateLimitSettings>(key: K, value: RateLimitSettings[K]) =>
setRateLimitSettings((prev) => ({ ...prev, [key]: value }));
const numberField = (key: keyof RateLimitSettings, min: number, max: number, labelKey: string, helpKey: string) => (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t(`settings.security.${labelKey}`)}
</label>
<Input
type="number"
min={min}
max={max}
value={rateLimitSettings[key] as number}
onChange={(e) => setRateLimit(key, Number(e.target.value) as RateLimitSettings[typeof key])}
aria-label={t(`settings.security.${labelKey}`)}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t(`settings.security.${helpKey}`)}</p>
</div>
);
return (
<div className="space-y-6">
@@ -136,6 +158,62 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
</div>
</Card>
{/* General API rate limiter (#1337). These keys had a backend route and
no screen, so installs ran on a budget nobody could see. */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">{t('settings.security.rateLimitTitle')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">{t('settings.security.rateLimitIntro')}</p>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={rateLimitSettings.rate_limit_enabled}
onChange={(e) => setRateLimit('rate_limit_enabled', e.target.checked)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.security.rateLimitEnabled')}</span>
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{numberField('rate_limit_window_minutes', 1, 60, 'rateLimitWindowMinutes', 'rateLimitWindowMinutesHelp')}
{numberField('rate_limit_max_requests', 10, 10000, 'rateLimitMaxRequests', 'rateLimitMaxRequestsHelp')}
{numberField('rate_limit_auth_max_requests', 1, 100, 'rateLimitAuthMaxRequests', 'rateLimitAuthMaxRequestsHelp')}
</div>
<label className="flex items-start">
<input
type="checkbox"
checked={rateLimitSettings.rate_limit_skip_authenticated}
onChange={(e) => setRateLimit('rate_limit_skip_authenticated', e.target.checked)}
className="mt-0.5 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
{t('settings.security.rateLimitSkipAuthenticated')}
<span className="block text-xs text-neutral-500 dark:text-neutral-400">{t('settings.security.rateLimitSkipAuthenticatedHelp')}</span>
</span>
</label>
<label className="flex items-start">
<input
type="checkbox"
checked={rateLimitSettings.rate_limit_public_endpoints_only}
onChange={(e) => setRateLimit('rate_limit_public_endpoints_only', e.target.checked)}
className="mt-0.5 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
{t('settings.security.rateLimitPublicOnly')}
<span className="block text-xs text-neutral-500 dark:text-neutral-400">{t('settings.security.rateLimitPublicOnlyHelp')}</span>
</span>
</label>
<div className="flex items-start gap-2 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
<AlertCircle className="w-5 h-5 flex-none mt-0.5" />
<p>{t('settings.security.rateLimitNatNote')}</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.security.recaptchaSettings')}</h2>
+15
View File
@@ -2325,6 +2325,21 @@
"secretKey": "Geheimer Schlüssel",
"recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von",
"saveSecuritySettings": "Sicherheitseinstellungen speichern",
"rateLimitTitle": "API-Ratenbegrenzung",
"rateLimitIntro": "Begrenzt, wie viele API-Anfragen eine Client-IP pro Zeitfenster stellen darf. Wer das Budget überschreitet, bekommt für den Rest des Fensters 429-Antworten. Für Besucher sieht das aus wie eine Galerie, die nicht mehr lädt, nicht wie ein Fehler.",
"rateLimitEnabled": "API-Ratenbegrenzung aktivieren",
"rateLimitWindowMinutes": "Zeitfenster (Minuten)",
"rateLimitWindowMinutesHelp": "Gleitendes Fenster pro Client-IP. 1 bis 60.",
"rateLimitMaxRequests": "Max. Anfragen pro Fenster",
"rateLimitMaxRequestsHelp": "Budget für API-Anfragen, die nicht ausgenommen sind. Standard 300. 10 bis 10000.",
"rateLimitAuthMaxRequests": "Fehlgeschlagene Anmeldungen pro Fenster",
"rateLimitAuthMaxRequestsHelp": "Eigenes Budget für Admin-Anmeldung und Galerie-Passwortprüfung; nur Fehlversuche zählen. Standard 5. 1 bis 100.",
"rateLimitSkipAuthenticated": "Authentifizierte Anfragen nicht zählen",
"rateLimitSkipAuthenticatedHelp": "Admin-Sitzungen sind ausgenommen, ebenso die eigenen Bildanfragen eines angemeldeten Galerie-Besuchers (Vorschaubilder, Vorschauen, Fotos).",
"rateLimitPublicOnly": "Nur öffentliche Endpunkte zählen",
"rateLimitPublicOnlyHelp": "Wenn aktiv, zählen nur Anfragen an /api/public und /api/gallery.",
"rateLimitNatNote": "Die Einheit ist die Client-IP. Ein Büro, ein Haushalt oder das WLAN einer Location teilen sich eine Adresse und damit ein Budget. Hinter einem Reverse-Proxy stimmt die Client-IP nur, wenn TRUST_PROXY den Proxy abdeckt; sonst teilen sich alle Besucher das Budget des Proxys. Abgewiesene Anfragen stehen als „Rate limit exceeded“ im Backend-Log.",
"rateLimitInvalid": "Werte der Ratenbegrenzung außerhalb des Bereichs: Zeitfenster 160 Minuten, Anfragen 1010000, fehlgeschlagene Anmeldungen 1100. Es wurde nichts gespeichert.",
"twoFactorTitle": "Zwei-Faktor-Authentifizierung",
"twoFactorNote": "Die Zwei-Faktor-Authentifizierung wird jetzt pro Admin unter Einstellungen → Allgemein → Admin-Konto verwaltet. Jeder Admin aktiviert sie für seine eigene Anmeldung."
},
+15
View File
@@ -1823,6 +1823,21 @@
"secretKey": "Secret Key",
"recaptchaHelp": "Get your reCAPTCHA keys from",
"saveSecuritySettings": "Save Security Settings",
"rateLimitTitle": "API rate limiting",
"rateLimitIntro": "Caps how many API requests one client IP may make per window. A visitor over the budget gets 429 responses for the rest of the window, which shows up as a gallery that stops loading, not as an error.",
"rateLimitEnabled": "Enable the API rate limiter",
"rateLimitWindowMinutes": "Window (minutes)",
"rateLimitWindowMinutesHelp": "Sliding window per client IP. 1 to 60.",
"rateLimitMaxRequests": "Max requests per window",
"rateLimitMaxRequestsHelp": "Budget for API requests that are not exempt. Default 300. 10 to 10000.",
"rateLimitAuthMaxRequests": "Failed logins per window",
"rateLimitAuthMaxRequestsHelp": "Separate budget for admin login and gallery password checks; only failed attempts count. Default 5. 1 to 100.",
"rateLimitSkipAuthenticated": "Do not count authenticated requests",
"rateLimitSkipAuthenticatedHelp": "Admin sessions are exempt, and so are a signed-in gallery viewer's own image requests (thumbnails, previews, photos).",
"rateLimitPublicOnly": "Count public endpoints only",
"rateLimitPublicOnlyHelp": "When on, only /api/public and /api/gallery requests count.",
"rateLimitNatNote": "The unit is the client IP. An office, a household or a venue's Wi-Fi share one address and therefore one budget. Behind a reverse proxy the client IP is only correct if TRUST_PROXY covers the proxy; otherwise every visitor shares the proxy's budget. Rejected requests are logged as \"Rate limit exceeded\" in the backend log.",
"rateLimitInvalid": "Rate limiter values are out of range: window 160 minutes, requests 1010000, failed logins 1100. Nothing was saved.",
"twoFactorTitle": "Two-factor authentication",
"twoFactorNote": "Two-factor authentication is now managed per admin from Settings → General → Admin Account. Each admin enables it for their own login."
},
@@ -209,6 +209,8 @@ export const SettingsPage: React.FC = () => {
generalSettings,
setGeneralSettings,
securitySettings,
rateLimitSettings,
setRateLimitSettings,
setSecuritySettings,
analyticsSettings,
setAnalyticsSettings,
@@ -571,6 +573,8 @@ export const SettingsPage: React.FC = () => {
<SecurityTab
securitySettings={securitySettings}
setSecuritySettings={setSecuritySettings}
rateLimitSettings={rateLimitSettings}
setRateLimitSettings={setRateLimitSettings}
saveSecurityMutation={saveSecurityMutation}
/>
)}
+12
View File
@@ -312,6 +312,18 @@ export const settingsService = {
await api.put('/admin/settings/theme', settings);
},
// General API rate limiter (#1337). Its own route validates ranges and
// clears the limiter's settings cache, so changes apply at once.
async updateRateLimit(settings: {
rate_limit_enabled: boolean;
rate_limit_window_minutes: number;
rate_limit_max_requests: number;
rate_limit_auth_max_requests: number;
rate_limit_skip_authenticated: boolean;
rate_limit_public_endpoints_only: boolean;
}): Promise<void> {
await api.put('/admin/settings/security/rate-limit', settings);
},
// Update multiple settings at once
async updateSettings(settings: Record<string, any>): Promise<void> {
// Determine the endpoint based on setting keys