diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 483c7316..51ebea03 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ const Domains = lazy(() => import('./pages/Domains')) const Security = lazy(() => import('./pages/Security')) const AccessLists = lazy(() => import('./pages/AccessLists')) const WafConfig = lazy(() => import('./pages/WafConfig')) +const RateLimiting = lazy(() => import('./pages/RateLimiting')) const Uptime = lazy(() => import('./pages/Uptime')) const Notifications = lazy(() => import('./pages/Notifications')) const Login = lazy(() => import('./pages/Login')) @@ -56,7 +57,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/RateLimiting.tsx b/frontend/src/pages/RateLimiting.tsx new file mode 100644 index 00000000..78342dfb --- /dev/null +++ b/frontend/src/pages/RateLimiting.tsx @@ -0,0 +1,192 @@ +import { useState, useEffect } from 'react' +import { Gauge, Info } from 'lucide-react' +import { Button } from '../components/ui/Button' +import { Input } from '../components/ui/Input' +import { Card } from '../components/ui/Card' +import { useSecurityStatus, useSecurityConfig, useUpdateSecurityConfig } from '../hooks/useSecurity' +import { updateSetting } from '../api/settings' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from '../utils/toast' +import { ConfigReloadOverlay } from '../components/LoadingStates' + +export default function RateLimiting() { + const { data: status, isLoading: statusLoading } = useSecurityStatus() + const { data: configData, isLoading: configLoading } = useSecurityConfig() + const updateConfigMutation = useUpdateSecurityConfig() + const queryClient = useQueryClient() + + const [rps, setRps] = useState(10) + const [burst, setBurst] = useState(5) + const [window, setWindow] = useState(60) + + const config = configData?.config + + // Sync local state with fetched config + useEffect(() => { + if (config) { + setRps(config.rate_limit_requests ?? 10) + setBurst(config.rate_limit_burst ?? 5) + setWindow(config.rate_limit_window_sec ?? 60) + } + }, [config]) + + const toggleMutation = useMutation({ + mutationFn: async (enabled: boolean) => { + await updateSetting('security.rate_limit.enabled', enabled ? 'true' : 'false', 'security', 'bool') + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['securityStatus'] }) + toast.success('Rate limiting setting updated') + }, + onError: (err: Error) => { + toast.error(`Failed to update: ${err.message}`) + }, + }) + + const handleToggle = () => { + const newValue = !status?.rate_limit?.enabled + toggleMutation.mutate(newValue) + } + + const handleSave = () => { + updateConfigMutation.mutate({ + rate_limit_requests: rps, + rate_limit_burst: burst, + rate_limit_window_sec: window, + }) + } + + const isApplyingConfig = toggleMutation.isPending || updateConfigMutation.isPending + + if (statusLoading || configLoading) { + return
Loading...
+ } + + const enabled = status?.rate_limit?.enabled ?? false + + return ( + <> + {isApplyingConfig && ( + + )} +
+ {/* Header */} +
+

+ + Rate Limiting Configuration +

+

+ Control request rates to protect your services from abuse +

+
+ + {/* Info Banner */} +
+
+ +
+

+ About Rate Limiting +

+

+ Rate limiting helps protect your services from abuse, brute-force attacks, and + excessive resource consumption. Configure limits per client IP address. +

+
+
+
+ + {/* Enable/Disable Toggle */} + +
+
+

Enable Rate Limiting

+

+ {enabled + ? 'Rate limiting is active and protecting your services' + : 'Enable to start limiting request rates'} +

+
+ +
+
+ + {/* Configuration Section - Only visible when enabled */} + {enabled && ( + +

Configuration

+
+ setRps(parseInt(e.target.value, 10) || 1)} + helperText="Maximum requests allowed per second per client" + data-testid="rate-limit-rps" + /> + setBurst(parseInt(e.target.value, 10) || 1)} + helperText="Allow short bursts above the rate limit" + data-testid="rate-limit-burst" + /> + setWindow(parseInt(e.target.value, 10) || 1)} + helperText="Time window for rate calculations" + data-testid="rate-limit-window" + /> +
+
+ +
+
+ )} + + {/* Guidance when disabled */} + {!enabled && ( + +
+
⏱️
+

Rate Limiting Disabled

+

+ Enable rate limiting to configure request limits and protect your services +

+
+
+ )} +
+ + ) +} diff --git a/frontend/src/pages/WafConfig.tsx b/frontend/src/pages/WafConfig.tsx index 9272003c..cbfdbc04 100644 --- a/frontend/src/pages/WafConfig.tsx +++ b/frontend/src/pages/WafConfig.tsx @@ -1,11 +1,45 @@ import { useState } from 'react' -import { Shield, Plus, Pencil, Trash2, ExternalLink, FileCode2 } from 'lucide-react' +import { Shield, Plus, Pencil, Trash2, ExternalLink, FileCode2, Sparkles } from 'lucide-react' import { Button } from '../components/ui/Button' import { Input } from '../components/ui/Input' import { useRuleSets, useUpsertRuleSet, useDeleteRuleSet } from '../hooks/useSecurity' import type { SecurityRuleSet, UpsertRuleSetPayload } from '../api/security' import { ConfigReloadOverlay } from '../components/LoadingStates' +/** + * WAF Rule Presets for common security configurations + */ +const WAF_PRESETS = [ + { + name: 'OWASP Core Rule Set', + url: 'https://github.com/coreruleset/coreruleset/archive/refs/tags/v3.3.5.tar.gz', + content: '', + description: 'Industry standard protection against OWASP Top 10 vulnerabilities.', + }, + { + name: 'Basic SQL Injection Protection', + url: '', + content: `SecRule ARGS "@detectSQLi" "id:1001,phase:1,deny,status:403,msg:'SQLi Detected'" +SecRule REQUEST_BODY "@detectSQLi" "id:1002,phase:2,deny,status:403,msg:'SQLi in Body'" +SecRule REQUEST_COOKIES "@detectSQLi" "id:1003,phase:1,deny,status:403,msg:'SQLi in Cookies'"`, + description: 'Simple rules to block common SQL injection patterns.', + }, + { + name: 'Basic XSS Protection', + url: '', + content: `SecRule ARGS "@detectXSS" "id:2001,phase:1,deny,status:403,msg:'XSS Detected'" +SecRule REQUEST_BODY "@detectXSS" "id:2002,phase:2,deny,status:403,msg:'XSS in Body'"`, + description: 'Rules to block common Cross-Site Scripting (XSS) attacks.', + }, + { + name: 'Common Bad Bots', + url: '', + content: `SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(curl|wget|python|scrapy|httpclient|libwww|nikto|sqlmap)" "id:3001,phase:1,deny,status:403,msg:'Bad Bot Detected'" +SecRule REQUEST_HEADERS:User-Agent "@streq -" "id:3002,phase:1,deny,status:403,msg:'Empty User-Agent'"`, + description: 'Block known malicious bots and scanners.', + }, +] as const + /** * Confirmation dialog for destructive actions */ @@ -78,6 +112,19 @@ function RuleSetForm({ const [mode, setMode] = useState<'blocking' | 'detection'>( initialData?.mode === 'detection' ? 'detection' : 'blocking' ) + const [selectedPreset, setSelectedPreset] = useState('') + + const handlePresetChange = (presetName: string) => { + setSelectedPreset(presetName) + if (presetName === '') return + + const preset = WAF_PRESETS.find((p) => p.name === presetName) + if (preset) { + setName(preset.name) + setSourceUrl(preset.url) + setContent(preset.content) + } + } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() @@ -94,6 +141,34 @@ function RuleSetForm({ return (
+ {/* Presets Dropdown - only show when creating new */} + {!initialData && ( +
+ + + {selectedPreset && ( +

+ {WAF_PRESETS.find((p) => p.name === selectedPreset)?.description} +

+ )} +
+ )} + + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + +const renderWithProviders = (ui: React.ReactNode) => { + const qc = createQueryClient() + return render( + + {ui} + + ) +} + +const mockStatusEnabled: SecurityStatus = { + cerberus: { enabled: true }, + crowdsec: { enabled: false, mode: 'disabled', api_url: '' }, + waf: { enabled: false, mode: 'disabled' }, + rate_limit: { enabled: true, mode: 'enabled' }, + acl: { enabled: false }, +} + +const mockStatusDisabled: SecurityStatus = { + cerberus: { enabled: true }, + crowdsec: { enabled: false, mode: 'disabled', api_url: '' }, + waf: { enabled: false, mode: 'disabled' }, + rate_limit: { enabled: false, mode: 'disabled' }, + acl: { enabled: false }, +} + +const mockSecurityConfig = { + config: { + name: 'default', + rate_limit_requests: 10, + rate_limit_burst: 5, + rate_limit_window_sec: 60, + }, +} + +describe('RateLimiting page', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('shows loading state while fetching status', async () => { + vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {})) + vi.mocked(securityApi.getSecurityConfig).mockReturnValue(new Promise(() => {})) + + renderWithProviders() + + expect(screen.getByText('Loading...')).toBeInTheDocument() + }) + + it('renders rate limiting page with toggle disabled when rate_limit is off', async () => { + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusDisabled) + vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Rate Limiting Configuration')).toBeInTheDocument() + }) + + const toggle = screen.getByTestId('rate-limit-toggle') + expect(toggle).toBeInTheDocument() + expect((toggle as HTMLInputElement).checked).toBe(false) + }) + + it('renders rate limiting page with toggle enabled when rate_limit is on', async () => { + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled) + vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Rate Limiting Configuration')).toBeInTheDocument() + }) + + const toggle = screen.getByTestId('rate-limit-toggle') + expect((toggle as HTMLInputElement).checked).toBe(true) + }) + + it('shows configuration inputs when enabled', async () => { + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled) + vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('rate-limit-rps')).toBeInTheDocument() + }) + + expect(screen.getByTestId('rate-limit-burst')).toBeInTheDocument() + expect(screen.getByTestId('rate-limit-window')).toBeInTheDocument() + }) + + it('calls updateSetting when toggle is clicked', async () => { + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusDisabled) + vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig) + vi.mocked(settingsApi.updateSetting).mockResolvedValue() + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('rate-limit-toggle')).toBeInTheDocument() + }) + + const toggle = screen.getByTestId('rate-limit-toggle') + await userEvent.click(toggle) + + await waitFor(() => { + expect(settingsApi.updateSetting).toHaveBeenCalledWith( + 'security.rate_limit.enabled', + 'true', + 'security', + 'bool' + ) + }) + }) + + it('calls updateSecurityConfig when save button is clicked', async () => { + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled) + vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig) + vi.mocked(securityApi.updateSecurityConfig).mockResolvedValue({}) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('rate-limit-rps')).toBeInTheDocument() + }) + + // Wait for initial values to be set from config + await waitFor(() => { + expect(screen.getByTestId('rate-limit-rps')).toHaveValue(10) + }) + + // Change RPS value using tripleClick to select all then type + const rpsInput = screen.getByTestId('rate-limit-rps') + await userEvent.tripleClick(rpsInput) + await userEvent.keyboard('25') + + // Click save + const saveBtn = screen.getByTestId('save-rate-limit-btn') + await userEvent.click(saveBtn) + + await waitFor(() => { + expect(securityApi.updateSecurityConfig).toHaveBeenCalledWith( + expect.objectContaining({ + rate_limit_requests: 25, + rate_limit_burst: 5, + rate_limit_window_sec: 60, + }) + ) + }) + }) + + it('displays default values from config', async () => { + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled) + vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('rate-limit-rps')).toBeInTheDocument() + }) + + expect(screen.getByTestId('rate-limit-rps')).toHaveValue(10) + expect(screen.getByTestId('rate-limit-burst')).toHaveValue(5) + expect(screen.getByTestId('rate-limit-window')).toHaveValue(60) + }) + + it('hides configuration inputs when disabled', async () => { + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusDisabled) + vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Rate Limiting Configuration')).toBeInTheDocument() + }) + + expect(screen.queryByTestId('rate-limit-rps')).not.toBeInTheDocument() + expect(screen.queryByTestId('rate-limit-burst')).not.toBeInTheDocument() + expect(screen.queryByTestId('rate-limit-window')).not.toBeInTheDocument() + }) + + it('shows info banner about rate limiting', async () => { + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatusEnabled) + vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockSecurityConfig) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText(/Rate limiting helps protect/)).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/pages/__tests__/WafConfig.spec.tsx b/frontend/src/pages/__tests__/WafConfig.spec.tsx index 7283000c..6242952c 100644 --- a/frontend/src/pages/__tests__/WafConfig.spec.tsx +++ b/frontend/src/pages/__tests__/WafConfig.spec.tsx @@ -461,4 +461,81 @@ SecRule ARGS "@contains test3" "id:3,phase:1,deny"`, expect(screen.getByText('3 rule(s)')).toBeInTheDocument() }) + + it('shows preset dropdown when creating new ruleset', async () => { + const response: RuleSetsResponse = { rulesets: [] } + vi.mocked(securityApi.getRuleSets).mockResolvedValue(response) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByTestId('create-ruleset-btn')) + + expect(screen.getByTestId('preset-select')).toBeInTheDocument() + expect(screen.getByText('Choose a preset...')).toBeInTheDocument() + }) + + it('auto-fills form when preset is selected', async () => { + const response: RuleSetsResponse = { rulesets: [] } + vi.mocked(securityApi.getRuleSets).mockResolvedValue(response) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByTestId('create-ruleset-btn')) + + // Select OWASP CRS preset + const presetSelect = screen.getByTestId('preset-select') + await userEvent.selectOptions(presetSelect, 'OWASP Core Rule Set') + + // Verify form is auto-filled + expect(screen.getByTestId('ruleset-name-input')).toHaveValue('OWASP Core Rule Set') + expect(screen.getByTestId('ruleset-url-input')).toHaveValue( + 'https://github.com/coreruleset/coreruleset/archive/refs/tags/v3.3.5.tar.gz' + ) + }) + + it('auto-fills content for inline preset', async () => { + const response: RuleSetsResponse = { rulesets: [] } + vi.mocked(securityApi.getRuleSets).mockResolvedValue(response) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('create-ruleset-btn')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByTestId('create-ruleset-btn')) + + // Select SQL Injection preset (has inline content) + const presetSelect = screen.getByTestId('preset-select') + await userEvent.selectOptions(presetSelect, 'Basic SQL Injection Protection') + + // Verify content is auto-filled + const contentInput = screen.getByTestId('ruleset-content-input') as HTMLTextAreaElement + expect(contentInput.value).toContain('SecRule') + expect(contentInput.value).toContain('SQLi') + }) + + it('does not show preset dropdown when editing', async () => { + const response: RuleSetsResponse = { rulesets: [mockRuleSet] } + vi.mocked(securityApi.getRuleSets).mockResolvedValue(response) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('rulesets-table')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByTestId('edit-ruleset-1')) + + // Preset dropdown should not be visible when editing + expect(screen.queryByTestId('preset-select')).not.toBeInTheDocument() + }) })