Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Security

The threat model and hardening notes below are the same document maintained in the repository (docs/SECURITY.md).

Ruscker — Security & Threat Model

Status: living document. Tracks the Phase 5 security audit (issue #14). Each control is marked [implemented], [accepted limitation], or [deferred]. File references use crate/path:symbol so they survive line-number drift.

Scope. This covers v0.1.80: single-operator install, Ruscker behind a TLS-terminating reverse proxy, Docker on the same host. Multi-tenant / shared-team auth (OIDC, RBAC) is out of scope until Phase 8.


1. Threat model

Assets

AssetWhy it matters
RUSCKER_ADMIN_TOKENbreak-glass Admin login + first-account bootstrap — full access
User passwords (DB)per-user login; stored as argon2id hashes
RUSCKER_MASTER_KEYdecrypts the registry-credential store
RUSCKER_COOKIE_KEYforges sticky-session cookies
Registry credentials (DB)pull access to private images
Running app sessionsper-visitor app state inside containers
The Docker daemonfull host compromise if reachable

Attackers

  • Network attacker — can reach the bound port. Mitigated by binding to localhost / private network + reverse proxy.
  • Malicious visitor — hits /app/* / /api/* without admin rights. Should never reach admin surfaces or other visitors’ sessions.
  • Curious operator-adjacent user — has some network access, tries to brute-force the admin token or forge cookies.
  • Compromised app image — a container Ruscker spawned that tries to escape its limits or reach the host/other containers.

Non-goals (explicitly out of scope for MVP)

  • Defending against a hostile operator (they own the host + Docker daemon + all keys).
  • Per-app ACLs and external identity providers (OIDC/SAML/LDAP). Coarse RBAC (Viewer/Editor/Admin) exists (§2); fine-grained, per-spec authorization is Phase 8.
  • TLS termination (delegated to the reverse proxy).

2. Authentication & authorization

  • [implemented] User accounts (#107) — per-user login (username + password) backed by the users table; passwords stored only as argon2id PHC hashes (db::users, never plaintext). verify_login runs a decoy hash on unknown usernames so timing doesn’t reveal whether an account exists. Roles (viewer/editor/admin) are per-user.
  • [implemented] Break-glass admin token — auth::AdminAuth::matches_tokenct_eq (XOR-fold, length- checked; time depends only on the public length). RUSCKER_ADMIN_TOKEN always grants an Admin session and bootstraps the first account (token login on a fresh install → forced setup). It’s the recovery path so an operator can never be locked out; treat it as a break-glass secret. The old RUSCKER_EDITOR_TOKEN/RUSCKER_VIEWER_TOKEN (the #101 MVP) are removed — Editor/Viewer are DB accounts now.
  • [implemented] Login rate limiting — auth::LoginRateLimiter (global sliding window, default 10 failures / 60 s). Saturated → 429 + Retry-After. Wired in routes::admin::login_submit. Global, not per-IP: behind a reverse proxy the peer IP is the proxy, and a per-IP key would trust a spoofable X-Forwarded-For. A global cap can’t be evaded by rotating source addresses.
  • [implemented] Admin cookie is HttpOnly + SameSite=Strict
    • Secure (under TLS, see §7) — routes::admin::login_submit.
  • [implemented] Opaque server-side sessions (#77) — the cookie carries a random 244-bit session id (auth::AdminSessionStore), never the token. Logout and server restart revoke it; a stolen cookie never exposes the token. The store is in-memory by default (InMemoryAdminSessionStore); for HA, point Ruscker at a shared Postgres via --admin-session-store-url (#185) so sessions survive a load-balancer hop.
  • [implemented] Role-based access control (#101/#107) — three roles (Viewer = dashboard read-only; Editor = apps + media + dashboard incl. stop/restart; Admin = everything, incl. user management). Enforcement is server-side via the AdminSession / RequireEditor / RequireAdmin extractors on each route group — the permission matrix lives in Role::can_access_section / can_manage, and the nav only hides links it can’t reach (UX, not the boundary). Denied → 403. Admins manage accounts at /admin/users (create/role/reset-password/delete) with a last-admin guard that refuses to delete or demote the only remaining admin. Audit entries record the acting username (or token for a break-glass session). Per-app ACLs and external IdPs (OIDC/SAML/LDAP) remain Phase 8.
  • [implemented] Identifier charset validation (#429 / #423) — a username (db::users::is_valid_username) and a stored-credential name (routes::admin::credentials::is_valid_credential_name) must be non-empty and made only of identifier-ish chars (letters, digits, _ . -, plus @ for e-mail logins). Both land un-encoded in a per-resource admin action URL path segment (/admin/users/{username}/..., /admin/credentials/{name}/delete), so a /, ?, #, or space would make the account/credential impossible to edit or delete from the UI — the validation keeps every row manageable (and therefore deletable). Rejected → the form re-renders with an error, no row written.
  • [implemented] Password fields are write-only in the admin forms (#430) — the user form and the spec-form Registry section never pre-fill or render a stored password; a blank field keeps the existing value, and the input is masked so a shoulder-surfer can’t read a freshly-typed secret. Server-side, a blank password on edit is a no-op, not a wipe.
  • [implemented] Bind-mount volumes are Admin-only (#302). A spec’s volumes map to Docker HostConfig.binds — i.e. host filesystem / docker.sock access — so an Editor (who can otherwise create/edit apps) cannot set or change them: the spec-form field is hidden for non-Admins and, server-side, into_spec keeps the base spec’s volumes when the actor isn’t Admin. Treat Admin as host-trusted and Editor as app-config-trusted.
  • [accepted limitation] Login lockout can be triggered by a flood of bad attempts (the global limiter’s trade-off). Self- heals within the 60 s window.

3. Credentials & secrets

  • [implemented] Registry passwords encrypted at rest with AES-256-GCM — crypto::MasterKey::{encrypt,decrypt}. A fresh random nonce per encryption, stored alongside the ciphertext; never reused (new nonce on every upsert).
  • [implemented] Unified credential store, two storage modes (#351) — db::credentials::upsert accepts either a literal password (AES-256-GCM at rest, as above) or a pure ${VAR} env-ref (stored verbatim, never encrypted, flagged by an empty nonce — a real GCM nonce is 12 bytes, never empty — and resolved from the environment only at pull time). The env-ref branch is gated on ruscker_config::env::is_pure_env_ref: the value must consist entirely of valid ${VAR} / ${VAR:-default} tokens (whole-token only). A value with any literal text — e.g. prefix${VAR} or a malformed abc${def — is not kept verbatim; it’s treated as a literal secret and AES-encrypted. This is the #422 fix: a loose contains("${") test would have stored such a literal in cleartext at rest. Either way the DB never holds resolved cleartext.
  • [implemented] Master key held in Zeroizing<[u8; 32]> inside an Arc — wiped on last drop. Cookie key likewise (ruscker_proxy::sticky::CookieKey).
  • [implemented] DB credential store wired to image pulls — db::credentials::resolve decrypts only at pull time, in the spawn path, never echoed to the UI.
  • [implemented] ${VAR} secrets stay literal end-to-end (#260) — docker-registry-password (and any [env::SECRET_KEYS] key) is not interpolated at parse: the ${VAR} placeholder is preserved through import into the DB and through export output, and resolved only at the point of use (creds_from_spec, right before a pull). So the resolved secret never lands in the config DB or an export. The admin spec form treats the password as write-only — never pre-filled or rendered; a blank field keeps the stored value. docker-registry-credential (the named store — AES literal or a verbatim ${VAR} env-ref, see above) is preferred for new flows; the spec-form Registry section is now just the picker for a stored credential. container-env values get the same treatment (#272): a ${VAR} in a container-env value is preserved literal at parse and resolved only at spawn (Spec::resolved_env_pairs), so an app secret passed via ${VAR} never lands in the DB either. A missing env var fails the pull/spawn with a clear message rather than passing a literal ${VAR} — both for the registry password (#273) and for container-env values (#300).
  • [legacy] A spec imported by an older build may hold a resolved password in its config_json. Re-import the YAML (which now preserves the literal) or rotate the secret to the credentials store; the scan_raw_text validator still flags inline cleartext in YAML.
  • [implemented] Plaintext secrets never logged: pull path logs with_creds=<bool> + registry host, not the password; audit-log inserts carry action/target, not secret values.
  • [accepted limitation] Cookie key and master key are separate, undrived keys. Deriving both from a single RUSCKER_ROOT_KEY via HKDF is a possible ergonomic improvement, not a security need.
  • [deferred] Confirm bollard never logs the auth header on pull at its own debug level (we run it at info+ in prod).

4. Image uploads

  • [implemented] 10 MB pre-decode cap — images::MAX_UPLOAD_BYTES, checked before any decode (defends against decompression-bomb-style payloads).
  • [implemented] MIME sniffing via infer::get — PNG/JPEG/ WebP recognized by magic bytes, not the client-supplied filename/Content-Type.
  • [implemented] X-Content-Type-Options: nosniff on served responses (§7) so a polyglot upload can’t be reinterpreted as active content.
  • [implemented] SVG script neutralization at serve time. Uploaded SVGs are still stored as-is, but /assets/img/* responses (routes::assets::serve_dynamic) carry Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox + X-Content-Type-Options: nosniff. Even if a malicious SVG is opened directly or embedded via <object>/<iframe>, its <script>/<foreignObject> can’t execute. The common <img src=…> use is unaffected (scripts never run in <img> context). The global page-header middleware uses entry().or_insert so it does NOT clobber this stricter per-asset policy. [deferred] content-level sanitization (usvg) if we ever need SVG in an active context.
  • [implemented] Path traversal guard on /assets/img/{file} rejects / and .., with tests for encoded variants (%2F, %2e%2e, backslash) — all 400 / 404, never a file read.

5. SQL & database

  • [implemented] All queries parameterized — no string interpolation into SQL (grep format!.*SELECT across db/ is empty). Dynamic filters in db::audit::list use QueryBuilder::push_bind, not concatenation.
  • [implemented] journal_mode = WAL + foreign_keys = ONdb::open / db::open_memory (db.rs).
  • [accepted limitation] No automated backups — the operator owns the SQLite file’s backup schedule. Documented in §8.

6. Proxy

  • [implemented] Hop-by-hop header strip — routes::proxy HOP_BY_HOP covers RFC 7230 §6.1 tokens + the dynamic Connection: token list. X-Forwarded-Proto / -Port are stripped before forwarding upstream.
  • [implemented] Open-redirect closed — routes::same_origin_path reduces a Referer to a same-origin path; used by /__set/* and the login redirect.
  • [implemented] CSRF defense — admin cookie is SameSite=Strict, so a cross-site POST can’t carry it, and a server-side guard (csrf_guard, #259) rejects state-changing chrome requests that aren’t same-origin: it trusts Sec-Fetch-Site (same-origin/none only) when present, else falls back to an Origin vs Host check. Requests with neither header (curl, the break-glass token POST) pass — they aren’t browser CSRF.
  • [implemented] Ruscker cookies are stripped before forwarding upstream (#258) — strip_ruscker_cookies removes the admin session, the sticky cookie, and the theme/locale prefs from the upstream-bound Cookie header so an app container never sees them (the admin session id is a bearer).
  • [accepted limitation — needs origin separation] Admin and apps share one origin by default. A script inside an untrusted app served at /app/{spec} is genuinely same-origin with /admin, so neither SameSite=Strict nor the same-origin CSRF guard can stop it from issuing credentialed fetch('/admin/...') calls. The cookie strip (#258) stops the app from reading the session, but a same-origin request from the browser still carries it. If you host third-party / untrusted apps, serve the admin on a separate hostname/origin (e.g. admin.example.org vs apps.example.org) so the browser’s same-origin policy isolates them. Trusted, first-party apps on one origin are fine.
  • [implemented] Sticky-cookie cross-app defense — the handler checks session.spec_id == spec.id before honoring a sticky cookie, even though its Path=/ spans apps. (routes::proxy::resolve_replica.)
  • [implemented] Sticky cookie integrity — HMAC-SHA256 truncated to 16 bytes (128-bit forgery resistance) over the signed payload (ruscker_proxy::sticky). 128 bits is far past brute-forceable within a session window.
  • [accepted limitation] Upstream is always 127.0.0.1:<port> — not an SSRF vector while the operator can’t point a spec at an external host.
  • [accepted limitation] Container labels (ruscker.spec_id, …) are trusted by list(). A manually-created container could forge them; acceptable because the operator owns the host.
  • [deferred] Add proxy-connection (legacy HTTP/1.0) to the hop-by-hop strip list.
  • [deferred] WS pump backpressure — a slow client can accumulate frames. Bound the channel with a drop policy.
  • [deferred] Whitelist (rather than only strip a couple of) client-supplied X-Forwarded-* headers before forwarding upstream.

7. TLS, headers & network

  • [implemented] Security response headers on Ruscker’s own surfaces (landing/admin/prefs/assets), NOT on proxied /app/*,/api/*lib::security_headers: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: same-origin, and a Content-Security-Policy (default-src 'self'; … frame-ancestors 'none'; base-uri 'self'; form-action 'self').
  • [implemented] Secure cookie flag under TLS — admin + sticky cookies set Secure when auth::request_is_https (reads X-Forwarded-Proto) is true. Off on plain-HTTP dev so the browser doesn’t drop the cookie.
  • [accepted limitation] Ruscker does NOT terminate TLS — expects a reverse proxy (see §9).
  • [deferred] CSP currently allows 'unsafe-inline' for script/style because the landing + dashboard use inline <script>/<style>. A nonce-based CSP that drops unsafe-inline is the hardening follow-up.

8. Logging & observability

  • [implemented] Default tracing level (info) logs paths, spec ids, replica ids — operational metadata, no secrets, no PII.
  • [accepted limitation] No PII is collected today; revisit if auth/user features land.
  • [opt-in] Prometheus /metrics (proxy.metrics-enabled, off by default). When enabled it’s served unauthenticated — it exposes operational gauges (replica counts/states, per-spec sessions, per-replica CPU/memory), no secrets. Only enable it where the endpoint is reachable solely by your scraper (private network / firewall / bound behind the reverse proxy); don’t expose it to the public alongside the landing page.

Reverse proxy (terminates TLS, forwards scheme)

Minimal Caddy:

portal.example.org {
    reverse_proxy 127.0.0.1:8080 {
        header_up X-Forwarded-Proto {scheme}
    }
}

Minimal nginx:

server {
    listen 443 ssl;
    server_name portal.example.org;
    # ssl_certificate ... ssl_certificate_key ...;
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $host;
        proxy_http_version 1.1;            # WebSocket support
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

X-Forwarded-Proto: https is what flips the Secure cookie flag on (§7) — without it Ruscker assumes plain HTTP and omits Secure.

Binding

Bind Ruscker to localhost so only the reverse proxy reaches it:

ruscker serve --bind 127.0.0.1:8080 ...

Secrets (env vars — never in YAML)

export RUSCKER_ADMIN_TOKEN=$(openssl rand -hex 32)   # 256-bit — break-glass admin
export RUSCKER_MASTER_KEY=$(openssl rand -hex 32)    # AES-256 key
export RUSCKER_COOKIE_KEY=$(openssl rand -hex 32)    # sticky HMAC key

On first run, log in with RUSCKER_ADMIN_TOKEN and you’ll be prompted to create the first admin account (username + password). After that, everyone signs in with their account; the token stays as a break-glass / recovery path. Manage further accounts (Viewer / Editor / Admin) at /admin/users.

  • Set RUSCKER_COOKIE_KEY explicitly in prod — without it the sticky key is randomized per process, invalidating all sessions on restart.
  • Rotate RUSCKER_ADMIN_TOKEN if you suspect cookie exfiltration (the cookie holds the token literally — §2).

Backups

Snapshot the SQLite DB (the --db file) on your own schedule; Ruscker does not back it up. With WAL, copy *.db, *.db-wal, *.db-shm together, or use sqlite3 .backup.


10. Audit checklist status (issue #14)

Blocking-for-prod (all done):

  • CSP + security headers on admin (§7)
  • Secure cookie flag under TLS (§7)
  • Login rate limiting (§2)

Non-blocking follow-ups:

  • SVG script neutralization (CSP+sandbox at serve time) (§4)
  • Encoded path-traversal tests for /assets/img (§4)
  • Opaque server-side admin sessions — cookie no longer holds the token; logout revokes server-side (#77)
  • Operator CSP origins (blocks/analytics) sanitized before use (#82)
  • proxy-connection in hop-by-hop strip (§6, #84)
  • WS pump backpressure: independent tasks + idle watchdog (§6, #81)
  • audit_log.diff_json verified to record metadata only — never a password/token/cookie (regression test in db::credentials)
  • Automated cargo audit in CI (.github/workflows/security.yml, weekly + on dependency changes)
  • Nonce-based CSP, drop unsafe-inline (§7)
  • semgrep in CI (cargo-audit is wired; semgrep deferred)