Files
felhom.eu/documentation/architecture/13-threat-model.md
T
admin d62cc08b2e
gates / gates (push) Successful in 6m44s
hub v0.152.0: operator access tokens with scopes (09 §3 decision 251, operator 2026-10-15 11:38)
- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once,
  stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it.
- Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only:
  secrets shown/minted, deletes/resets, password, tokens.
- R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test.
- Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open.
- 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS.
- website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push.
Not deployed — waits for the operator's yes.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-15 12:20:35 +02:00

8.9 KiB

Architecture Part 13 — Threat model

Status: draft (security review 2026-10-12, audits/security-review-2026-10-12/). Marks as in 01: [DESIGN] a decision taken; [FACT] an observation with a citation. Unmarked = not yet classified. Findings named here as R-… are register rows; for open ones the detail is held off-git.

1. Starting fact

[FACT] The controller, agent, felhom.eu and catalog repositories are readable by anyone without a login (measured 2026-10-12; homelab-manifests is private). Assume the attacker has read all product source. A guard that depends on the code being secret is not a guard here. Whether the repositories stay public is the operator's decision (R-925).

2. Assets, most harmful loss first

  1. Household app data — photos, documents, passwords in the household's apps (the customer guest, its data drive, and the backups).
  2. Household logins — the dashboard password, family members, each app's admin.
  3. Box credentials — the box's hub API key, the local-API token, the tunnel token, the agent's Proxmox token, the vaulted break-glass root credential.
  4. Hub data — customer records and reports, escrowed (sealed) backup keys, Cloudflare tokens, the global key.
  5. Operator signing keys — they decide what a box accepts (R-924).
  6. Off-site backups and their keys — ciphertext on ep0 and the Storage Box.
  7. Gitea, the registry and the installer — they decide what code runs on every box (R-971, R-979, R-980).

3. Attackers and what they start with

attacker starts with
a a stranger on the internet the public source; the tunnel addresses of every app and dashboard; the hub's public name (from an allowed country)
b someone on the household LAN the box's LAN address; the host's and the guest's LAN ports (measured: SSH, the Proxmox UI, DNS, rpcbind)
c a household member with a family account a family session
d a bad catalog app (upstream image turned bad) code in one container on the guest's shared Docker daemon
e a compromised controller root in the guest, the docker socket, the local-API token, the box's hub key
f a compromised hub (or a copy of its database) every box's hub key, sealed escrow blobs, Cloudflare tokens
g a compromised Gitea / registry / build push to the catalog, the images and the installer tag
h a thief with the box the disks
i Cloudflare the plaintext of tunnel traffic — accepted (R-904)

4. Entry points

How enumerated (re-runnable): none of the three uses a router library; each registers a few ServeMux patterns and dispatches in switch blocks, so the cases were parsed from source, and auth was read by hand per handler. The scripts are held with the review's working notes.

component routes no credential listeners
controller 231 (+8 in setup mode) 21, of which 7 change state, each behind its own password, code or token port 8080 on the guest's Docker networks (not published); mail relay 2525/2465/2526
hub 121 (1 health, 53 API, 67 web — v0.152.0 added the two token routes; both route tables are now pinned to the source by tests, R-952) appliance register (rate-limited, by design), re-enroll (only while the operator has armed recovery mode), login, health one ingress, path /, geo filter (HU, IE, LAN)
agent 38 local-API routes (POST /host/poweroff added 2026-10-13, R-998) 0 local API on the island bridge; WireGuard; poke (tunnel only); felhom-sshd (tunnel only, measured)

Outbound fetches that bring code or config onto a box: the catalog (git, main), app images, the four infra images, the controller image (by tag), controller.yaml from the hub, the agent binary and config bundle (signed), OS packages (exact versions, apt-signed), the installer script (by tag, R-980).

5. The trust table of 01 §5, checked

boundary verdict evidence
end-user ↔ apps holds, with gaps traefik routing and header stripping hold; homepage has the Docker socket and no gate (R-981)
added 2026-10-14 (R-1033, P1): a visitor ↔ Cloudflare over plain http holds on boxes with controller ≥ 0.312.0 before: every public name answered http:// (login pages too), and Sonarr/Radarr/Prowlarr redirected https visitors to an absolute http:// login; a password could cross an untrusted network unencrypted (attacker: anyone on the visitor's network). Now traefik sends every http:// request to https (301) before an app sees it, HSTS one day; boxes below 0.312.0 (Tester 2 at 0.292.0) keep the gap until the floor delivers it. audits/https-only-2026-10-14/
added 2026-10-15 (R-1045): a stranger ↔ a fresh Plex holds on controller ≥ 0.313.0; on older ones only while the household does not press „Kész" before claiming an unclaimed Plex answers everything to a private address with no token, and through the tunnel every request comes from one. The setup gate (catalog 9a23ee5) closes the public path; 0.313.0 opens it only when Plex itself says claimed. A status probe meant to cover older controllers OPENED an unclaimed Plex while it started (audits/media-build-2026-10-15/G7/) and was removed
customer ↔ controller UI partly bcrypt and session hold; small gaps (R-983); family sessions survive a reset (R-976)
controller ↔ agent partly every route bound to the caller's guest; a compromised controller can still flush restore points (R-973), void the recovery code (R-978), and — since agent v0.155.0 — switch the whole machine off once per power-on, holding the hub's silence alarms for up to 14 days (R-998; mitigations and residual risk: 14-host-power-off.md §5)
added (R-1007): controller → an app's own job queue holds — nothing new the after_unclean_boot commands run inside the app's container through the docker socket the controller already holds, and read the app's database with a read-only session: no key into the household's account, nothing a compromised controller could not already do (exec in any app, read its database). The household's lost-file names stay on the box (app.yaml, the app page); the operator event carries counts only, and a failing command's output is never logged (security review of controller 8d862e4, fixed in 3b85a0c)
added: agent ↔ root on the host holds by the sudoers every root verb is one regex-anchored line or a root wrapper that re-checks its input (03 §3); agent v0.156.1 adds one delete: felhom-vzdump-partial-clean removes only a DEAD half-written vzdump of the named guest on a dir storage (no vzdump on the host, nothing holding it open) — a compromised agent gains the deletion of a leftover that is not a backup
agent ↔ hub holds for destructive ops signed jobs verified in the documented order; unsigned spec has no size bounds (R-984)
added 2026-10-15: operator script ↔ hub UI holds from hub v0.152.0 scripts used the operator password, which can do everything; now a scoped, expiring, hashed token (05 §17); a token never reaches a secret, a delete or /api/v1. Open: a customers:write token can redirect a claim code (R-1055); the global API key is still one all-powerful bearer (R-1054)
controller ↔ hub did not hold until 2026-10-12 the global key was public (R-950, rotated) and three routes needed no key (R-951, fixed); per-box keys are bound to their box (R-955)
box ↔ PBS holds as written ciphertext only; the zero-knowledge wording is stronger than a live box allows (R-985)
guest ↔ Proxmox host holds the guest has no Proxmox credentials (measured)
hub ↔ Cloudflare API holds for the zone check tokens are stored unsealed in the hub database (R-975); R-137 open
added: app ↔ app (shared Docker daemon) partly separate containers and networks; any docker-socket app is root in the guest (R-981)
added: app ↔ controller partly apps share a network with the controller; dashboard routes still need a session
added: LAN ↔ box partly root password SSH on the host from the LAN (R-982)
added: hub ↔ box (inbound) does not hold 01 §4 says the box exposes no control endpoint; a hub-key route can replace the box's settings (R-970)
added: catalog ↔ every box design gap a catalog push reaches every box with no check on the box (R-971)

6. What we accept (do not re-report)

  • Cloudflare can read tunnel traffic — R-904, 09 §3 decision 184.
  • Signing keys on DooPlex with no passphrase until the first sale — R-924, 04 §3.1 (operator ruling 1).
  • Public source — the operator's decision (R-925).
  • A pasted Cloudflare token is checked for its zone only; the recipe guards the write side — 09 §3 decision 226.
  • Per-name family sign-in lock — 01 §5 (a stranger can lock one member out for 10 minutes; same shape as R-775).