diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index 4955976a..349c773a 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -179,11 +179,12 @@ stopping line that lies. | **R-332** | Storage & devices | P3 | **The new Hiba-from-counters path has never fired on real hardware.** v0.215.0's whole point is a verdict the product could not previously reach, and it is proven only against the committed fixture's values in unit tests (12 scenario groups, 11 of 12 red-proofs failing as required). The live validation on demo-hp proved the **negative** — three healthy disks still read Rendben across the deploy, no false alert — and the **severity wire** end to end, but no live disk has actually reached Hiba. **This is the honest gap and it must not be closed by pointing at the fixture tests**: the drive that produced the fixture is in DooPlex, which is Tier 2 and never a drill target, and the demo boxes are all-flash and healthy | **WATCHING — NEW 2026-08-14, NARROWED same day.** One item originally in this gap is now PROVEN LIVE: the **persisted state surviving a controller restart**. The v0.215.0→v0.216.0 redeploy destroyed and rebuilt the container, and the new one read back a `changed_at` written by the PREVIOUS version (`2026-08-14T07:23:14.640216851Z`, still intact at 09:31:35Z) instead of re-baselining — Scenario L on real hardware, not just the production-path unit test. **What remains unproven is the verdict itself, plus the stronger restart half: an already-ALERTED disk not re-alerting** | a real degrading disk, or an injection harness | **Closing condition:** a live disk reaching Hiba from counters, OR a deliberate injection through the REAL pipeline (agent `/disks` → controller check → hub event), not a hand-set verdict | CC | | **R-331** | Storage & devices | P4 | **Disk health Phase 3 — growth-rate detection, and retiring the static 64.** The v0.215.0 count backstop (64 unreadable sectors → Hiba) is **a judgement from ONE drive**: the observed benign excursion peaked at 16 and cleared inside an hour, and the terminal run passed 64 at 13 Aug 11:28 and never came back. It is deliberately a backstop BEHIND the sustain rule, not the primary signal, but it is still a magic number tuned on a single sample and it will be wrong for some drive. With Phase 2's history the box can ask the question that actually matters — *is this count climbing, and how fast* — which distinguishes a drive with eight stable aging sectors from one adding forty a day, something no static threshold can do. Revisit 64 when that exists | **READY (M) — NEW 2026-08-14** | R-330 | Growth-rate rule over persisted samples; re-derive or delete the static 64 | CC | -## Security & access — 10 rows (P1 1, P2 2, P3 4, P4 3) +## Security & access — 11 rows (P1 1, P2 2, P3 4, P4 4) | ID | Category | Sev | What | State | Blocked on | Next action | Owner | |---|---|---|---|---|---|---|---| | **R-954** | Security & access | P2 | **We cannot tell whether anyone used the old hub operator key (R-950) or the open hub read routes (R-951) before 2026-10-12; if someone did, they could have read every box's break-glass root credential and every customer's box report.** The exposure began in February 2026; the logs that could show it cover only 2026-10-12 from about 09:54 and one day in June, so about 238 days are not visible. Fix (operator decision): change the break-glass root password of every box (tester-1 holds a real person's data), or accept the risk; either way, keep ingress access logs long enough to answer this next time. | WAITING-ON-OPERATOR — filed 2026-10-12 by the security review; the log search was not completed in-session | operator decision | Decide: rotate every box's break-glass root password now, or accept | operator | +| **R-955** | Security & access | P4 | **The cross-box check of the hub API is done in source and tests but not fully proven: if a later check masks a missing id check, a box's key could read or change another box's hub record without any test noticing.** Read 2026-10-12: every box-facing `/api/v1` route takes the box from its own key or refuses a different id; 14 routes already had a refusal test, 5 got one (`crossbox_refusal_test.go`). Left: red-prove the 4 new tests that were not red-proved (as the restore-directive one was), and the live read-only check with one demo box's key against the other demo box's id. | OPEN — filed 2026-10-12 by the security review | — | Red-prove the 4 tests; run the live GET check on the two demo boxes | CC | | **R-924** | Security & access | P2 | **The operator signing keys exist only on DooPlex, with no passphrase and in no backup — a loss of DooPlex means no box ever accepts a signed agent update, config bundle or OS step again without an on-site re-enrollment.** MEASURED 2026-10-09: `/mnt/5_hdd/felhom.eu/felhom-op-operational`, `felhom-rec-recovery`, `felhom_op_ed25519` (0600 `kisfenyo`); `ssh-keygen -y -P ''` opens all three (no passphrase); `/mnt/5_hdd/felhom.eu` is in no backup set, the off-site job does not carry them. `04` §1 says the operational key is "passphrase-protected" and the recovery key "kept offline" — today neither holds (custody ruling 1 of 2026-09-16 put both on DooPlex for this phase; `04` §3.1). Both keys share one fate, so the cold key cannot do its one job (re-pin after losing the operational key). `runbooks/total-loss-of-dooplex.md` S6. | **NARROWED 2026-10-09 — operator ruling: option A** (print both AND copy them). The three key files and their public halves ride the nightly encrypted DooPlex→ep0 copy (`scripts/dooplex-offsite/`, a missing key refuses the push); the Sunday restore test proves each private key still derives its own public half (never printed). **LEFT:** the operator prints S6a/S6b (`runbooks/break-glass-sheet.md`); the keys still have no passphrase (custody ruling 1 of 2026-09-16 stands until the first sale). | operator: print | Operator: print the two keys; then close | operator | | **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 ""` to both expressions — a TWO-REPO change (controller + hub `RemoveGeoRules`). Same audit §5.1 | CC | diff --git a/hub/internal/api/crossbox_refusal_test.go b/hub/internal/api/crossbox_refusal_test.go new file mode 100644 index 00000000..f4374861 --- /dev/null +++ b/hub/internal/api/crossbox_refusal_test.go @@ -0,0 +1,84 @@ +package api + +import ( + "net/http" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// Security review 2026-10-12, Part 3 (R-955): every box-facing route that names a host or customer must refuse a +// key that belongs to a different box. Most routes already had such a test (TestEscrowGet_CrossHostRefused, +// TestGetDesiredState_SelfScoped, …); these five had none. Each asserts the refusal AND that the other box's +// record is unchanged where the route writes. Red-proof (2026-10-12): with handleGetRestoreDirective's host check +// disabled, TestCrossBox_RestoreDirectiveForOtherHostRefused failed (h1's key got 200); the other four were not +// red-proved one by one (R-955). + +func newTwoBoxHandler(t *testing.T) (*Handler, *store.Store) { + t.Helper() + h, st := newTwoCustomerHandler(t) // customers c1/ckey and c2/c2key + for _, hst := range []*store.Host{ + {HostID: "h1", CustomerID: "c1", APIKey: "h1key"}, + {HostID: "h2", CustomerID: "c2", APIKey: "h2key"}, + } { + if err := st.UpsertHost(hst); err != nil { + t.Fatalf("UpsertHost %s: %v", hst.HostID, err) + } + } + return h, st +} + +func refused(code int) bool { return code == http.StatusUnauthorized || code == http.StatusForbidden } + +func TestCrossBox_ReportForOtherCustomerRefused(t *testing.T) { + h, st := newTwoBoxHandler(t) + rr := do(h, http.MethodPost, "/report", "c2key", `{"customer_id":"c1","customer_name":"written by c2"}`) + if !refused(rr.Code) { + t.Fatalf("c2's key reporting as c1: status = %d, want 401/403", rr.Code) + } + if c, _ := st.GetCustomer("c1"); c != nil { + t.Fatalf("c1's report was stored from c2's key: %q", c.ReportJSON) + } +} + +func TestCrossBox_EventForOtherCustomerRefused(t *testing.T) { + h, st := newTwoBoxHandler(t) + rr := do(h, http.MethodPost, "/event", "c2key", eventBody("error", "test")) + if !refused(rr.Code) { + t.Fatalf("c2's key posting an event for c1: status = %d, want 401/403", rr.Code) + } + if evs, _ := st.GetRecentEvents("c1", 10); len(evs) != 0 { + t.Fatalf("c1 got %d event(s) from c2's key", len(evs)) + } +} + +func TestCrossBox_EscrowPutForOtherHostRefused(t *testing.T) { + h, _ := newTwoBoxHandler(t) + rr := do(h, http.MethodPut, "/hosts/h2/escrow", "h1key", `{}`) + if !refused(rr.Code) { + t.Fatalf("h1's key writing h2's escrow: status = %d, want 401/403", rr.Code) + } +} + +func TestCrossBox_RestoreDirectiveForOtherHostRefused(t *testing.T) { + h, _ := newTwoBoxHandler(t) + // Arm h2's recovery mode, so the "not in recovery mode" 403 cannot mask a missing host check. + if rr := do(h, http.MethodPut, "/admin/hosts/h2/recovery-mode", globalKey, `{"ttl_seconds":600}`); rr.Code != http.StatusOK { + t.Fatalf("arm h2 recovery mode: status = %d", rr.Code) + } + if rr := do(h, http.MethodGet, "/hosts/h2/restore-directive", "h2key", ""); rr.Code != http.StatusOK { + t.Fatalf("positive control: h2's own key: status = %d, want 200", rr.Code) + } + rr := do(h, http.MethodGet, "/hosts/h2/restore-directive", "h1key", "") + if !refused(rr.Code) { + t.Fatalf("h1's key reading h2's restore directive: status = %d, want 401/403", rr.Code) + } +} + +func TestCrossBox_OffsitePasswordForOtherCustomerRefused(t *testing.T) { + h, _ := newTwoBoxHandler(t) + rr := do(h, http.MethodPost, "/offsite/consume-password/c1", "c2key", `{}`) + if !refused(rr.Code) { + t.Fatalf("c2's key on c1's off-site password: status = %d, want 401/403", rr.Code) + } +}