fix WAF detection mode and payload logging
- DetectionOnly mode: add SecAction to set anomaly score thresholds to 9999999 so rule 949110/980130 never fires; works around coraza-caddy bug where is_interrupted=true still causes a 403 in detection mode - Switch SecAuditEngine back to On (from RelevantOnly) so DetectionOnly hits are captured, now safe because body parts are excluded - SecAuditLogParts: ABIJDEFHZ → ABFHZ, dropping request body (I), multipart files (J), intermediate response headers (D), and response body (E) — prevents multi-MB payloads being written to audit log - Parser: store both blocked and detected events; filter on rule matched OR is_interrupted instead of is_interrupted only - Add blocked column to waf_events (migration 0014); existing rows default to blocked=true - WAF Events UI: Blocked/Detected chip in table and drawer header - Fix misleading help text that said to use Detection Only to observe traffic before blocking Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -874,8 +874,8 @@ export default function SettingsClient({
|
||||
</Collapse>
|
||||
</Box>
|
||||
<Alert severity="info" sx={{ fontSize: "0.8rem" }}>
|
||||
WAF audit events are stored for 90 days and viewable under <strong>WAF Events</strong> in the sidebar.
|
||||
Set mode to <em>Detection Only</em> first to observe traffic before enabling blocking.
|
||||
WAF events (blocked requests) are stored for 90 days and viewable under <strong>WAF Events</strong> in the sidebar.
|
||||
Events only appear when the engine is set to <em>On (Blocking)</em> — Detection Only mode matches rules without blocking and produces no events here.
|
||||
</Alert>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button type="submit" variant="contained">
|
||||
|
||||
@@ -57,6 +57,12 @@ function SeverityChip({ severity }: { severity: string | null }) {
|
||||
return <Chip label={upper} size="small" color={color} variant="outlined" sx={{ fontWeight: 600, fontSize: "0.7rem" }} />;
|
||||
}
|
||||
|
||||
function BlockedChip({ blocked }: { blocked: boolean }) {
|
||||
return blocked
|
||||
? <Chip label="Blocked" size="small" color="error" sx={{ fontWeight: 600, fontSize: "0.7rem" }} />
|
||||
: <Chip label="Detected" size="small" color="warning" variant="outlined" sx={{ fontWeight: 600, fontSize: "0.7rem" }} />;
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
@@ -120,6 +126,7 @@ function WafEventDrawer({
|
||||
<Stack spacing={2.5} sx={{ height: "100%", overflow: "auto" }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<BlockedChip blocked={event.blocked} />
|
||||
<SeverityChip severity={event.severity} />
|
||||
<Typography variant="h6" fontWeight={600}>WAF Event</Typography>
|
||||
</Stack>
|
||||
@@ -367,6 +374,10 @@ export default function WafEventsClient({ events, pagination, initialSearch, glo
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "blocked", label: "Action", width: 90,
|
||||
render: (r: WafEvent) => <BlockedChip blocked={r.blocked} />,
|
||||
},
|
||||
{
|
||||
id: "severity", label: "Severity", width: 100,
|
||||
render: (r: WafEvent) => <SeverityChip severity={r.severity} />,
|
||||
@@ -458,7 +469,7 @@ export default function WafEventsClient({ events, pagination, initialSearch, glo
|
||||
columns={columns}
|
||||
data={events}
|
||||
keyField="id"
|
||||
emptyMessage="No WAF events found. Enable the WAF in Settings and send some traffic to see events here."
|
||||
emptyMessage="No WAF events found. Enable the WAF in Settings and send some traffic — blocked requests appear when the engine is On, detected-only events appear in Detection Only mode."
|
||||
pagination={pagination}
|
||||
onRowClick={setSelected}
|
||||
/>
|
||||
|
||||
3
drizzle/0014_waf_blocked.sql
Normal file
3
drizzle/0014_waf_blocked.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Add blocked column to waf_events.
|
||||
-- Existing rows are backfilled as blocked=1 (they were all captured via is_interrupted=true).
|
||||
ALTER TABLE `waf_events` ADD COLUMN `blocked` integer NOT NULL DEFAULT 1;
|
||||
@@ -845,10 +845,21 @@ function buildWafHandler(waf: WafSettings): Record<string, unknown> {
|
||||
] : []),
|
||||
...(waf.excluded_rule_ids?.length ? [`SecRuleRemoveById ${waf.excluded_rule_ids.join(' ')}`] : []),
|
||||
`SecRuleEngine ${waf.mode}`,
|
||||
// In DetectionOnly mode, coraza-caddy has a bug where it still blocks requests if the anomaly
|
||||
// score exceeds the threshold (is_interrupted becomes true). Work around this by setting the
|
||||
// inbound/outbound anomaly score thresholds to an unreachable value so rule 949110/980130
|
||||
// never fires and no request is ever interrupted in DetectionOnly mode.
|
||||
...(waf.mode === 'DetectionOnly' ? [
|
||||
'SecAction "id:9998001,phase:1,nolog,pass,t:none,setvar:tx.inbound_anomaly_score_threshold=9999999,setvar:tx.outbound_anomaly_score_threshold=9999999"',
|
||||
] : []),
|
||||
// Log all transactions so DetectionOnly hits are captured and shown in WAF Events.
|
||||
// Body parts are excluded (see SecAuditLogParts below) so large uploads don't bloat the log.
|
||||
'SecAuditEngine On',
|
||||
'SecAuditLog /logs/waf-audit.log',
|
||||
'SecAuditLogFormat JSON',
|
||||
'SecAuditLogParts ABIJDEFHZ',
|
||||
// Omit request/response bodies (parts I, J, E) and intermediate response headers (D)
|
||||
// to prevent logging multi-MB payloads. Headers (B, F) and rule match trailer (H) are kept.
|
||||
'SecAuditLogParts ABFHZ',
|
||||
'SecResponseBodyAccess Off',
|
||||
];
|
||||
|
||||
|
||||
@@ -261,6 +261,7 @@ export const wafEvents = sqliteTable(
|
||||
ruleMessage: text('rule_message'),
|
||||
severity: text('severity'),
|
||||
rawData: text('raw_data'),
|
||||
blocked: integer('blocked', { mode: 'boolean' }).notNull().default(true),
|
||||
},
|
||||
(table) => ({
|
||||
tsIdx: index('idx_waf_events_ts').on(table.ts),
|
||||
|
||||
@@ -14,6 +14,7 @@ export type WafEvent = {
|
||||
ruleMessage: string | null;
|
||||
severity: string | null;
|
||||
rawData: string | null;
|
||||
blocked: boolean;
|
||||
};
|
||||
|
||||
function buildSearch(search?: string) {
|
||||
@@ -135,5 +136,6 @@ export async function listWafEvents(limit = 50, offset = 0, search?: string): Pr
|
||||
ruleMessage: r.ruleMessage ?? null,
|
||||
severity: r.severity ?? null,
|
||||
rawData: r.rawData ?? null,
|
||||
blocked: r.blocked ?? true,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -144,11 +144,6 @@ function parseLine(line: string, ruleMap: Map<string, RuleInfo>): typeof wafEven
|
||||
const clientIp = tx.client_ip ?? '';
|
||||
if (!clientIp) return null;
|
||||
|
||||
// Only store events where the WAF actually interrupted (blocked/detected) the request.
|
||||
// Coraza does not write matched rules to the audit log messages array (known bug),
|
||||
// so we use is_interrupted as the primary filter.
|
||||
if (!tx.is_interrupted) return null;
|
||||
|
||||
const req = tx.request ?? {};
|
||||
|
||||
// unix_timestamp is nanoseconds; fall back to parsing timestamp string
|
||||
@@ -168,6 +163,12 @@ function parseLine(line: string, ruleMap: Map<string, RuleInfo>): typeof wafEven
|
||||
// Look up rule info from the WAF rules log via the transaction unique_id
|
||||
const ruleInfo = tx.id ? ruleMap.get(tx.id) : undefined;
|
||||
|
||||
const blocked = tx.is_interrupted ?? false;
|
||||
|
||||
// Only store events where a specific rule matched or the request was blocked.
|
||||
// Audit log entries without any rule match are clean requests and can be discarded.
|
||||
if (!blocked && !ruleInfo) return null;
|
||||
|
||||
return {
|
||||
ts,
|
||||
host,
|
||||
@@ -179,6 +180,7 @@ function parseLine(line: string, ruleMap: Map<string, RuleInfo>): typeof wafEven
|
||||
ruleMessage: ruleInfo?.ruleMessage ?? null,
|
||||
severity: ruleInfo?.severity ?? null,
|
||||
rawData: line,
|
||||
blocked,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user