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:
fuomag9
2026-03-06 15:32:56 +01:00
parent 044f012dd0
commit e06b41b604
7 changed files with 39 additions and 9 deletions

View File

@@ -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',
];

View File

@@ -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),

View File

@@ -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,
}));
}

View File

@@ -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,
};
}