| Threat | Mitigation |
|---|---|
| Rogue agent publishes false metrics | HMAC-SHA256 payload signing; bridge rejects unsigned messages |
| Agent impersonation | Agent key stored as bcrypt hash; credentials checked by Mosquitto auth plugin on every connect |
| Agent reads another agent's data | Per-agent MQTT ACL files restrict topic access to own namespace |
| MitM between agent and external proxy | TLS handled by the external reverse proxy — operator's responsibility to configure correctly |
| Compromised agent key in DB | Keys stored as bcrypt hashes — raw key is shown exactly once |
| API session hijacking | JWT short-lived (15 min); refresh token in HttpOnly cookie, Redis-backed with revocation |
| Brute-force admin login | Rate-limited to 5 attempts per 15 minutes per IP |
| API abuse / DDoS | slowapi rate limiting: 5 req/min on auth, 120 req/min general |
| XSS in browser | CSP: script-src 'self'; X-Content-Type-Options nosniff |
| Clickjacking | X-Frame-Options DENY |
| SQL injection | SQLAlchemy ORM only — no raw string query construction |
| Secrets in source code | All secrets via environment variables; .env is gitignored |
| Unauthorised API access | RBAC enforced server-side on every route via FastAPI dependency |
| Privilege escalation via CORS | Explicit allow_origins list — no wildcards; credentials only for hub origin |
| Internal service exposure | Two-network Docker setup; only Nginx on the public network |
TLS termination is the responsibility of the external reverse proxy (e.g. Traefik, Nginx, Cloudflare Tunnel) sitting in front of the Docker stack. The Docker stack operates entirely in plain HTTP and plain WebSocket internally — there is no TLS configuration inside Docker.
[Agent / Browser]
│ wss:// / https:// (TLS terminated here)
▼
[External Reverse Proxy — Traefik / Nginx / etc.]
│ ws:// / http:// (plain, forwarded to port 5555)
▼
[Docker: nginx:80 exposed as host port 5555]
The external proxy must:
- Enforce TLS 1.2+ minimum
- Set
X-Forwarded-Proto: httpsso the API can build correct redirect URLs - Forward WebSocket upgrade headers for both
/ws/(browser) and/mqtt(agents)
Agents connect to wss://hub.example.com/mqtt. TLS is handled by the external proxy. Inside Docker, traffic flows:
Agent (wss://)
→ External proxy (TLS terminates, forwards as ws://)
→ Nginx :80 /mqtt (WebSocket proxy)
→ Mosquitto :9001 (WebSocket listener, plain)
→ Mosquitto auth plugin checks username (agent_id) + password (agent_key) against DB
→ ACL checked on every publish/subscribe
- TLS terminated at the external proxy — the Docker stack sees plain HTTP/WS
- The API sets security headers including HSTS so browsers enforce HTTPS on subsequent visits
- WebSocket upgrade at
/ws/only accepted from authenticated sessions
Every MQTT message includes an HMAC-SHA256 signature over the payload field, regardless of transport. This provides message-level integrity independent of TLS:
# Agent — signing (payload_sign.py)
import hmac, hashlib, json
def sign_payload(payload: dict, secret_key: str) -> str:
serialised = json.dumps(payload, sort_keys=True, separators=(',', ':'))
return hmac.new(secret_key.encode(), serialised.encode(), hashlib.sha256).hexdigest()# Hub — verification (mqtt_bridge.py)
def verify_payload(payload: dict, signature: str, secret_key: str) -> bool:
expected = sign_payload(payload, secret_key)
return hmac.compare_digest(expected, signature)Messages with missing or invalid HMAC are dropped silently (no error response to prevent oracle attacks) and logged internally.
- Google ID tokens validated server-side with
google-authlibrary - Only Google Workspace accounts may be permitted (configurable
allowed_domainslist) - PKCE flow for the OAuth redirect
- Username + bcrypt-hashed password
- Rate-limited: 5 failed attempts per 15 minutes per IP (Redis-backed counter)
- Login form is hidden behind a "Sign in as administrator" link — not prominent in the UI
| Token | Expiry | Storage | Revocation |
|---|---|---|---|
| Access token | 15 minutes | In-memory (JavaScript variable) | Expires naturally |
| Refresh token | 7 days | HttpOnly cookie (SameSite=Strict) | Redis entry deleted on logout or rotation |
Token rotation: every refresh issues a new refresh token and invalidates the old one (rotation detected via Redis).
On logout: both the access token (client-side clear) and refresh token (Redis deletion) are invalidated.
Roles are enforced server-side on every FastAPI route via dependency injection. Frontend role checks are cosmetic only — the API always enforces the real restriction.
See docs/rbac.md for the full permission matrix and implementation patterns.
Applied by middleware/security_headers.py on every response:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
HSTS is set here even though TLS is external — the external proxy forwards the header to the browser, which then enforces HTTPS on future requests.
internet → external proxy (TLS) → :5555 → [nginx:80] → [internal network]
├── api :8000
├── worker
├── beat
├── mosquitto :1883 (internal MQTT)
├── mosquitto :9001 (MQTT-over-WS, via nginx)
├── postgres :5432
└── redis :6379
Postgres, Redis, and Mosquitto have no host port bindings. They are only reachable from within the internal Docker network.
All secrets are environment variables. The .env file is gitignored. .env.example documents every variable with no real values.
Secrets:
JWT_SECRET_KEY— 256-bit random, generated at first deployADMIN_PASSWORD_HASH— bcrypt hash of the admin passwordMQTT_BRIDGE_PASSWORD— internal bridge credentialsAUDIT_IP_HMAC_KEY— HMAC key for IP address pseudonymisationGOOGLE_CLIENT_SECRET— Google OAuth client secret- Database passwords, Redis URL
Secrets are never logged, never included in error responses, never committed to source control.
- Generation: Hub generates a 32-byte cryptographically random key (URL-safe base64). Displayed once to the operator.
- Storage: Bcrypt hash stored in
agents.agent_key_hash. Plaintext is never stored. - Use: Agent sends plaintext key as MQTT password on every connect. Mosquitto auth plugin hashes it and compares to the stored hash.
- Revocation:
- Set
agents.status = 'revoked' - Delete ACL file from
infra/mosquitto/acl/ - Reload Mosquitto (SIGHUP)
- Audit log entry created
- Set
- Set
JWT_SECRET_KEYto a new value in the environment - Restart the API service
- All existing access tokens are immediately invalidated (signed with the old key)
- All users are prompted to log in again
- Refresh tokens in Redis are also invalidated as they will fail validation
- Update
MQTT_BRIDGE_PASSWORDin the environment - Update the Mosquitto password file:
mosquitto_passwd -b /etc/mosquitto/passwd hub_bridge <new_password> - Reload Mosquitto:
kill -HUP <mosquitto_pid>ordocker compose exec mosquitto kill -HUP 1 - Restart the API service with the new password