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:
co-authored by
Paul Nothaft
parent
834111fc66
commit
8017370271
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user