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,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