When allowWebsocket=true and WAF is enabled, the WAF handler sits first
in the handler chain and processes the initial HTTP upgrade request
(GET + Upgrade: websocket). If any rule matches, Coraza can block the
handshake before SecAuditEngine captures it — producing no log entry
and an unexplained connection failure from the client's perspective.
Fix: when allowWebsocket=true, prepend a phase:1 SecLang rule that
matches Upgrade: websocket (case-insensitive) and turns the rule engine
off for that transaction via ctl:ruleEngine=off. After the 101
Switching Protocols response the connection becomes a raw WebSocket
tunnel that the WAF cannot inspect anyway, so this bypass has no impact
on normal HTTP traffic through the same host.
The rule is inserted before OWASP CRS includes so it always fires first
regardless of which ruleset is loaded.
Add 9 unit tests in caddy-waf.test.ts covering: bypass present/absent,
phase:1 placement, case-insensitive regex, nolog/noauditlog flags,
ordering before CRS, and compatibility with custom directives.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug: when a proxy host had per-host WAF explicitly disabled (enabled:false)
with waf_mode:"merge" (or no waf_mode set), resolveEffectiveWaf entered the
merge branch and returned enabled:true unconditionally, applying the global
WAF to a host the user had opted out of.
Fix: add `if (host.enabled === false) return null` at the top of the merge
branch. Explicit opt-out now takes precedence over the global setting
regardless of mode. The override mode already handled this correctly.
Also extract resolveEffectiveWaf from caddy.ts into caddy-waf.ts so it
can be unit tested. Add 12 new tests covering no-config fallback,
merge opt-out regression, merge settings combination, and override mode.
What runs without OWASP CRS: only SecRuleEngine + audit directives +
any custom_directives. The @coraza.conf-recommended and CRS includes
are gated behind load_owasp_crs (fixed in previous commit).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add onConsoleLog: () => false to vitest config to silence expected
warn/error calls from production code when tests deliberately feed bad
input (e.g. parseJson with malformed JSON). Tests can still use
vi.spyOn(console, ...) to assert on console calls explicitly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- test.yml: add pull_request trigger (safe — permissions: {}, no secrets
referenced; pull_request event from forks gets no secrets and read-only token)
- stale.yml: add explicit permissions (issues: write, pull-requests: write)
instead of relying on potentially over-broad repo defaults
- docker-build-pr.yml: add actions: write permission required for GHA
cache writes (cache-to: type=gha)
Remaining known limitation: actions are pinned to tags (@v3/@v6) rather
than SHAs — moving a tag upstream would run arbitrary code. Low risk for
official Docker/GitHub actions but worth noting.
ci: remove cache-to from PR build, drop actions: write permission
PR builds read from the GHA cache (warmed by main branch pushes) but
don't write back. This avoids needing actions: write on a job that runs
untrusted PR code.
ci: restrict test job permissions to zero
Zero out GITHUB_TOKEN permissions and remove the non-existent
ACTIONS_RUNNER_NO_SECRETS variable. No repo secrets are referenced
in this workflow, so the test runner has no credentials available.
The workflow only triggers on push to protected branches (not PRs),
so code is reviewed before it runs.
ci: add test workflow to run unit/integration tests on push
Runs `npm test` (Vitest unit + integration tests) on every push to
main and develop. E2E tests are excluded as they require a full
Docker Compose stack.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The WAF handler always prepended 'Include @coraza.conf-recommended' to the
SecLang directives regardless of load_owasp_crs. The @-prefixed paths only
resolve from the embedded coraza-coreruleset filesystem, which the Caddy
WAF plugin mounts only when load_owasp_crs=true. Without it Caddy fails:
"failed to readfile: open @coraza.conf-recommended: no such file or directory"
Fix: gate all @-prefixed Include directives behind load_owasp_crs.
Also extract buildWafHandler from caddy.ts into caddy-waf.ts so it can be
unit tested in isolation, and add tests/unit/caddy-waf.test.ts (19 tests)
covering the regression, CRS include ordering, excluded rule IDs, and
handler structure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract pemToBase64Der and buildClientAuthentication from caddy.ts into
a new caddy-mtls.ts module, adding groupMtlsDomainsByCaSet to group mTLS
domains by their CA fingerprint before building TLS connection policies.
Previously all mTLS domains sharing a cert type (auto-managed, imported,
or managed) were grouped into a single policy, causing CA union: a client
cert from CA_B could authenticate against a host that only trusted CA_A.
The fix creates one policy per unique CA set, ensuring strict per-host
CA isolation across all three TLS policy code paths.
Also adds:
- tests/unit/caddy-mtls.test.ts (26 tests) covering pemToBase64Der,
buildClientAuthentication, groupMtlsDomainsByCaSet, and cross-CA
isolation regression tests
- tests/unit/instance-sync-env.test.ts (33 tests) for the five pure
env-reading functions in instance-sync.ts
- tests/integration/instance-sync.test.ts (16 tests) for
buildSyncPayload and applySyncPayload using an in-memory SQLite db
- Fix tests/helpers/db.ts to use a relative import for db/schema so it
works inside vi.mock factory dynamic imports
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move WAF config (enable, CRS, custom directives, templates) from
Settings page into a new Settings tab on the WAF page
- WAF page now has three tabs: Events | Suppressed Rules | Settings
- Rename nav item from "WAF Events" to "WAF", route /waf-events → /waf
- Fix excluded_rule_ids preservation: no longer wiped when form field
is absent (Settings tab omits the hidden field intentionally)
- Allow pre-adding suppressed rules even when WAF is disabled
- Reorder sidebar: Overview, Proxy Hosts, Access Lists, Certificates,
WAF, Analytics, Audit Log, Settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add lookupWafRuleMessageAction server action — queries WAF event
history for a known message for any rule ID
- Suppressed Rules tab: type a rule ID, look it up to see its
description (or a "not triggered yet" note), confirm to suppress
- Duplicate-guard: looking up an already-suppressed rule shows an error
- Search field filters the suppressed list by rule ID or message text
- Newly added rules show their message immediately without page reload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- IssuedCertsPanel preview: only show active (non-revoked) certs
- ManageIssuedClientCertsDialog: filter out revoked by default; show
"Show revoked (N)" toggle when revoked certs exist
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Split ACME / Imported / CA-mTLS into tabs with count badges
- Add clickable status summary bar (expired / expiring soon / healthy)
- Per-tab search filter by name and domain
- Replace accordion cards with DataTable for imported certs
- Slide-in Drawers (480 px) for add/edit imported and CA certs
- File upload + show/hide toggle for private key in ImportCertDrawer
- CaCertDrawer: Generate / Import PEM tabs for add, simple form for edit
- CA tab: expandable rows showing issued client certs inline
- RelativeTime component: "in 45 days" / "EXPIRED 3 days ago" with date tooltip
- Remove CreateCaCertDialog and EditCaCertDialog (replaced by CaCertDrawer)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When all issued certs for a CA are revoked, buildAuth returns null.
Previously the code would merge mTLS domains back into a policy with no
client_authentication, silently dropping the requirement and allowing
unauthenticated access (open bypass).
Fix by always splitting mTLS and non-mTLS domains first, then using
drop: true when buildAuth returns null — so a fully-revoked CA causes
Caddy to drop TLS connections for those domains rather than admit them
without a client certificate.
Also removed the redundant first buildAuth(domains) call in the
auto-managed path that was used only as an existence check.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Caddy's trusted_leaf_certs is an additional check on top of CA chain
validation, not a replacement. Without trusted_ca_certs, Go's TLS
rejects the client cert before the leaf check runs, causing 'unknown ca'.
Updated buildClientAuthentication to always include the CA cert in
trusted_ca_certs for chain validation, and additionally set
trusted_leaf_certs for managed CAs to enforce revocation. When all
issued certs for a CA are revoked, the CA is excluded from
trusted_ca_certs entirely so chain validation fails for any cert from it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs fixed:
1. buildClientAuthentication was placing issued leaf cert PEMs into
trusted_ca_certs. Caddy uses that field for CA chain validation, not
leaf pinning — putting leaf certs there made chain verification fail
for every presented client cert, causing the browser to be asked
repeatedly. Fixed by using trusted_leaf_certs for managed CAs.
2. If all issued certs for a CA were revoked, the active cert map would
be empty and the code fell back to trusting the CA cert directly,
effectively un-revoking everything. Fixed by tracking which CAs have
ever had issued certs (including revoked) and keeping them in
trusted_leaf_certs mode permanently (empty list = no one trusted).
Also fix CA certificate delete action not surfacing the error message
to the user in production (Next.js strips thrown error messages in
server actions). Changed to return { success, error } and updated the
client dialog to check the result instead of using try/catch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Old DB records may still have mode='DetectionOnly'. The previous
value?.mode ?? 'inherit' would pass that string into state, leaving no
engine mode button selected. Explicitly accept only 'Off'/'On'; anything
else (including legacy DetectionOnly) falls back to 'inherit'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WafSettings.mode is now 'Off' | 'On' so the legacy DB coercion guard
triggered a TS2367 type error. DB values are already normalised upstream.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With DetectionOnly removed, the global WAF had two redundant controls:
an Enable toggle and an Off/On radio, both doing the same thing. Collapse
them into a single labelled switch. Mode is now derived from the enabled
state in the action rather than being a separate form field.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DetectionOnly was fundamentally broken in coraza-caddy (actually blocks
requests via anomaly scoring), caused massive audit log flooding, and the
threshold workaround had several issues:
- t:none is meaningless in a SecAction (no target to transform)
- SecRuleEngine directive ordering relative to SecAction is implementation-
defined, making the override fragile
- host.mode ?? 'DetectionOnly' fallbacks silently gave any host without an
explicit mode the broken DetectionOnly behaviour
Changes:
- Remove DetectionOnly from UI (global settings radio, per-host engine mode)
- Coerce legacy DB values of 'DetectionOnly' to 'On' in buildWafHandler
- Fix fallback defaults: host.mode ?? 'DetectionOnly' → host.mode ?? 'On'
- Fix action parsers: unknown mode defaults to 'On' (was 'DetectionOnly')
- Fix global settings defaultValue: ?? 'DetectionOnly' → ?? 'On' (or 'Off')
- Remove the fragile threshold SecAction workaround
- Update types: mode is now 'Off' | 'On' throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SecAuditEngine On logs every request through the WAF regardless of whether
any rules matched, causing massive disk I/O on busy hosts (e.g. during
Docker image pushes). RelevantOnly still captures DetectionOnly hits because
OWASP CRS rules include auditlog in their SecDefaultAction, so rule-matched
transactions are marked for audit logging. Only truly clean requests (no
rule match at all) are silently skipped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_journal.json must list every migration for the Drizzle migrator to pick
it up at runtime. 0014_waf_blocked was missing, so the blocked column was
never added to waf_events.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drizzle's better-sqlite3 migrator splits SQL files on --> statement-breakpoint
before running each statement. Without it, multi-statement files fail with
"The supplied SQL string contains more than one statement".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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>
Replace file-based cert loading with inline content to fix cross-container
filesystem issues (web and caddy containers don't share the data volume):
- Imported server certs: switch from tls.certificates.load_files to
tls.certificates.load_pem (inline PEM content in JSON config)
- Client CA certs: use trusted_ca_certs (base64 DER) instead of
trusted_ca_certs_pem_files
- Fix pre-existing bug where certificates[] was placed inside
tls_connection_policies (invalid Caddy JSON field)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New `ca_certificates` table for reusable CA certs (migration 0011)
- CA cert CRUD model, server actions, and UI dialogs
- Proxy host create/edit dialogs include mTLS toggle + CA cert selection
- Caddy config generates `client_authentication` TLS policy blocks with
`require_and_verify` mode for hosts with mTLS enabled
- CA certs sync to slave instances via instance-sync payload
- Certificates page shows CA Certificates section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds checkSameOrigin() helper in auth.ts that validates the Origin header
against the Host header. If Origin is present and mismatched, returns 403.
Applied to all 5 custom POST routes flagged in CPM-003 (NEXT-CSRF-001):
- change-password, link-oauth-start, unlink-oauth, update-avatar, logout
SameSite=Lax (NextAuth default) already blocks standard cross-site CSRF;
this adds defense-in-depth against subdomain and misconfiguration scenarios.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both "waf" and "geoblock" settings were missing from the sync payload,
meaning slaves used their own (potentially unconfigured) values.
Per-host WAF was already synced via the proxyHosts table.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add getTopWafRulesWithHosts() and getWafEventCountries() model queries
- WAF stats API now returns topRules with per-host breakdown and byCountry
- Analytics: replace WAF rules table with bar chart + host chip details
- Analytics: add WAF column (amber) to Top Countries table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WafEvent model: expose rawData field from DB
- DataTable: add optional onRowClick prop with hover cursor
- WafEventsClient: clicking a row opens a right-side drawer showing
all event fields plus the raw Coraza audit JSON (pretty-printed)
Safety: rawData is rendered via JSON.stringify into a <pre> element,
never via dangerouslySetInnerHTML, so attack payloads are displayed
as inert text.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DataTable: add overflowX auto to TableContainer + minWidth 600
- WAF events: tighten column widths (Time 150, Host 150, IP 140,
Method 60), add ellipsis+tooltip on Host column, let Rule Message
expand to fill remaining space
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Coraza does not write matched rules to the audit log (known upstream
bug). Rule details are logged by Caddy's http.handlers.waf logger.
Two changes:
1. caddy.ts: Always configure a dedicated Caddy log sink that writes
http.handlers.waf logger output to /logs/waf-rules.log as JSON.
2. waf-log-parser.ts: Before parsing the audit log, read the new
waf-rules.log to build a Map<unique_id, RuleInfo>. Each audit log
entry joins against this map via transaction.id to populate
ruleId, ruleMessage, and severity fields. Skips anomaly evaluation
rules (949110/980130) to show the actual detection rule instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>