Verifies all Docker containers in the test stack are running and healthy,
including a restart-count check on the l4-port-manager to detect permission
errors or other crash-loop scenarios that previously went unnoticed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 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>
- 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>
SQLite was too slow for analytical aggregations on traffic_events and
waf_events (millions of rows, GROUP BY, COUNT DISTINCT). ClickHouse is
a columnar OLAP database purpose-built for this workload.
- Add ClickHouse container to Docker Compose with health check
- Create src/lib/clickhouse/client.ts with singleton client, table DDL,
insert helpers, and all analytics query functions
- Update log-parser.ts and waf-log-parser.ts to write to ClickHouse
- Remove purgeOldEntries — ClickHouse TTL handles 90-day retention
- Rewrite analytics-db.ts and waf-events.ts to query ClickHouse
- Remove trafficEvents/wafEvents from SQLite schema, add migration
- CLICKHOUSE_PASSWORD is required (no hardcoded default)
- Update .env.example, README, and test infrastructure
API response shapes are unchanged — no frontend modifications needed.
Parse state (file offsets) remains in SQLite.
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>
Replace client-controlled redirectUri with server-side redirect intents.
The portal server component validates the ?rd= hostname against
isForwardAuthDomain, stores the URI in a new forward_auth_redirect_intents
table, and passes only an opaque rid (128-bit random, SHA-256 hashed) to
the client. Login endpoints consume the intent atomically (one-time use,
10-minute TTL) and retrieve the stored URI — the client never sends the
redirect URL to any API endpoint.
Security properties:
- Redirect URI is never client-controlled in API requests
- rid is 128-bit random, stored as SHA-256 hash (not reversible from DB)
- Atomic one-time consumption prevents replay
- 10-minute TTL limits attack window for OAuth round-trip
- Immediate deletion after consumption
- Expired intents cleaned up opportunistically
- Hostname validated against registered forward-auth domains before storage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add per-user API token limit (max 10) and name length validation (max 100 chars)
- Return 404 instead of 500 for "not found" errors in API responses
- Disable X-Powered-By header to prevent framework fingerprinting
- Enforce http/https protocol on proxy host upstream URLs
- Remove stale comment about OAuth users defaulting to admin role
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add five new features to the features list: Forward Auth Portal, mTLS
RBAC, User Roles, User Management, and Groups. Add a Forward Auth
Portal section explaining the built-in IdP, groups, and per-host
access control.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use config.oauth.providerName (e.g. "Keycloak", "Google") instead of
the raw provider ID "oauth2" in audit summaries. Include user name or
email in sign-in and sign-up messages for easier log reading.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Intermittent failure — the default 5s wasn't enough when the page
loaded slowly during a long E2E run (227/228 passed).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Traffic (24h) card and Recent Activity section were visible to
user/viewer roles even though they received empty data. Now both
sections are conditionally rendered only for admin users.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- dashboard.spec.ts: anchor regex /^\d+\s+Proxy Hosts/ to not match
"L4 Proxy Hosts" sidebar link
- role-access.spec.ts: use exact: true for "Proxy Hosts" link
- users.spec.ts: match any user count (/\d+ users?/) since other test
suites create additional users
- groups.spec.ts: remove unused emptyText variable
- link-account.spec.ts: remove unused context parameter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- dashboard: Match stat cards via link role with count+label pattern
to avoid matching subtitle paragraph containing "certificates"
- role-access: Use Bun.password.hash (built-in bcrypt) instead of
bcryptjs which is not installed in the production container
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- api-docs: Don't rely on CDN-loaded Swagger UI class in test env
- dashboard: Use `p` locator for stat card labels to avoid matching nav
- groups: Scope add-member click to bordered container to avoid nav match
- link-account: Remove assertion on error= URL param (not always present)
- portal: Use exact:true for "Sign in" button (OAuth button also matches)
- role-access: Use ESM imports in bun -e script, use getByLabel for login
fields, increase waitForURL timeout, use exact button match
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Allow non-admin users (user/viewer) to access / and /profile while
blocking admin-only pages. The dashboard layout now uses requireUser()
instead of requireAdmin(), and the sidebar filters nav items by role.
Non-admin users see a minimal welcome page without stat cards.
New test files (86 tests across 7 files):
- dashboard, users, groups, api-docs, portal, link-account specs
- role-access spec with full RBAC coverage for all 3 roles
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove unused imports, functions, and variables flagged by
@typescript-eslint/no-unused-vars and no-useless-assignment rules.
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>
Pentest found that all 8 analytics API endpoints, the GeoIP status
endpoint, and the OpenAPI spec were accessible to any authenticated
user. Since the user role should only have access to forward auth
and self-service, these are now admin-only.
- analytics/*: requireUser → requireAdmin
- geoip-status: requireUser → requireAdmin
- openapi.json: add requireApiAdmin + change Cache-Control to private
- analytics/api-docs pages: requireUser → requireAdmin (defense-in-depth)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix broken rate limiting: add registerFailedAttempt/resetAttempts calls
- Remove raw session token from exchange table; generate fresh token at redemption
- Fix TOCTOU race: atomic UPDATE...WHERE used=false for exchange redemption
- Delete exchange records immediately after redemption
- Change bcrypt.compareSync to async bcrypt.compare to prevent event loop blocking
- Fix IP extraction: prefer x-real-ip, fall back to last x-forwarded-for entry
- Restrict redirect URI scheme to http/https only
- Add Origin header CSRF check on login and session-login endpoints
- Remove admin auto-access bypass from checkHostAccess (deny-by-default for all)
- Revoke forward auth sessions when user status changes away from active
- Validate portal domain against registered forward-auth hosts to prevent phishing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New /users page with search, inline editing, role/status changes, and deletion
- Model: added updateUserRole, updateUserStatus, deleteUser functions
- API: PUT /api/v1/users/[id] now supports role and status fields, added DELETE
- Safety: cannot change own role/status or delete own account
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>
Caddy's certmagic creates storage dirs with hardcoded 0700 permissions,
making the web container's supplementary group membership ineffective.
Rather than working around this with ACLs or chmod hacks, remove the
feature entirely — it was cosmetic (issuer/expiry display) for certs
that Caddy auto-manages anyway.
Also bump access list dropdown timeout from 5s to 10s to fix flaky E2E test.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Traffic (24h) card's "Blocked" percentage only counted
geo-blocks from trafficEvents. Now also queries wafEvents to
include WAF-blocked requests in the total.
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 LocationRule schema with path and upstreams fields
- Add location_rules to ProxyHost and ProxyHostInput schemas
- Fix response_headers using concrete example instead of generic additionalProperties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>