diff --git a/app/(dashboard)/analytics/AnalyticsClient.tsx b/app/(dashboard)/analytics/AnalyticsClient.tsx index eb6a9a0f..57611efa 100644 --- a/app/(dashboard)/analytics/AnalyticsClient.tsx +++ b/app/(dashboard)/analytics/AnalyticsClient.tsx @@ -77,6 +77,9 @@ interface BlockedEvent { } interface BlockedPage { events: BlockedEvent[]; total: number; page: number; pages: number; } +interface TopWafRule { ruleId: number; count: number; message: string | null; } +interface WafStats { total: number; topRules: TopWafRule[]; } + // ── Helpers ─────────────────────────────────────────────────────────────────── function countryFlag(code: string): string { @@ -158,6 +161,7 @@ export default function AnalyticsClient() { const [protocols, setProtocols] = useState([]); const [userAgents, setUserAgents] = useState([]); const [blocked, setBlocked] = useState(null); + const [wafStats, setWafStats] = useState(null); const [loading, setLoading] = useState(true); const [selectedCountry, setSelectedCountry] = useState(null); @@ -201,13 +205,15 @@ export default function AnalyticsClient() { fetch(`/api/analytics/protocols${params}`).then(r => r.json()), fetch(`/api/analytics/user-agents${params}`).then(r => r.json()), fetch(`/api/analytics/blocked${params}&page=1`).then(r => r.json()), - ]).then(([s, t, c, p, u, b]) => { + fetch(`/api/analytics/waf-stats${params}`).then(r => r.json()), + ]).then(([s, t, c, p, u, b, w]) => { setSummary(s); setTimeline(t); setCountries(c); setProtocols(p); setUserAgents(u); setBlocked(b); + setWafStats(w); }).catch(() => {}).finally(() => setLoading(false)); }, [buildParams, interval, customFrom, customTo]); @@ -427,20 +433,20 @@ export default function AnalyticsClient() { <> {/* Stats row */} - + - + - + 0 ? '#ef4444' : undefined} /> - + 10 ? '#f59e0b' : undefined} /> + + 0 ? `${wafStats.topRules.length} rules triggered` : 'No WAF events'} + color={(wafStats?.total ?? 0) > 0 ? '#f59e0b' : undefined} + /> + {/* Timeline */} @@ -644,6 +658,40 @@ export default function AnalyticsClient() { )} + {/* WAF Top Rules */} + {wafStats && wafStats.total > 0 && ( + + + + Top WAF Rules Triggered + + + + + {['Rule ID', 'Message', 'Hits'].map(h => ( + {h} + ))} + + + + {wafStats.topRules.map(rule => ( + + + {rule.ruleId} + + + {rule.message ?? '—'} + + + {rule.count.toLocaleString()} + + + ))} + +
+
+
+ )} )} diff --git a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx index f8e92acc..fb58c891 100644 --- a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx +++ b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; -import { IconButton, Stack, Switch, Tooltip, Typography } from "@mui/material"; +import { Chip, IconButton, Stack, Switch, Tooltip, Typography } from "@mui/material"; +import SecurityIcon from "@mui/icons-material/Security"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; @@ -95,6 +96,26 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, aut ) }, + { + id: "waf", + label: "WAF", + render: (host: ProxyHost) => { + if (!host.waf?.enabled) return ; + const excludedCount = host.waf.excluded_rule_ids?.length ?? 0; + return ( + + + {excludedCount > 0 && ( + + )} + + ); + } + }, { id: "status", label: "Status", diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts index 3b7f967e..3f8b05b7 100644 --- a/app/(dashboard)/proxy-hosts/actions.ts +++ b/app/(dashboard)/proxy-hosts/actions.ts @@ -397,6 +397,10 @@ function parseWafConfig(formData: FormData): { waf?: WafHostConfig | null } { const customDirectives = typeof formData.get("waf_custom_directives") === "string" ? (formData.get("waf_custom_directives") as string).trim() : ""; + const rawExcl = formData.get("waf_excluded_rule_ids"); + const excluded_rule_ids: number[] = rawExcl + ? (JSON.parse(rawExcl as string) as unknown[]).filter((x): x is number => Number.isInteger(x) && (x as number) > 0) + : []; if (!enabled) { return { waf: { enabled: false, waf_mode: wafMode } }; @@ -408,6 +412,7 @@ function parseWafConfig(formData: FormData): { waf?: WafHostConfig | null } { mode: engineMode, load_owasp_crs: loadCrs, custom_directives: customDirectives, + excluded_rule_ids, waf_mode: wafMode, } }; diff --git a/app/(dashboard)/settings/SettingsClient.tsx b/app/(dashboard)/settings/SettingsClient.tsx index 90cb6fe5..f6ea59ef 100644 --- a/app/(dashboard)/settings/SettingsClient.tsx +++ b/app/(dashboard)/settings/SettingsClient.tsx @@ -2,7 +2,9 @@ import { useState } from "react"; import { useFormState } from "react-dom"; -import { Alert, Box, Button, Card, CardContent, Checkbox, FormControl, FormControlLabel, FormLabel, MenuItem, Radio, RadioGroup, Stack, Switch, TextField, Typography } from "@mui/material"; +import { Alert, Box, Button, Card, CardContent, Checkbox, Collapse, FormControl, FormControlLabel, FormLabel, MenuItem, Radio, RadioGroup, Stack, Switch, TextField, Typography } from "@mui/material"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import type { GeneralSettings, AuthentikSettings, @@ -14,6 +16,7 @@ import type { WafSettings } from "@/src/lib/settings"; import { GeoBlockFields } from "@/src/components/proxy-hosts/GeoBlockFields"; +import { WafRuleExclusions } from "@/src/components/proxy-hosts/WafRuleExclusions"; import { updateCloudflareSettingsAction, updateGeneralSettingsAction, @@ -109,6 +112,8 @@ export default function SettingsClient({ const [syncState, syncFormAction] = useFormState(syncSlaveInstancesAction, null); const [geoBlockState, geoBlockFormAction] = useFormState(updateGeoBlockSettingsAction, null); const [wafState, wafFormAction] = useFormState(updateWafSettingsAction, null); + const [wafCustomDirectives, setWafCustomDirectives] = useState(globalWaf?.custom_directives ?? ""); + const [wafShowTemplates, setWafShowTemplates] = useState(false); const isSlave = instanceSync.mode === "slave"; const isMaster = instanceSync.mode === "master"; @@ -823,18 +828,51 @@ export default function SettingsClient({ } /> + setWafCustomDirectives(e.target.value)} placeholder={`SecRule REQUEST_URI "@contains /secret" "id:9001,deny,status:403,log,msg:'Blocked path'"`} inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }} helperText="ModSecurity SecLang syntax. Applied after OWASP CRS if enabled." fullWidth /> + + + + + {[ + { label: "Allow IP", snippet: `SecRule REMOTE_ADDR "@ipMatch 1.2.3.4" "id:9000,phase:1,allow,nolog,msg:'Allow IP'"` }, + { label: "Disable WAF for path", snippet: `SecRule REQUEST_URI "@beginsWith /api/" "id:9001,phase:1,ctl:ruleEngine=Off,nolog"` }, + { label: "Remove XSS rules", snippet: `SecRuleRemoveByTag "attack-xss"` }, + { label: "Block User-Agent", snippet: `SecRule REQUEST_HEADERS:User-Agent "@contains badbot" "id:9002,phase:1,deny,status:403,log"` }, + ].map((t) => ( + + ))} + + + WAF audit events are stored for 90 days and viewable under WAF Events in the sidebar. Set mode to Detection Only first to observe traffic before enabling blocking. diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index d9eea2e1..12314283 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -5,7 +5,8 @@ import { requireAdmin } from "@/src/lib/auth"; import { applyCaddyConfig } from "@/src/lib/caddy"; import { getInstanceMode, getSlaveMasterToken, setInstanceMode, setSlaveMasterToken, syncInstances } from "@/src/lib/instance-sync"; import { createInstance, deleteInstance, updateInstance } from "@/src/lib/models/instances"; -import { clearSetting, getSetting, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings, saveUpstreamDnsResolutionSettings, saveGeoBlockSettings, saveWafSettings } from "@/src/lib/settings"; +import { clearSetting, getSetting, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings, saveUpstreamDnsResolutionSettings, saveGeoBlockSettings, saveWafSettings, getWafSettings } from "@/src/lib/settings"; +import { listProxyHosts, updateProxyHost } from "@/src/lib/models/proxy-hosts"; import type { CloudflareSettings, GeoBlockSettings, WafSettings } from "@/src/lib/settings"; type ActionResult = { @@ -628,6 +629,67 @@ export async function syncSlaveInstancesAction(_prevState: ActionResult | null, } } +export async function removeWafRuleGloballyAction(ruleId: number): Promise { + try { + await requireAdmin(); + const current = await getWafSettings(); + if (!current) return { success: false, message: "WAF settings not found." }; + const ids = (current.excluded_rule_ids ?? []).filter((id) => id !== ruleId); + await saveWafSettings({ ...current, excluded_rule_ids: ids }); + try { await applyCaddyConfig(); } catch { /* non-fatal */ } + revalidatePath("/settings"); + revalidatePath("/waf-events"); + return { success: true, message: `Rule ${ruleId} removed from exclusions.` }; + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to remove WAF rule" }; + } +} + +export async function suppressWafRuleGloballyAction(ruleId: number): Promise { + try { + await requireAdmin(); + const current = await getWafSettings(); + if (!current?.enabled) { + return { success: false, message: "Global WAF is not enabled. Enable it in Settings first." }; + } + const ids = [...new Set([...(current.excluded_rule_ids ?? []), ruleId])]; + await saveWafSettings({ ...current, excluded_rule_ids: ids }); + try { + await applyCaddyConfig(); + } catch (err) { + revalidatePath("/settings"); + return { success: true, message: `Rule ${ruleId} added to exclusions. Warning: could not reload Caddy.` }; + } + revalidatePath("/settings"); + revalidatePath("/waf-events"); + return { success: true, message: `Rule ${ruleId} suppressed globally.` }; + } catch (error) { + console.error("Failed to suppress WAF rule:", error); + return { success: false, message: error instanceof Error ? error.message : "Failed to suppress WAF rule" }; + } +} + +export async function suppressWafRuleForHostAction(ruleId: number, hostname: string): Promise { + try { + const session = await requireAdmin(); + const userId = Number(session.user.id); + const hosts = await listProxyHosts(); + const host = hosts.find((h) => h.domains.includes(hostname)); + if (!host) { + return { success: false, message: `No proxy host found for ${hostname}.` }; + } + const existingWaf = host.waf ?? {}; + const ids = [...new Set([...(existingWaf.excluded_rule_ids ?? []), ruleId])]; + await updateProxyHost(host.id, { waf: { ...existingWaf, enabled: existingWaf.enabled ?? false, excluded_rule_ids: ids } }, userId); + revalidatePath("/proxy-hosts"); + revalidatePath("/waf-events"); + return { success: true, message: `Rule ${ruleId} suppressed for ${hostname}.` }; + } catch (error) { + console.error("Failed to suppress WAF rule for host:", error); + return { success: false, message: error instanceof Error ? error.message : "Failed to suppress WAF rule" }; + } +} + export async function updateWafSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise { try { await requireAdmin(); @@ -640,8 +702,12 @@ export async function updateWafSettingsAction(_prevState: ActionResult | null, f const customDirectives = typeof formData.get("waf_custom_directives") === "string" ? (formData.get("waf_custom_directives") as string).trim() : ""; + const rawExcl = formData.get("waf_excluded_rule_ids"); + const excluded_rule_ids: number[] = rawExcl + ? (JSON.parse(rawExcl as string) as unknown[]).filter((x): x is number => Number.isInteger(x) && (x as number) > 0) + : []; - const config: WafSettings = { enabled, mode, load_owasp_crs: loadOwasp, custom_directives: customDirectives }; + const config: WafSettings = { enabled, mode, load_owasp_crs: loadOwasp, custom_directives: customDirectives, excluded_rule_ids }; await saveWafSettings(config); try { diff --git a/app/(dashboard)/waf-events/WafEventsClient.tsx b/app/(dashboard)/waf-events/WafEventsClient.tsx index 93e04086..efcb5392 100644 --- a/app/(dashboard)/waf-events/WafEventsClient.tsx +++ b/app/(dashboard)/waf-events/WafEventsClient.tsx @@ -1,27 +1,42 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, useTransition } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { + Alert, Box, + Button, Chip, Divider, Drawer, IconButton, + Snackbar, Stack, + Tab, + Tabs, TextField, Tooltip, Typography, } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import CloseIcon from "@mui/icons-material/Close"; +import BlockIcon from "@mui/icons-material/Block"; +import DeleteIcon from "@mui/icons-material/Delete"; import { DataTable } from "@/src/components/ui/DataTable"; import type { WafEvent } from "@/src/lib/models/waf-events"; +import { + suppressWafRuleGloballyAction, + suppressWafRuleForHostAction, + removeWafRuleGloballyAction, +} from "../settings/actions"; type Props = { events: WafEvent[]; pagination: { total: number; page: number; perPage: number }; initialSearch: string; + globalExcluded: number[]; + globalWafEnabled: boolean; + hostWafMap: Record; }; const SEVERITY_COLOR: Record = { @@ -51,96 +66,244 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo ); } -function WafEventDrawer({ event, onClose }: { event: WafEvent | null; onClose: () => void }) { - // Parse rawData safely — render as text only, never as HTML +function WafEventDrawer({ + event, + onClose, + globalExcluded, + hostWafMap, + onSuppressGlobal, + onSuppressHost, +}: { + event: WafEvent | null; + onClose: () => void; + globalExcluded: number[]; + hostWafMap: Record; + onSuppressGlobal: (ruleId: number) => void; + onSuppressHost: (ruleId: number, host: string) => void; +}) { + const [pending, startTransition] = useTransition(); + const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; success: boolean }>({ open: false, message: "", success: true }); + let parsedRaw: unknown = null; if (event?.rawData) { try { parsedRaw = JSON.parse(event.rawData); } catch { parsedRaw = event.rawData; } } + const isGloballySuppressed = event?.ruleId != null && globalExcluded.includes(event.ruleId); + const isHostOnlySuppressed = event?.ruleId != null && !!event.host && (hostWafMap[event.host] ?? []).includes(event.ruleId); + const isHostSuppressed = isGloballySuppressed || isHostOnlySuppressed; + + function handleSuppressGlobally() { + if (!event?.ruleId) return; + startTransition(async () => { + const result = await suppressWafRuleGloballyAction(event.ruleId!); + setSnackbar({ open: true, message: result.message ?? (result.success ? "Done" : "Failed"), success: result.success }); + if (result.success) onSuppressGlobal(event.ruleId!); + }); + } + + function handleSuppressForHost() { + if (!event?.ruleId || !event?.host) return; + startTransition(async () => { + const result = await suppressWafRuleForHostAction(event.ruleId!, event.host!); + setSnackbar({ open: true, message: result.message ?? (result.success ? "Done" : "Failed"), success: result.success }); + if (result.success) onSuppressHost(event.ruleId!, event.host!); + }); + } + return ( - - {event && ( - - {/* Header */} - - - - WAF Event + <> + + {event && ( + + + + + WAF Event + + - + + + + + {new Date(event.ts * 1000).toLocaleString()} + + + + {event.host || "—"} + + + + + {event.clientIp} + {event.countryCode && } + + + + + + {event.method} {event.uri} + + + + + + {event.ruleId ?? "—"} + {event.ruleId != null && ( + <> + + {event.host && ( + + )} + + )} + + + + + {event.ruleMessage ?? "—"} + + + + + + {parsedRaw !== null ? ( + + {JSON.stringify(parsedRaw, null, 2)} + + ) : ( + + )} + - - - - {/* Fields */} - - {new Date(event.ts * 1000).toLocaleString()} - - - - {event.host || "—"} - - - - - {event.clientIp} - {event.countryCode && } - - - - - - {event.method} {event.uri} - - - - - {event.ruleId ?? "—"} - - - - {event.ruleMessage ?? "—"} - - - - - {/* Raw audit log — rendered as plain text, never as HTML */} - - {parsedRaw !== null ? ( - - {JSON.stringify(parsedRaw, null, 2)} - - ) : ( - - )} - - - )} - + )} + + setSnackbar((s) => ({ ...s, open: false }))} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setSnackbar((s) => ({ ...s, open: false }))}> + {snackbar.message} + + + ); } -export default function WafEventsClient({ events, pagination, initialSearch }: Props) { +function GlobalSuppressedRules({ + excluded, + wafEnabled, + onRemove, +}: { + excluded: number[]; + wafEnabled: boolean; + onRemove: (ruleId: number) => void; +}) { + const [pending, startTransition] = useTransition(); + const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; success: boolean }>({ open: false, message: "", success: true }); + + function handleRemove(ruleId: number) { + startTransition(async () => { + const result = await removeWafRuleGloballyAction(ruleId); + setSnackbar({ open: true, message: result.message ?? (result.success ? "Done" : "Failed"), success: result.success }); + if (result.success) onRemove(ruleId); + }); + } + + return ( + <> + + + Global WAF Rule Exclusions + + Rules listed here are suppressed globally via SecRuleRemoveById for all proxy hosts using global WAF settings. + + {!wafEnabled && ( + Global WAF is currently disabled. Exclusions are saved but have no effect until WAF is enabled. + )} + + + {excluded.length === 0 ? ( + + + No globally suppressed rules. + Open a WAF event and click "Suppress Globally" to add one. + + ) : ( + + {excluded.map((id) => ( + handleRemove(id)} + deleteIcon={} + disabled={pending} + sx={{ fontFamily: "monospace", fontWeight: 600 }} + color="error" + variant="outlined" + /> + ))} + + )} + + setSnackbar((s) => ({ ...s, open: false }))} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setSnackbar((s) => ({ ...s, open: false }))}> + {snackbar.message} + + + + ); +} + +export default function WafEventsClient({ events, pagination, initialSearch, globalExcluded, globalWafEnabled, hostWafMap }: Props) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const [tab, setTab] = useState(0); const [searchTerm, setSearchTerm] = useState(initialSearch); const [selected, setSelected] = useState(null); + const [localGlobalExcluded, setLocalGlobalExcluded] = useState(globalExcluded); + const [localHostWafMap, setLocalHostWafMap] = useState(hostWafMap); useEffect(() => { setSearchTerm(initialSearch); }, [initialSearch]); const debounceRef = useRef | null>(null); @@ -165,9 +328,7 @@ export default function WafEventsClient({ events, pagination, initialSearch }: P const columns = [ { - id: "ts", - label: "Time", - width: 150, + id: "ts", label: "Time", width: 150, render: (r: WafEvent) => ( {new Date(r.ts * 1000).toLocaleString()} @@ -175,15 +336,11 @@ export default function WafEventsClient({ events, pagination, initialSearch }: P ), }, { - id: "severity", - label: "Severity", - width: 100, + id: "severity", label: "Severity", width: 100, render: (r: WafEvent) => , }, { - id: "host", - label: "Host", - width: 150, + id: "host", label: "Host", width: 150, render: (r: WafEvent) => ( @@ -193,9 +350,7 @@ export default function WafEventsClient({ events, pagination, initialSearch }: P ), }, { - id: "clientIp", - label: "Client IP", - width: 140, + id: "clientIp", label: "Client IP", width: 140, render: (r: WafEvent) => ( @@ -208,32 +363,23 @@ export default function WafEventsClient({ events, pagination, initialSearch }: P ), }, { - id: "method", - label: "M", - width: 60, + id: "method", label: "M", width: 60, render: (r: WafEvent) => ( ), }, { - id: "uri", - label: "URI", - width: 200, + id: "uri", label: "URI", width: 200, render: (r: WafEvent) => ( - + {r.uri || } ), }, { - id: "ruleId", - label: "Rule ID", - width: 80, + id: "ruleId", label: "Rule ID", width: 80, render: (r: WafEvent) => ( {r.ruleId ?? "—"} @@ -241,14 +387,10 @@ export default function WafEventsClient({ events, pagination, initialSearch }: P ), }, { - id: "ruleMessage", - label: "Rule Message", + id: "ruleMessage", label: "Rule Message", render: (r: WafEvent) => ( - + {r.ruleMessage ?? } @@ -258,34 +400,63 @@ export default function WafEventsClient({ events, pagination, initialSearch }: P return ( - - WAF Events - + WAF - Web Application Firewall detections and blocks. Events are retained for 90 days. + Web Application Firewall events and rule management. - { setSearchTerm(e.target.value); updateSearch(e.target.value); }} - slotProps={{ - input: { startAdornment: }, - }} - size="small" - sx={{ maxWidth: 480 }} - /> + setTab(v)} sx={{ borderBottom: 1, borderColor: "divider" }}> + + + Suppressed Rules + {localGlobalExcluded.length > 0 && ( + + )} + + } + /> + - + {tab === 0 && ( + <> + { setSearchTerm(e.target.value); updateSearch(e.target.value); }} + slotProps={{ + input: { startAdornment: }, + }} + size="small" + sx={{ maxWidth: 480 }} + /> + + setSelected(null)} + globalExcluded={localGlobalExcluded} + hostWafMap={localHostWafMap} + onSuppressGlobal={(ruleId) => setLocalGlobalExcluded((prev) => [...new Set([...prev, ruleId])])} + onSuppressHost={(ruleId, host) => setLocalHostWafMap((prev) => ({ ...prev, [host]: [...new Set([...(prev[host] ?? []), ruleId])] }))} + /> + + )} - setSelected(null)} /> + {tab === 1 && ( + setLocalGlobalExcluded((prev) => prev.filter((id) => id !== ruleId))} + /> + )} ); } diff --git a/app/(dashboard)/waf-events/page.tsx b/app/(dashboard)/waf-events/page.tsx index 9462d8a9..a7220bcb 100644 --- a/app/(dashboard)/waf-events/page.tsx +++ b/app/(dashboard)/waf-events/page.tsx @@ -1,5 +1,9 @@ +export const dynamic = 'force-dynamic'; + import WafEventsClient from "./WafEventsClient"; import { listWafEvents, countWafEvents } from "@/src/lib/models/waf-events"; +import { getWafSettings } from "@/src/lib/settings"; +import { listProxyHosts } from "@/src/lib/models/proxy-hosts"; import { requireAdmin } from "@/src/lib/auth"; const PER_PAGE = 50; @@ -15,16 +19,29 @@ export default async function WafEventsPage({ searchParams }: PageProps) { const search = searchParam?.trim() || undefined; const offset = (page - 1) * PER_PAGE; - const [events, total] = await Promise.all([ + const [events, total, globalWaf, hosts] = await Promise.all([ listWafEvents(PER_PAGE, offset, search), countWafEvents(search), + getWafSettings(), + listProxyHosts(), ]); + const hostWafMap: Record = {}; + for (const host of hosts) { + const ids = host.waf?.excluded_rule_ids ?? []; + for (const domain of host.domains) { + hostWafMap[domain] = ids; + } + } + return ( ); } diff --git a/app/api/analytics/waf-stats/route.ts b/app/api/analytics/waf-stats/route.ts new file mode 100644 index 00000000..8669a744 --- /dev/null +++ b/app/api/analytics/waf-stats/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireUser } from '@/src/lib/auth'; +import { INTERVAL_SECONDS } from '@/src/lib/analytics-db'; +import { countWafEventsInRange, getTopWafRules } from '@/src/lib/models/waf-events'; + +function resolveRange(params: URLSearchParams): { from: number; to: number } { + const fromParam = params.get('from'); + const toParam = params.get('to'); + if (fromParam && toParam) { + return { from: parseInt(fromParam, 10), to: parseInt(toParam, 10) }; + } + const interval = params.get('interval') ?? '1h'; + const to = Math.floor(Date.now() / 1000); + const from = to - (INTERVAL_SECONDS[interval as keyof typeof INTERVAL_SECONDS] ?? INTERVAL_SECONDS['1h']); + return { from, to }; +} + +export async function GET(req: NextRequest) { + await requireUser(); + const { from, to } = resolveRange(req.nextUrl.searchParams); + const [total, topRules] = await Promise.all([ + countWafEventsInRange(from, to), + getTopWafRules(from, to, 10), + ]); + return NextResponse.json({ total, topRules }); +} diff --git a/src/components/proxy-hosts/WafFields.tsx b/src/components/proxy-hosts/WafFields.tsx index 31c0a443..bc4b9d7a 100644 --- a/src/components/proxy-hosts/WafFields.tsx +++ b/src/components/proxy-hosts/WafFields.tsx @@ -2,6 +2,7 @@ import { Box, + Button, Checkbox, Collapse, Divider, @@ -12,12 +13,22 @@ import { Typography, } from "@mui/material"; import GppBadIcon from "@mui/icons-material/GppBad"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { useState } from "react"; import { type WafHostConfig } from "@/src/lib/models/proxy-hosts"; +import { WafRuleExclusions } from "./WafRuleExclusions"; type WafMode = "merge" | "override"; type EngineMode = "Off" | "DetectionOnly" | "On"; +const QUICK_TEMPLATES = [ + { label: "Allow IP", snippet: `SecRule REMOTE_ADDR "@ipMatch 1.2.3.4" "id:9000,phase:1,allow,nolog,msg:'Allow IP'"` }, + { label: "Disable WAF for path", snippet: `SecRule REQUEST_URI "@beginsWith /api/" "id:9001,phase:1,ctl:ruleEngine=Off,nolog"` }, + { label: "Remove XSS rules", snippet: `SecRuleRemoveByTag "attack-xss"` }, + { label: "Block User-Agent", snippet: `SecRule REQUEST_HEADERS:User-Agent "@contains badbot" "id:9002,phase:1,deny,status:403,log"` }, +]; + type Props = { value?: WafHostConfig | null; showModeSelector?: boolean; @@ -29,6 +40,7 @@ export function WafFields({ value, showModeSelector = true }: Props) { const [engineMode, setEngineMode] = useState(value?.mode ?? "DetectionOnly"); const [loadCrs, setLoadCrs] = useState(value?.load_owasp_crs ?? true); const [customDirectives, setCustomDirectives] = useState(value?.custom_directives ?? ""); + const [showTemplates, setShowTemplates] = useState(false); return ( + {/* Excluded rule IDs */} + + + + {/* Custom directives */} + + {/* Quick Templates */} + + + + + {QUICK_TEMPLATES.map((t) => ( + + ))} + + + diff --git a/src/components/proxy-hosts/WafRuleExclusions.tsx b/src/components/proxy-hosts/WafRuleExclusions.tsx new file mode 100644 index 00000000..7716814f --- /dev/null +++ b/src/components/proxy-hosts/WafRuleExclusions.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Box, Chip, IconButton, Stack, TextField, Typography } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import { useState } from "react"; + +type Props = { + value?: number[]; +}; + +export function WafRuleExclusions({ value }: Props) { + const [ids, setIds] = useState(value ?? []); + const [inputVal, setInputVal] = useState(""); + + function addId() { + const n = parseInt(inputVal.trim(), 10); + if (!Number.isInteger(n) || n <= 0) return; + if (ids.includes(n)) { setInputVal(""); return; } + setIds((prev) => [...prev, n]); + setInputVal(""); + } + + function removeId(id: number) { + setIds((prev) => prev.filter((x) => x !== id)); + } + + return ( + + + + Excluded Rule IDs + + + Rules listed here are disabled via SecRuleRemoveById + + + {ids.map((id) => ( + removeId(id)} + sx={{ fontFamily: "monospace", fontSize: "0.75rem" }} + /> + ))} + + + setInputVal(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addId(); } }} + inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} + sx={{ flex: 1 }} + /> + + + + + + ); +} diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index 77a54db1..fe59e059 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -801,6 +801,7 @@ function resolveEffectiveWaf( mode: host.mode ?? 'DetectionOnly', load_owasp_crs: host.load_owasp_crs ?? false, custom_directives: host.custom_directives ?? '', + excluded_rule_ids: host.excluded_rule_ids, }; } @@ -811,6 +812,10 @@ function resolveEffectiveWaf( mode: host.mode ?? global.mode, load_owasp_crs: host.load_owasp_crs ?? global.load_owasp_crs, custom_directives: [global.custom_directives, host.custom_directives].filter(Boolean).join('\n'), + excluded_rule_ids: [ + ...(global.excluded_rule_ids ?? []), + ...(host.excluded_rule_ids ?? []), + ], }; } @@ -820,6 +825,7 @@ function resolveEffectiveWaf( mode: host.mode ?? 'DetectionOnly', load_owasp_crs: host.load_owasp_crs ?? false, custom_directives: host.custom_directives ?? '', + excluded_rule_ids: host.excluded_rule_ids, }; } if (global?.enabled) return global; @@ -837,6 +843,7 @@ function buildWafHandler(waf: WafSettings): Record { 'Include @crs-setup.conf.example', 'Include @owasp_crs/*.conf', ] : []), + ...(waf.excluded_rule_ids?.length ? [`SecRuleRemoveById ${waf.excluded_rule_ids.join(' ')}`] : []), `SecRuleEngine ${waf.mode}`, 'SecAuditEngine On', 'SecAuditLog /logs/waf-audit.log', diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts index 2b8c2617..d2aa0a47 100644 --- a/src/lib/models/proxy-hosts.ts +++ b/src/lib/models/proxy-hosts.ts @@ -32,6 +32,7 @@ export type WafHostConfig = { mode?: 'Off' | 'DetectionOnly' | 'On'; load_owasp_crs?: boolean; custom_directives?: string; + excluded_rule_ids?: number[]; waf_mode?: WafMode; }; @@ -533,6 +534,10 @@ function serializeMeta(meta: ProxyHostMeta | null | undefined) { normalized.geoblock_mode = meta.geoblock_mode; } + if (meta.waf) { + normalized.waf = meta.waf; + } + return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : null; } @@ -550,7 +555,8 @@ function parseMeta(value: string | null): ProxyHostMeta { dns_resolver: sanitizeDnsResolverMeta(parsed.dns_resolver), upstream_dns_resolution: sanitizeUpstreamDnsResolutionMeta(parsed.upstream_dns_resolution), geoblock: parsed.geoblock, - geoblock_mode: parsed.geoblock_mode + geoblock_mode: parsed.geoblock_mode, + waf: parsed.waf, }; } catch (error) { console.warn("Failed to parse proxy host meta", error); @@ -1454,7 +1460,8 @@ export async function updateProxyHost(id: number, input: Partial dns_resolver: dehydrateDnsResolver(existing.dns_resolver), upstream_dns_resolution: dehydrateUpstreamDnsResolution(existing.upstream_dns_resolution), geoblock: dehydrateGeoBlock(existing.geoblock), - ...(existing.geoblock_mode !== "merge" ? { geoblock_mode: existing.geoblock_mode } : {}) + ...(existing.geoblock_mode !== "merge" ? { geoblock_mode: existing.geoblock_mode } : {}), + ...(existing.waf ? { waf: existing.waf } : {}), }; const meta = buildMeta(existingMeta, input); diff --git a/src/lib/models/waf-events.ts b/src/lib/models/waf-events.ts index fd34793f..7a118ed3 100644 --- a/src/lib/models/waf-events.ts +++ b/src/lib/models/waf-events.ts @@ -1,6 +1,6 @@ import db from "../db"; import { wafEvents } from "../db/schema"; -import { desc, like, or, count, and } from "drizzle-orm"; +import { desc, like, or, count, and, gte, lte, sql } from "drizzle-orm"; export type WafEvent = { id: number; @@ -34,6 +34,33 @@ export async function countWafEvents(search?: string): Promise { return row?.value ?? 0; } +export async function countWafEventsInRange(from: number, to: number): Promise { + const [row] = await db + .select({ value: count() }) + .from(wafEvents) + .where(and(gte(wafEvents.ts, from), lte(wafEvents.ts, to))); + return row?.value ?? 0; +} + +export type TopWafRule = { ruleId: number; count: number; message: string | null }; + +export async function getTopWafRules(from: number, to: number, limit = 10): Promise { + const rows = await db + .select({ + ruleId: wafEvents.ruleId, + count: count(), + message: sql`MAX(${wafEvents.ruleMessage})`, + }) + .from(wafEvents) + .where(and(gte(wafEvents.ts, from), lte(wafEvents.ts, to), sql`${wafEvents.ruleId} IS NOT NULL`)) + .groupBy(wafEvents.ruleId) + .orderBy(desc(count())) + .limit(limit); + return rows + .filter((r): r is typeof r & { ruleId: number } => r.ruleId != null) + .map((r) => ({ ruleId: r.ruleId, count: r.count, message: r.message ?? null })); +} + export async function listWafEvents(limit = 50, offset = 0, search?: string): Promise { const rows = await db .select() diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 3499ca41..75bd1c77 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -216,6 +216,7 @@ export type WafSettings = { mode: 'Off' | 'DetectionOnly' | 'On'; load_owasp_crs: boolean; custom_directives: string; + excluded_rule_ids?: number[]; }; export async function getWafSettings(): Promise {