diff --git a/app/(dashboard)/settings/SettingsClient.tsx b/app/(dashboard)/settings/SettingsClient.tsx
index f6ea59ef..dc22a172 100644
--- a/app/(dashboard)/settings/SettingsClient.tsx
+++ b/app/(dashboard)/settings/SettingsClient.tsx
@@ -874,8 +874,8 @@ export default function SettingsClient({
- 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.
+ WAF events (blocked requests) are stored for 90 days and viewable under WAF Events in the sidebar.
+ Events only appear when the engine is set to On (Blocking) — Detection Only mode matches rules without blocking and produces no events here.
diff --git a/app/(dashboard)/waf-events/WafEventsClient.tsx b/app/(dashboard)/waf-events/WafEventsClient.tsx
index f3b81a96..cb9ac8e1 100644
--- a/app/(dashboard)/waf-events/WafEventsClient.tsx
+++ b/app/(dashboard)/waf-events/WafEventsClient.tsx
@@ -57,6 +57,12 @@ function SeverityChip({ severity }: { severity: string | null }) {
return ;
}
+function BlockedChip({ blocked }: { blocked: boolean }) {
+ return blocked
+ ?
+ : ;
+}
+
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
@@ -120,6 +126,7 @@ function WafEventDrawer({
+
WAF Event
@@ -367,6 +374,10 @@ export default function WafEventsClient({ events, pagination, initialSearch, glo
),
},
+ {
+ id: "blocked", label: "Action", width: 90,
+ render: (r: WafEvent) => ,
+ },
{
id: "severity", label: "Severity", width: 100,
render: (r: WafEvent) => ,
@@ -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}
/>
diff --git a/drizzle/0014_waf_blocked.sql b/drizzle/0014_waf_blocked.sql
new file mode 100644
index 00000000..0b4afbc9
--- /dev/null
+++ b/drizzle/0014_waf_blocked.sql
@@ -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;
diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts
index 27fbfb3d..cb5667aa 100644
--- a/src/lib/caddy.ts
+++ b/src/lib/caddy.ts
@@ -845,10 +845,21 @@ function buildWafHandler(waf: WafSettings): Record {
] : []),
...(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',
];
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 1a381980..c39ee7f5 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -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),
diff --git a/src/lib/models/waf-events.ts b/src/lib/models/waf-events.ts
index 9b8a4664..3f88617d 100644
--- a/src/lib/models/waf-events.ts
+++ b/src/lib/models/waf-events.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,
}));
}
diff --git a/src/lib/waf-log-parser.ts b/src/lib/waf-log-parser.ts
index 38ea333b..65cd1c7a 100644
--- a/src/lib/waf-log-parser.ts
+++ b/src/lib/waf-log-parser.ts
@@ -144,11 +144,6 @@ function parseLine(line: string, ruleMap: Map): 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): 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): typeof wafEven
ruleMessage: ruleInfo?.ruleMessage ?? null,
severity: ruleInfo?.severity ?? null,
rawData: line,
+ blocked,
};
}