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>