- C1: Replace all ClickHouse string interpolation with parameterized queries
(query_params) to eliminate SQL injection in analytics endpoints
- C3: Strip Caddy placeholder patterns from redirect rules, protected paths,
and Authentik auth endpoint to prevent config injection
- C4: Replace WAF custom directive blocklist with allowlist approach — only
SecRule/SecAction/SecMarker/SecDefaultAction permitted; block ctl:ruleEngine
and Include directives
- H2: Validate GCM authentication tag is exactly 16 bytes before decryption
- H3: Validate forward auth redirect URIs (scheme, no credentials) to prevent
open redirects
- H4: Switch 11 analytics/WAF/geoip endpoints from session-only requireAdmin
to requireApiAdmin supporting both Bearer token and session auth
- H5: Add input validation for instance-mode (whitelist) and sync-token
(32-char minimum) in settings API
- M1: Add non-root user to l4-port-manager Dockerfile
- M5: Document Caddy admin API binding security rationale
- Document C2 (custom config injection) and H1 (SSRF via upstreams) as
intentional admin features
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the ?rd= query parameter in the Caddy→portal redirect with a
_cpm_rd HttpOnly cookie (Secure, SameSite=Lax, Path=/portal, 10min TTL).
The portal server component reads and immediately deletes the cookie,
then processes it through the existing validation and redirect intent flow.
This removes the redirect URI from the browser URL bar while maintaining
all existing security properties (domain validation, server-side storage,
one-time opaque rid).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two bugs caused mTLS to be silently disabled when all issued client
certificates for a CA were revoked:
1. New cert-based trust model (caddy.ts): When deriving CA IDs from
trusted cert IDs, revoked certs were invisible (active-only query),
causing derivedCaIds to be empty and the domain to be dropped from
mTlsDomainMap entirely — no mTLS policy at all. Fix by falling back
to a cert-ID-to-CA-ID lookup that includes revoked certs, keeping the
domain in the map so it gets a fail-closed policy.
2. Legacy CA-based model (caddy-mtls.ts): buildClientAuthentication
returned null when all certs were revoked, relying on Caddy's
experimental "drop" TLS connection policy field which didn't work
reliably. Fix by pinning to the CA cert itself as a trusted_leaf_certs
entry — no client cert can hash-match a CA certificate (and presenting
the CA cert would require its private key, already a full compromise).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CPM can now act as its own forward auth provider for proxied sites.
Users authenticate at a login portal (credentials or OAuth) and Caddy
gates access via a verify subrequest, eliminating the need for external
IdPs like Authentik.
Key components:
- Forward auth flow: verify endpoint, exchange code callback, login portal
- User groups with membership management
- Per-proxy-host access control (users and/or groups)
- Caddy config generation for forward_auth handler + callback route
- OAuth and credential login on the portal page
- Admin UI: groups page, inline user/group assignment in proxy host form
- REST API: /api/v1/groups, /api/v1/forward-auth-sessions, per-host access
- Integration tests for groups and forward auth schema
Also fixes mTLS E2E test selectors broken by the RBAC refactor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The caddy-blocker plugin already emits "request blocked" log entries
for geo/IP blocks, but they were going to Caddy's default log (stdout)
instead of /logs/access.log because http.handlers.blocker was not in
the access log include list. The existing log parser and dashboard were
already wired up to count these — they just never received the data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add udp/ prefix to upstream dial addresses for UDP proxy hosts
(Caddy L4 requires udp/ prefix on both listen and dial for UDP)
- Fix TCP "disabled host" test to check data echo instead of connection
refusal (Docker port mapping always accepts TCP handshake)
- Add waitForTcpRoute before "both ports" test to handle re-enable timing
- Increase UDP route wait timeout to 30s for listener startup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix Caddy L4 config to use "udp/:PORT" listen syntax for UDP proxy hosts
(previously used bare ":PORT" which Caddy treated as TCP)
- Fix TCP unused port test to check data echo instead of connection refusal
(Docker port mapping accepts TCP handshake even without a Caddy listener)
- Fix mTLS import test to wait for sheet close and scope cert name to table
- Fix CA certificate generate test to scope name assertion to table
- Remaining L4 routing test failures are infrastructure issues with Docker
port forwarding and Caddy L4 UDP listener startup timing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New l4_proxy_hosts table and Drizzle migration (0015)
- Full CRUD model layer with validation, audit logging, and Caddy config
generation (buildL4Servers integrating into buildCaddyDocument)
- Server actions, paginated list page, create/edit/delete dialogs
- L4 port manager sidecar (docker/l4-port-manager) that auto-recreates
the caddy container when port mappings change via a trigger file
- Auto-detects Docker Compose project name from caddy container labels
- Supports both named-volume and bind-mount (COMPOSE_HOST_DIR) deployments
- getL4PortsStatus simplified: status file is sole source of truth,
trigger files deleted after processing to prevent stuck 'Waiting' banner
- Navigation entry added (CableIcon)
- Tests: unit (entrypoint.sh invariants + validation), integration (ports
lifecycle + caddy config), E2E (CRUD + functional routing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds two new UI-configurable Caddy patterns that previously required raw JSON:
- Per-path redirect rules (from/to/status) emitted as a subroute handler before
auth so .well-known paths work without login; supports full URLs, cross-domain
targets, and wildcard path patterns (e.g. /.well-known/*)
- Path prefix rewrite that prepends a segment to every request before proxying
(e.g. /recipes → upstream sees /recipes/original/path)
Config is stored in the existing meta JSON column (no schema migration). Includes
integration tests for meta serialization and E2E functional tests against a real
Caddy instance covering relative/absolute destinations, all 3xx status codes, and
various wildcard combinations. Adds traefik/whoami to the test stack to verify
rewritten paths actually reach the upstream.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- accept wildcard proxy host domains like *.example.com with validation and normalization
- make exact hosts win over overlapping wildcards in generated routes and TLS policies
- add unit coverage for host-pattern priority and wildcard domain handling
- add a single test:all entry point and clean up lint/typecheck issues so the suite runs cleanly
- run mobile layout Playwright checks under both chromium and mobile-iphone
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>
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>
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>
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>
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>
- 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>
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>
Coraza's RelevantOnly mode does not write audit log entries for requests
blocked by the WAF itself (403 responses), so the waf-log-parser had
nothing to parse. Reverting to On so all transactions are logged, and
relying on the parser-side messages[] filter to skip clean requests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Change SecAuditEngine from On to RelevantOnly so Coraza only writes
audit log entries for transactions that triggered at least one rule.
Previously all requests were logged regardless of matches.
- Add parser-side guard to skip entries with empty messages array as
belt-and-suspenders against any pre-existing clean entries in log.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
load_owasp_crs: true only merges the embedded coraza-coreruleset
filesystem - it does NOT auto-include rule files. The correct way to
load CRS rules is to explicitly Include them using the @ prefix which
references the embedded FS:
Include @coraza.conf-recommended
Include @crs-setup.conf.example
Include @owasp_crs/*.conf
Without these includes, SecRuleEngine On had no rules to apply and
all requests passed through unblocked (rulesets: null in audit log).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Caddyfile adapter test confirms: load_owasp_crs loads all CRS rules
internally without any Include directives. Include @owasp_crs/... was
wrong — that path is not accessible from SecLang directives.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
load_owasp_crs:true mounts the embedded CRS filesystem (@owasp_crs prefix),
but Include @owasp_crs/... directives are still needed to actually load the
rules. Previously we had one or the other — now both are set together.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The coraza-caddy Go struct defines directives as type string, not []string.
Revert to joined string but keep the Include @owasp_crs/... fix for CRS loading.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- buildWafHandler: directives must be string[] not a joined string (coraza-caddy
JSON API requirement); load_owasp_crs is Caddyfile-only and silently ignored in
JSON config — replaced with Include @owasp_crs/... directives
- waf-log-parser: use unix_timestamp (nanoseconds) for precise ts; host header is
headers.host[] (lowercase array); messages[].data.{id,msg,severity} not rule.*
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The blocker plugin only accepts literal IP/CIDR strings; Caddy's built-in
'private_ranges' shorthand is not understood by third-party modules.
Expand it to the equivalent CIDR list at config-build time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New field from upstream plugin: when the real client IP is
indeterminate (trusted proxy present but no usable XFF entry),
fail_closed=true blocks the request instead of passing it through.
- Add fail_closed to GeoBlockSettings type
- Include in mergeGeoBlockSettings (OR semantics: either global or host enables it)
- Emit fail_closed in buildBlockerHandler (only when true)
- Parse geoblock_fail_closed from form in both settings and proxy-host actions
- Add Checkbox UI in the Advanced accordion of GeoBlockFields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>