---
name: cb-security-hardening
description: Enforces Circuit Breaker security hardening conventions across backend, frontend, Docker, and nginx. Use when modifying authentication logic, security headers, Docker configuration, credential handling, session management, URL validation, WebSocket auth, NATS bus auth, agent enrollment, or any code in core/security.py, core/rbac.py, core/agent_crypto.py, core/network_acl.py, middleware/security_headers.py, core/url_validation.py, docker-compose.yml, or docker/{nginx.mono.conf,entrypoint-mono.sh,supervisord.mono.conf}.
---

# Circuit Breaker — Security Hardening Conventions

Codified from SECURITY_STANDING-1.md and SECURITY_STANDING-2.md audit reports.

## 1. Authentication Is Always Enforced

Authentication cannot be disabled after OOBE. The `auth_enabled` column on `AppSettings` is a **one-way OOBE completion marker** only.

**Rules:**
- Never add a code path that skips auth when `auth_enabled` is `False` post-bootstrap
- `get_optional_user` returns `None` only when no `jwt_secret` exists (pre-OOBE)
- `require_write_auth` always raises `401` when `user_id is None`
- `require_role` / `require_scope` in `core/rbac.py` never return a synthetic admin for unauthenticated requests
- WebSocket handlers (`ws_discovery.py`, `ws_telemetry.py`, `ws_topology.py`, `ws_monitors.py`, `ws_agents.py`) must validate JWT — no anonymous sentinel when `auth_enabled` is `False`. The one deliberate exception is the agent `/enroll` and `/link` sockets, mounted without `Depends(require_auth)` because the Noise IK handshake performed inside them *is* that router's authentication (`core/agent_crypto.py`) — do not "fix" it by adding the dependency
- The `AppSettingsUpdate` schema must not include `auth_enabled`
- Frontend `AuthContext` (`apps/frontend/src`, JavaScript/JSX) has no `authEnabled` state or `setAuthEnabled` — auth is always on
- Frontend route gating: `!isAuthenticated` redirects to `/login` (not `authEnabled && !isAuthenticated`)

## 2. Timing-Safe Token Comparison

All sensitive token comparisons must use `hmac.compare_digest`, never `==`.

```python
# Correct
import hmac
if api_token and raw_token and hmac.compare_digest(raw_token, api_token):
    return 0

# Wrong — timing side-channel
if api_token and raw_token == api_token:
    return 0
```

**Applies to:** `CB_API_TOKEN`, any bearer token comparison, webhook signature verification.

## 3. Session Revocation on Password Change

When a user changes their password, all other active sessions must be revoked.

```python
from app.services.user_service import _hash_token, revoke_all_sessions

token = _extract_token(request)
except_hash = _hash_token(token) if token else None
revoke_all_sessions(db, user_id, except_token_hash=except_hash)
```

An audit log entry (`action="password_changed"`) must also be written.

## 4. SSRF Prevention — URL Scheme Validation

`core/url_validation.py` must reject all non-HTTP(S) schemes before any IP resolution:

```python
_ALLOWED_SCHEMES = frozenset({"http", "https"})

scheme = (parsed.scheme or "").lower()
if scheme not in _ALLOWED_SCHEMES:
    raise ValueError(f"URL scheme '{scheme}' is not allowed.")
```

This blocks `file://`, `gopher://`, `ftp://`, `dict://`, etc.

## 5. Security Headers

All responses must include these headers (set in both `middleware/security_headers.py` and `docker/nginx.mono.conf`):

| Header | Value |
|--------|-------|
| Content-Security-Policy | `default-src 'self'; script-src 'self' 'unsafe-inline' 'strict-dynamic'; ...` |
| X-Content-Type-Options | `nosniff` |
| X-Frame-Options | `DENY` |
| Referrer-Policy | `strict-origin-when-cross-origin` |
| Strict-Transport-Security | `max-age=63072000; includeSubDomains` |
| Permissions-Policy | `camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()` |

**Rules:**
- `'strict-dynamic'` must remain in `script-src` to progressively override `'unsafe-inline'`
- Never remove `frame-ancestors 'none'` from CSP
- Never weaken `X-Frame-Options` from `DENY`

## 6. Transport Security — HTTPS Redirect

`docker/nginx.mono.conf` must have two server blocks:

1. **Port 80** — returns `301` redirect to `https://` for all paths except `/api/v1/health` (exempt for Docker healthchecks)
2. **Port 443** — main HTTPS server with TLS termination

Never serve application content over plain HTTP.

## 7. Docker Socket Isolation

The Docker socket is **not mounted by default** in `docker-compose.yml`.

**Opt-in methods:**
1. Override file: `docker compose -f docker-compose.yml -f docker-compose.socket.yml up -d`
2. TCP proxy: set `CB_DOCKER_HOST=tcp://proxy:2375`

Backend Docker discovery code (`docker_discovery.py`, `discovery_safe.py`) must check `CB_DOCKER_HOST` env var first, falling back to the local socket only if present.

## 8. Container Filesystem Immutability

`docker-compose.yml` must specify:

```yaml
read_only: true
tmpfs:
  - /tmp:size=100M
  - /run:size=10M
  - /var/log:size=50M
  - /var/lib/nginx:size=10M
  - /var/lib/postgresql:size=10M
```

Only `/data` is a persistent writable bind mount. Never add writable volume mounts without explicit justification.

## 9. Redis Authentication

Embedded Redis must use `--requirepass`. The password is:
- Auto-generated at first container start (`openssl rand -base64 32`) to `/data/.redis_pass`
- Injected into `CB_REDIS_URL` by `docker/entrypoint-mono.sh` when not user-supplied
- Read by `docker/supervisord.mono.conf` at Redis process start

Never run Redis without `requirepass` inside the container.

## 10. Vault Key Rotation

The Fernet vault key auto-rotates via an APScheduler daily job (04:30):
- Reads `vault_key_rotation_days` (default 90) and `vault_key_rotated_at` from `AppSettings`
- Calls `rotate_vault_key()` which re-encrypts all credentials, persists the new key, and hot-swaps the in-memory vault

When modifying vault-related code, ensure the rotation path remains functional and tested.

**Anything newly stored with `vault.encrypt` must be added to `rotate_vault_key`.**
A location the rotation skips survives as ciphertext the new key cannot read.
The agent server keys were skipped, and the first rotation broke every agent
handshake (issue #168). `tests/services/test_vault_rotation_coverage.py` fails
when a module that encrypts with the vault is missing from its
`ROTATED_BY_MODULE` map; add the location to the rotation *and* seed it in that
test, never just silence the map.

## 11. Mandatory Secrets

`docker-compose.yml` must fail-fast on missing secrets:

```yaml
environment:
  - CB_DB_PASSWORD=${CB_DB_PASSWORD:?Set CB_DB_PASSWORD}
  - CB_VAULT_KEY=${CB_VAULT_KEY:?Set CB_VAULT_KEY}
  - NATS_AUTH_TOKEN=${NATS_AUTH_TOKEN:?Set NATS_AUTH_TOKEN for internal bus auth}
```

Never remove the `:?` error syntax. Never add defaults for secret values.

## 12. Capability Restrictions

Docker containers must run with:

```yaml
security_opt:
  - no-new-privileges:true
cap_drop:
  - ALL
cap_add:
  - NET_RAW
  - NET_BIND_SERVICE
  - CHOWN
  - SETUID
  - SETGID
  - DAC_OVERRIDE
  - KILL   # root supervisord must signal its breaker-owned programs on stop
stop_grace_period: 60s   # > the largest stopwaitsecs in supervisord.mono.conf
```

Never add capabilities without documenting why. `KILL` is there because
supervisord runs as root and its programs run as `breaker`. Without CAP_KILL,
every SIGTERM on `docker stop` fails with EPERM, Docker SIGKILLs the container,
and Postgres needs crash recovery. `tests/build/test_mono_stop_is_clean.py`
enforces both lines.

## Validation Checklist

When reviewing security-sensitive changes:

- [ ] No `==` for token/secret comparison — use `hmac.compare_digest`
- [ ] No code path allows unauthenticated writes post-OOBE
- [ ] WebSocket handlers validate JWT (no anonymous bypass)
- [ ] URL inputs validate scheme is HTTP(S) only
- [ ] All security headers present in both middleware and nginx
- [ ] Docker socket not mounted in default compose
- [ ] `read_only: true` on container, only `/data` writable
- [ ] Redis uses `requirepass`
- [ ] Secrets use `${VAR:?error}` in compose — no defaults
- [ ] Password changes revoke other sessions
- [ ] Vault rotation scheduler job intact

## Reference

- [SECURITY_STANDING-1.md](../../../SECURITY_REPORTS/SECURITY_STANDING-1.md) — initial audit
- [SECURITY_STANDING-2.md](../../../SECURITY_REPORTS/SECURITY_STANDING-2.md) — post-remediation
