D6 / R-138: every stored Cloudflare token checked once (operator present), all 4 PASS; R-138 closed
gates / gates (push) Successful in 4m35s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-09 09:00:05 +02:00
parent 53ec983849
commit ff27a107c8
5 changed files with 14 additions and 5 deletions
@@ -0,0 +1,7 @@
== D6 / R-138 2026-10-09T06:58:26Z: the hub's own check (GET /zones?per_page=50, exactly one zone and it IS the customer's domain), run READ-ONLY against every STORED token; nothing saved. Tokens never printed.
Tester-2 domain=sajatfelhom.hu zones=1 ['sajatfelhom.hu'] -> PASS
demo-felhom domain=demo-felhom.eu zones=1 ['demo-felhom.eu'] -> PASS
demo-hp domain=enkisfelhom.hu zones=1 ['enkisfelhom.hu'] -> PASS
tester-1 domain=enkicsifelhom.hu zones=1 ['enkicsifelhom.hu'] -> PASS
negative control: a made-up token -> HTTP 403 -> would be COULD NOT CHECK / refused (the check can fail)
hub DB copies (they hold the tokens) deleted from the scratchpad 2026-10-09T06:58:37Z
+1
View File
@@ -37,6 +37,7 @@ The full text of every row below: `git show b9073e8fb6:documentation/backlog/OPE
| **R-79** | **`report.Issues` / `report.Warnings` are English on customer-facing surfaces** (P3) | CLOSED 2026-10-09 — DELIVERED and proven live (hub 0.144.0 + controller 0.304.0): a `health_critical` on the Tester 1 box (a protected container stopped) → the household mail in the catch-all has no `{` and ends „A részleteket a vezérlőpultodon látod."; the operator mail still carries the details. Dashboard half on 9202: the health banners read English for `lang=en` and Hungarian for `lang=hu`. | `audits/release-2026-10-09/proofs/`D3/ |
| **R-30** | **[P2-HIGH] Liveness presence should come from the wait channel, not the report clock.** (P3) | CLOSED 2026-10-09 — DELIVERED and measured live (hub 0.144.0): the whole Tester 1 machine shut down 05:53:27Z; the host page read „last connected 05:51 UTC" at +114 s; the „box is off" tick opened at 05:57:42Z (6 min after the last call, as designed); control from the ingress log: Tester 1's guest (192.168.0.101) made its last `/api/v1/wait` at 05:51:41Z while the other boxes kept calling. A stopped GUEST on a live host is started again by the agent in 36 s (guest-power watchdog), so only a switched-off box opens the tick. demo-hp was never tick-deleted. | `audits/release-2026-10-09/proofs/`D2/ |
| **R-901** | **Two kinds of a household's data outlive the deletion of the customer, and no document says when they go.** (P2) | CLOSED 2026-10-09 — both halves LIVE: the hub's 1-year deletion of a deleted customer's audit rows ships in hub 0.144.0 (daily prune; nothing is a year old yet); the DooPlex `ep0-copy` job installed with the operator present — key root-only, dry run, then the 08:00 timer; the first dry run found a parser defect (ep0 answers `{"data": [...]}`) that the mass-absence guard stopped (nothing recorded, nothing deleted), fixed `69fa9cf7`, red-proved; the first real run read 5 of 5 namespaces, deleted 0. Both times are for the privacy-notice draft (R-813). | `audits/release-2026-10-09/ep0-copy-gc/` |
| **R-138** | **A shared-zone `cf_api_token` is a zone-wide DNS-write capability on a customer's box** (P3) | CLOSED 2026-10-09 — DELIVERED (hub 0.144.0) and every stored token checked once, operator present: the hub's own question (`GET /zones`, exactly one zone and it IS the domain) run read-only against the 4 stored tokens — Tester-2, demo-felhom, demo-hp, tester-1 each see 1 zone, their own domain: PASS; control: a made-up token is refused (HTTP 403). A re-save with the SAME token would not have checked it (the edit path checks only a new token or a new domain, by design), so the check ran outside the save. Full row text: `git show 53ec983849:documentation/backlog/OPEN-ITEMS.md`. R-913 keeps the read-scope limit. | `audits/release-2026-10-09/proofs/D6/` |
## 2026-10-08 (evening) — the operator's decision sheet D1–D10
-1
View File
@@ -198,7 +198,6 @@ stopping line that lies.
| **R-861** | Security & access | P2 | **The agent's sudoers lets the agent user reach root without the operator key, so "root-minimized" (`03` §3) overstates it and the root-owned trust files (decision 93, the bundle's R17) are defence in depth, not a boundary.** READ 2026-10-04 from `felhom-agent/configs/felhom-agent.sudoers` (not exploited): `FELHOM_GUESTHOOK` installs `/tmp/felhom-guest-hook-*.sh` as a hookscript Proxmox runs as root at guest start, and `pct reboot` is granted; `FELHOM_INTERMEDIARY` installs a script + a systemd unit that run as root at boot; `FELHOM_ESCROW` runs `/usr/local/bin/felhom-agent` as root, and `FELHOM_SELFUPDATE apply` accepts a sha the agent itself passes. A compromised agent PROCESS is therefore root on its host. Fix direction: each of the four becomes a root-owned wrapper that checks its own input (fixed content or a signature), like `felhom-os-apply`; delivered by the config bundle. `11` §5.4.2, `03` §11. | **NARROWED 2026-10-05 — FIXED agent v0.146.1 for every root path found (nine, not four), delivered to demo-hp, demo-felhom and Tester 1 by a step bundle (R-880); live on both demo boxes: `sudo -l` 93/93 (64 commands allowed, 29 attacks refused — 23 of them allowed before), capability probe 67/67, a staged unit over /etc/sudoers.d refused. Design `03` §3.1, decision 122. LEFT, each named there: (a) the controller-swap image ref is guest-scoped (a compromised agent can run a chosen pinned-registry image in the guest); (b) the felhom-op SSH key is hub-delivered, not signed (felhom-op's sudo is scoped, not root); (c) the escrow ceremony hands the agent R by design (the box's PBS key). Tester 2: not delivered (offline).** **2026-10-06 night: design written** (`audits/night-burndown-2026-10-06/design-R-861.md`). Correction: (a) is not "pinned-registry" — the `tee` content is unchecked by sudo, so any image from any registry runs in the guest with the docker socket (`03` §3.1 corrected). Pick: (a) close before the first paying customer (a `felhom-priv-apply controller-image` verb; ~1–2 h, rides the bundle); (b) and (c) accept for the first customers. Waits for the operator. **2026-10-07 07:58: `09` §3 decision 165 — (a) A1 yes before the first paying customer; (b) B3 accept + B2 hygiene in the same bundle; (c) C2 accept.** **2026-10-07: (a) A1 and (b) B2 DELIVERED (agent 0.151.0 + bundle on demo-hp, demo-felhom, Tester 1; probe 68/68).** Live on demo-hp: no `tee` grant left in `sudo -l -U felhom-agent`; the verb `felhom-priv-apply ^controller-image [0-9]+$` is the route; a hand-fed `docker.io/library/alpine:latest` → `REFUSED [I1]` rc 3, the guest's image file unchanged; the old `pct exec … tee` asks for a password; felhom-op's pct lines anchored (`audits/day-2026-10-07/C/C-live-demo-hp.txt`). **LEFT:** one managed controller swap seen through the verb — no newer controller existed today; the next controller release shows it. (c) accepted (decision 165). | — | the operator decides whether (a)–(c) are accepted or need work before the first paying customer | CC |
| **R-132** | Security & access | P3 | **`curl -w '%{redirect_url}'` reconstructs the request URL WITH its basic-auth credential** — so a `-u ":$HUB_PW"` call that never put the password in a URL still printed it **Merged 2026-10-05 from R-350 (duplicate):** (1) 2026-08-20 occurrence: POST /configuration/artifacts answers 303; leak lives only in the CC transcript under ~/.claude/projects/ on DooPlex, not in git/evidence (checked then). (2) `-v` and `--libcurl` also re-render the credential, not only %{redirect_url}; confirm redirects with %{http_code} + follow-up GET. (3) Rotation path: hub /configuration form (current_password/new_password/confirm_password); DB override wins over ConfigMap (break-glass); CC can rotate file-to-file without printing (operator-present-one-time-secrets) if asked. | **WAITING-ON-OPERATOR** — **ACTION: rotate `HUB_PW`** **Folded R-580 2026-10-03** (the same `curl -w %{redirect_url}` credential echo, seen again 2026-09-18). | — | Happened on 2026-07-31 while red-proofing the R-120 gate: the hub operator password was written to the session transcript by the write-out format, not by the request. `-u` is safe; the *reporting* was not. Rule: read the redirect from `-D -` and grep `^Location:`, never `%{redirect_url}`, on any authenticated call. Rotate the hub password (`/configuration` → Login password; ConfigMap `auth.password_hash` is the reset path) and update `~/.config/credentials` | Viktor |
| **R-137** | Security & access | P3 | **Cloudflare geo-WAF rules are zone-scoped and non-namespaced — four cross-tenant faults.** `globalRuleDesc = "[felhom-geo] Global"` (`waf.go:18`) is one literal description per ZONE; `appRuleDescPrefix` keys by app name with no customer (`waf.go:21`); `BuildGlobalExpression` has no positive hostname scoping (`waf.go:241`); `applyDiff` deletes every `[felhom-geo]` rule not in THIS box's desired set (`geosync.go:320`) | READY (M) — **blocks shared-zone onboarding** | — | With two customers in one zone: they overwrite each other's Global rule forever; one customer's country policy applies zone-wide; per-app rules collide by name; and disabling the feature for one (or the hub's `RemoveGeoRules`) wipes them all. Interim mitigation, no code: keep geo-restriction OFF for every shared-zone customer. Fix = namespace descriptions by `customer_id` + add `http.host ends_with "<domain>"` to both expressions — a TWO-REPO change (controller + hub `RemoveGeoRules`). Same audit §5.1 | CC |
| **R-138** | Security & access | P3 | **A shared-zone `cf_api_token` is a zone-wide DNS-write capability on a customer's box** — written 0600 to `/opt/docker/stacks/traefik/.env` (`controller/internal/infra/infra.go:123`) **-- 2026-10-08 design:** `audits/day-2026-10-08/design-R-138.md` — the shared-zone premise is gone (own domain per customer, `01` §7), but nothing enforces it and an account-wide token has the same reach; option B (hub refuses a duplicate or nested domain) needs no decision and also covers R-415. Question D6 on STATUS's decision sheet. **-- 2026-10-08 (afternoon):** option B BUILT on hub main (the duplicate/nested/felhom.eu domain guard, R-415 closed with it). Left: option C (check the key's reach with Cloudflare) — question D6. **-- 2026-10-08 14:16 operator ruling D6 (`09` §3 decision 190):** yes — option C, the hub checks with Cloudflare that a pasted key reaches only the customer's own zone. | **VERIFY — 2026-10-09: DELIVERED in hub 0.144.0. Still VERIFY: each stored customer token must be re-saved once with the operator present (not done today).** **VERIFY — built on hub main 2026-10-08 (D6, decision 190), ships tomorrow; closes after each stored customer token is checked once (tokens saved before the check were never checked); R-913 holds the read-scope limit.** READY (S) **2026-10-05 (burn-down night): NEEDS A DESIGN.** No notion of a „shared zone” exists anywhere; the token is typed into the hub form, so the guard belongs on the hub side with that notion defined. | — | Today each box holds a token for a zone nobody else uses, so the blast radius is one customer. Under a shared customer zone, one compromised tester box could repoint every other tester's DNS. The ACME path is already switchable — an empty token selects HTTP-01 (`traefik.yml.tmpl`) — so the fix is policy plus a guard that refuses to hand a shared-zone customer a zone-scoped token. Same audit §5.2 | CC |
| **R-255** | Security & access | P3 | **The check that would catch a fourth secret-in-the-body covers 4 of 27 pages, and the cheap gate that covers all 36 templates is blind to the shape that actually shipped.** Filed 2026-08-08 while closing R-254, **because a partial guard reported as complete is worse than no guard — it stops the next person looking.** **Two nets, both measured.** **(1) `scripts/secret_in_markup_gate.py`** reads all 36 templates and convicts any `{{ … }}` naming a secret unless allowlisted with a reason. It catches `{{.RetrievalPassword}}` and `{{.InitialCreds.Password}}`, **and it catches a launder through a local variable** because the assignment itself names the secret (`{{$v := .InitialCreds.Password}}` is convicted — verified). **It is blind to a secret arriving under a NEUTRAL PAGE-DATA KEY** — `data["Tagline"] = creds.Password` then `{{.AppInfo.Tagline}}` passes it cleanly, also verified. **That is exactly the shape of R-254 site two** (`value="{{$val}}"` inside an `{{if eq .Type "secret"}}` branch), so the gate **would not have caught one of the three instances it was written for.** **(2) The runtime body assertion** — render the page and grep the response for a sentinel — catches every shape, including that one (demonstrated on the same planted leak the gate missed). But it needs each page's data to be constructible in a test, and **only 4 of 27 page templates have that today**: `settings_security`, `app_info`, `deploy`, `backups_restore` — the four that were touched by R-249/R-252/R-253/R-254 and therefore got their own tests. **The other 23 pages have no runtime coverage at all.** **What closing this needs, so the cost is not re-estimated:** a per-page data fixture for the remaining 23 (most need a wired `Server` — `stackMgr`, `backupMgr`, agent seams), then one table-driven test that renders each with a sentinel substituted for every string in its data and asserts the sentinel is absent. **That is real scaffolding, which is why it was NOT built inside R-254's session** rather than half-built and declared done. | **READY** — owner Viktor | — | — | operator |
| **R-338** | Security & access | P3 | **`demo-hp` is not on the R-50 island at all, and `operations/nodes.md` states that it is.** The page records both fleet boxes as island-migrated 2026-07-25. True of `felhom-pve`; **false of `demo-hp`**, whose `agent.json` has `listen_addr: 192.168.0.87:8443` — the customer LAN address — and **no `island_bridge`/`island_guest_addr` keys at all**, whose guest 9201 has `net0` only (no `eth1`), and whose `vmbr9` exists with **zero members**. The controller's `controller.yaml` points at the LAN address, so the box works; this is inventory drift, not breakage. **Two costs.** A session trusting the page addresses the wrong endpoint — that happened on 2026-08-18 and the resulting timeout was briefly read as a fault. And the agent's local API is **bound to the customer LAN on this box** rather than to a point-to-point island, which is the exposure R-50 was built to remove — so a documented security property is claimed for a box that does not have it **Checked from source 2026-10-05 (burn-down round 2):** nodes.md:86-88 still claims demo-hp is on the R-50 island (`local_api` on 169.254.253.1:8443/vmbr9, guest eth1). git blame: that claim dates from e6b5fa1e (2026-07-30); the 2026-09-21 edit bcdd5b20 re-read addresses but only reworded the lan_resolver clause -- the island claim was NOT re-verified after the reprovision. Agent config path /etc/felhom-agent/agent.json (felhom-agent cmd/felhom-agent/main.go:171), island keys island_bridge (internal/config/config.go:246). | **READY (S) — NEW 2026-08-18** | — | Decide which is true: migrate `demo-hp` to the island, or correct `nodes.md`. Leaving both is the one option that keeps the doc lying | Viktor decides; CC executes |
| **R-616** | Security & access | P3 | **[P3-LOW] The catalog credentials are stored in PLAINTEXT in the box's catalog clone and are printed by an ordinary `git remote -v`.** FOUND 2026-09-21 on guest 9202 while pointing it at a private drill catalog. `Syncer.buildRepoURL` injects `username:token` into the HTTPS URL, and `git clone` persists that URL as the clone's `origin`, so `<data>/catalog-cache/.git/config` holds the token in the clear and **any** diagnostic that prints the remote leaks it — which is what happened in this session's own transcript, and is the same shape as R-580 (`curl -w '%{redirect_url}'`). `maskRepoURL` exists and is used for the LOG lines, so the masking intent is already there; the stored remote is the half that was missed. **INERT ON THE FLEET TODAY** — the live catalog is public and `git.token` is empty on every real box — which is exactly why it should be fixed before it is not: the day the catalog goes private, every box carries a readable credential and every support session that runs `git remote -v` prints it. **Fix shape:** store the remote WITHOUT credentials and supply them per-fetch (a credential helper, `http.extraHeader`, or `GIT_ASKPASS`), and a test asserting the clone's stored `origin` contains no `@`. **Operator action from tonight, unrelated to the fix:** the Gitea `admin` token used for the drill repo was printed by that command and must be rotated. Evidence: `audits/update-night-2026-09-21/05-9202-follows-drill.txt` (redacted). | **READY — rank P3-LOW; owner: CC (controller); one operator action (rotate the Gitea admin token)** **2026-10-05 (burn-down night): FIXED on controller `main`** (`28a5203`; the catalog clone stores no credentials; the token is supplied per fetch; R-615's repo comparison ignores credentials on both sides, so a token never re-clones (`TestR616_TokenSetSameRepoNoRecloneOriginClean`) and a credentialed origin is cleaned at the next pull. The operator's Gitea admin token rotation (the row's second half) is still owed). Ships with the next controller release; close after delivery. **2026-10-06: DELIVERED** in controller v0.298.0 (the clone stores no credentials). Left: the operator's Gitea admin token rotation. | — | — | CC + operator |