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
| Asset | Why it matters |
|---|---|
RUSCKER_ADMIN_TOKEN | break-glass Admin login + first-account bootstrap — full access |
| User passwords (DB) | per-user login; stored as argon2id hashes |
RUSCKER_MASTER_KEY | decrypts the registry-credential store |
RUSCKER_COOKIE_KEY | forges sticky-session cookies |
| Registry credentials (DB) | pull access to private images |
| Running app sessions | per-visitor app state inside containers |
| The Docker daemon | full 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 theuserstable; passwords stored only as argon2id PHC hashes (db::users, never plaintext).verify_loginruns 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_token→ct_eq(XOR-fold, length- checked; time depends only on the public length).RUSCKER_ADMIN_TOKENalways 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 oldRUSCKER_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 inroutes::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 spoofableX-Forwarded-For. A global cap can’t be evaded by rotating source addresses. - [implemented] Admin cookie is
HttpOnly+SameSite=StrictSecure(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/RequireAdminextractors on each route group — the permission matrix lives inRole::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 (ortokenfor 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
volumesare Admin-only (#302). A spec’svolumesmap to DockerHostConfig.binds— i.e. host filesystem /docker.sockaccess — 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_speckeeps 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 everyupsert). - [implemented] Unified credential store, two storage modes (#351) —
db::credentials::upsertaccepts 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 onruscker_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 malformedabc${def— is not kept verbatim; it’s treated as a literal secret and AES-encrypted. This is the #422 fix: a loosecontains("${")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 anArc— wiped on last drop. Cookie key likewise (ruscker_proxy::sticky::CookieKey). - [implemented] DB credential store wired to image pulls —
db::credentials::resolvedecrypts 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 throughimportinto the DB and throughexportoutput, 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-envvalues get the same treatment (#272): a${VAR}in acontainer-envvalue 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 forcontainer-envvalues (#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; thescan_raw_textvalidator 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_KEYvia HKDF is a possible ergonomic improvement, not a security need. - [deferred] Confirm bollard never logs the auth header on
pull at its own
debuglevel (we run it atinfo+ 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: nosniffon 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) carryContent-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 usesentry().or_insertso 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!.*SELECTacrossdb/is empty). Dynamic filters indb::audit::listuseQueryBuilder::push_bind, not concatenation. - [implemented]
journal_mode = WAL+foreign_keys = ON—db::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::proxyHOP_BY_HOPcovers RFC 7230 §6.1 tokens + the dynamicConnection:token list.X-Forwarded-Proto/-Portare stripped before forwarding upstream. - [implemented] Open-redirect closed —
routes::same_origin_pathreduces aRefererto 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 trustsSec-Fetch-Site(same-origin/noneonly) when present, else falls back to anOriginvsHostcheck. 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_cookiesremoves the admin session, the sticky cookie, and the theme/locale prefs from the upstream-boundCookieheader 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 neitherSameSite=Strictnor the same-origin CSRF guard can stop it from issuing credentialedfetch('/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.orgvsapps.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.idbefore honoring a sticky cookie, even though itsPath=/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 bylist(). 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 aContent-Security-Policy(default-src 'self'; … frame-ancestors 'none'; base-uri 'self'; form-action 'self'). - [implemented]
Securecookie flag under TLS — admin + sticky cookies setSecurewhenauth::request_is_https(readsX-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 dropsunsafe-inlineis the hardening follow-up.
8. Logging & observability
- [implemented] Default
tracinglevel (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.
9. Recommended production configuration
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_KEYexplicitly in prod — without it the sticky key is randomized per process, invalidating all sessions on restart. - Rotate
RUSCKER_ADMIN_TOKENif 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)
-
Securecookie 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-connectionin hop-by-hop strip (§6, #84) - WS pump backpressure: independent tasks + idle watchdog (§6, #81)
-
audit_log.diff_jsonverified to record metadata only — never a password/token/cookie (regression test indb::credentials) - Automated
cargo auditin CI (.github/workflows/security.yml, weekly + on dependency changes) - Nonce-based CSP, drop
unsafe-inline(§7) -
semgrepin CI (cargo-audit is wired; semgrep deferred)