# REPORT — a secret leaves the page source, and two refusals learn to say what to do (v0.207.0, 2026-08-08) **R-249, R-252, R-253 — implementation. R-201's record corrected, R-254 filed. Controller `v0.206.0` → `v0.207.0`, built, pushed and deployed to `demo-hp` guest 9201 and VM 325.** No hub change was needed. Baselines re-read on arrival and all four repos matched the task exactly (`felhom-controller 3d3b4496f377`, `felhom.eu 3f4fb3825f06`); highest register ID was **R-253**. --- ## 1. Scenario A — the raw response body, before and after **This is the deliverable that matters, and it was measured on a live box with a positive control in both directions.** A known passphrase was planted on VM 325 (a throwaway due for destruction the same hour) and the security settings page fetched exactly as the defect was originally found — with `curl`. | | v0.206.0 (the shipped defect) | v0.207.0 (the fix) | |---|---|---| | page bytes | 50 208 | 51 115 | | **positive control** — card renders (a passphrase IS stored) | **1** | **1** | | reveal control present | — | **2** | | **passphrase occurrences in the response body** | **1** | **0** | **The positive control is load-bearing.** The first attempt at this check returned `0` on a box that had no passphrase stored at all — a zero that meant nothing. That is why the card's presence is asserted first: *an empty listing is not evidence of emptiness.* **Scenario B, live:** `POST /settings/retrieval-password/reveal` → **200**, `Cache-Control: no-store`, body 85 bytes carrying the correct passphrase (**1** match against the planted value). The controller logged `retrieval passphrase revealed via the security page from 192.168.0.100 (value never logged)`, and a grep of five minutes of controller log for the value itself returned **0**. ### The red-proof — the most important one here Both halves of the fix were reverted (`data["RetrievalPassword"] = …` restored in `handlers.go`, and `{{.RetrievalPassword}}` put back inside a `display:none` span), **each mutation asserted to have applied** before running: ``` BOTH MUTATIONS APPLIED — verified by string change --- FAIL: TestSecurityPage_DoesNotContainTheRetrievalPassphrase (0.08s) R-249: the retrieval passphrase is in the response body of the security page … ``` Restored; residue checked (`{{.RetrievalPassword}}` occurrences: **0**); green again. **Why the tests assert the body and not a view:** the old markup hid the value with CSS. Every test that asked *what does the customer see* passed while the bytes carried the secret. A test that cannot see a `display:none` span cannot see this defect at all. --- ## 2. §7.1's census — the pattern found once, looked for everywhere **It was worth doing: two more instances, one with a real secret.** | Where | What | Verdict | |---|---|---| | **`app_info.html:185`** | an app's auto-generated first-login password in `` beside a „Megjelenítés" button | **the same defect with a real per-install secret** — `ReadInitialCredentials` reads it live out of the deployed container, so it is *not* the catalog's published `default_creds` | | `deploy.html:482` | an auto-generated `type: secret` field in `` | weaker — the **pre-deploy** form must post the value; on an **already-deployed** app's page the exposure is gratuitous | **Filed as R-254, not fixed** — scope was R-249/R-252/R-253, and each needs its own reveal endpoint and its own body-asserting test rather than a shared quick edit. **Recommended next**, because R-249 proved the pattern is not theoretical. **Rotation advice, left to the operator:** the `walk5` instance is compromised (it reached a transcript) and dies with that customer's teardown. **No other instance is known to have been read — but nothing records a read, which is itself part of the defect**, and that is the honest state of the evidence. If any customer page has been screen-shared, saved or proxied, treat that passphrase as exposed. --- ## 3. §7.2's decision — which half of the reinstall promise was wrong **The promise was wrong, and the reason is structural rather than a matter of effort.** `reconstituteOffbox` refuses when `GetStackHDDPath(stack)` is empty, because **the destination of a restore is the app's own data path — a drive the CUSTOMER chooses at deploy time.** An automatic reinstall would mean the product picking that drive for them, which is precisely the decision this recovery path exists to leave with them (`DOMAIN` and `SUBDOMAIN` would have to be invented too). So the smaller change is also the correct one: the copy now says to install first and routes there. The row's own code comment already read *"restore in place vs. reinstall first"* — the behaviour was right and the sentence had drifted. --- ## 4. Every changed Hungarian string, for review as copy **Removed** (`backups_restore.html`) — the promise the restore could not keep: > „Nincs telepítve — a visszaállítás előbb újratelepíti." **Added** (`backups_restore.html`, the not-installed row): > „Nincs telepítve — előbb **telepítsd újra**, utána hozhatod vissza az adatait." *(„telepítsd újra" > links to `/stacks//deploy`)* **Added** (`backups_restore.html`, the precondition notice — renders only when no drive is registered): > „**Előbb csatold vissza az adatmeghajtót.** A mentéseid megvannak, és a meghajtók is megvannak — > újratelepítés után viszont a gép még nem ismeri őket, ezért most nincs hová visszaállítani. Ez két > kattintás: **Tárhely → Meghajtók**, „Meglévő meghajtó csatolása". Utána gyere vissza ide." **Changed** (`offbox_restore.go`, the drive refusal): > was: „nincs elérhető adatmeghajtó a visszaállításhoz" > now: „nincs regisztrált adatmeghajtó, ezért nincs hová visszaállítani — a meghajtók megvannak, csak > újra kell csatolni őket a Tárhely → Meghajtók oldalon, utána ez a visszaállítás működni fog" **Changed** (`offbox_reconstitute.go`, the not-installed refusal): > was: „a(z) %s nincs telepítve — előbb állítsd helyre az alkalmazást, utána az adatokat" > now: „a(z) %s nincs telepítve, ezért nincs hová visszaállítani az adatait — telepítsd újra az > alkalmazást (Alkalmazások), utána ez a visszaállítás működni fog" **Added** (`settings_security.html`, reveal failure): „A visszaállítási jelszó lekérése nem sikerült." **Added** (reveal endpoint, nothing stored): „Ezen a gépen nincs tárolt visszaállítási jelszó." --- ## 5. Tests and red-proofs — each mutation asserted to have applied | Test | Scenario | Result | Red-proof (mutation → observed) | |---|---|---|---| | `TestSecurityPage_DoesNotContainTheRetrievalPassphrase` | **A** | PASS | value re-rendered into the page → **FAIL**, printing the exposure | | `TestSecurityPage_NoCardWhenNoPassphraseStored` | A | PASS | — (gate is on existence, not the value) | | `TestRevealEndpoint_ReturnsThePassphraseToAnAuthenticatedCaller` | **B** | PASS | remove the route/handler → customer cannot obtain it at all | | `TestRevealEndpoint_404sWhenNothingStored` | B | PASS | — | | `TestRestorePage_NoRegisteredDrive_NamesTheReasonAndTheRoute` | **C** | PASS | precondition block deleted → **FAIL** | | `TestRestorePage_HealthyBox_HasNoPreconditionNotice` | **E** | PASS | notice made unconditional (`{{if true}}`) → **FAIL** | | `TestRestorePage_NotInstalled_DoesNotPromiseAReinstall` | **D** | PASS | old promise restored → **FAIL on all three assertions** | | `TestHasRestoreDestination_AgreesWithTheResolver` | seam | PASS | predicate forced `true` → **FAIL** | **Red-proof D caught a fault in my own work.** On its first run only two of its three assertions failed, because **the explanatory HTML comment I had written quoted the old sentence — and HTML comments ship in the response body.** The contradiction was still on the page, and the assertion that forbids it could never fail. The comment was rewritten to describe the old string without quoting it; re-run, all three assertions fail under the mutation. *This is the same class as the defect being fixed, and it was found only because the red-proof was actually run.* **One pre-existing test was updated, not deleted:** `TestOffsiteRestoreList_RenderedForRebuiltBox` pinned the old sentence. Its intent — *state the not-installed consequence, do not hide it* — is unchanged; it now asserts the consequence **and** the route. **Green gate:** `go build ./... && go vet ./... && go test ./...` → **rc 0**. **`controller_gates.py`:** all nine **OK**. --- ## 6. §7.5 — the currency gate went red, as predicted ``` newest released controller : 0.207.0 newest golden baked : 0.206.0 GOLDEN CURRENCY GATE FAILED ``` **The `felhom-controller` push needed no bypass** — that repo's own gates passed. **The `felhom.eu` push used `git push --no-verify`**, declared in that commit message, in this report, and in **R-242's row**. **A bypass and NOT a waiver, deliberately.** The gate offers a waiver for a release that *deliberately needs no golden*; this release needs one. Recording a waiver would have made the register say something untrue. **Owed: bake golden 0.207.0 and vouch it.** --- ## 7. Part 3 — the record **R-201's state field read `PASSED + PROVEN-LIVE 2026-08-04` — the *data* half, from the drill** — while the row body carried the fifth walk's journey pass. Corrected to **`BOTH HALVES PASSED — DATA 2026-08-04, JOURNEY 2026-08-07`**, with what it does **not** claim stated in the field itself: the journey is not smooth, and the discriminator's positive half is unproven. **The census asked for: a sweep of all 146 register rows found NO OTHER state field disagreeing with its own body.** Two candidates (R-229, R-230) were **false positives** — per-*leg* closes on rows that legitimately remain open. **So the pattern is real but not currently widespread**: R-218 on 2026-08-06, R-201 now, nothing else outstanding. **The mechanism is worth naming, because it will recur:** a row states status in *two* places — a bold phrase early in the What column and the State cell at the end — and a session that closes an item updates the body and the end cell, while the early phrase, **which is what a reader sees first**, goes stale. --- ## 8. Live validation — what was proven on a box, and what was not | # | Check | Result | |---|---|---| | 1 | `curl` the security page, passphrase absent | **PASS, live, with before/after** (§1) | | 2 | the reveal works for a customer who asks | **PASS, live** — 200, `no-store`, correct value, logged | | 3 | the two refusals rendered on VM 325 | **NOT PROVEN LIVE — stated plainly** (below) | | 4 | a healthy box's restore page unchanged | **PASS, live** — no notice, no not-installed hint | **Check 3 is the honest gap.** R-252's state is **rebuild-only**: the customer API **refuses to deregister the last usable drive** („ez az egyetlen használható tárhely — a leszerelés megtagadva"), and when `storage_paths` was emptied directly the controller re-registered one on restart, so `HasRestoreDestination()` was correctly `true` and the notice correctly did not render — **which is Scenario E passing live, not a failure.** R-253 likewise: after removing the app the controller still reports `deployed=true` (state `stopped`), so the row's `Installed` stays true. **Both notices are therefore pinned at the template + predicate level** — by tests that drive the real template and the real predicate, each with a demonstrated red-proof — **and not by a live render.** I would rather say that than dress the template test up as a live one. --- ## 9. Files, commits, deployed version **`felhom-controller` — `8dbbc98`** (v0.207.0): `internal/web/handlers.go` · `internal/web/server.go` · `internal/web/templates/settings_security.html` · `internal/web/templates/backups_restore.html` · `internal/backup/offbox_restore.go` · `internal/backup/offbox_reconstitute.go` · `internal/web/offsite_restore_list_test.go` · **new:** `internal/web/retrieval_password_exposure_test.go`, `internal/web/restore_preconditions_test.go`, `internal/backup/restore_destination_test.go` · `CHANGELOG.md` · `CONTEXT.md` · `REUSE.md` · `controller/README.md` **`felhom.eu` — `1fc3876`** (pushed `--no-verify`, declared): `documentation/backlog/OPEN-ITEMS.md` (R-249/R-252/R-253 closed, **R-254** filed, R-201's state field corrected, R-242 updated) · `STATUS.md` (93 lines). **Deployed:** `gitea.dooplex.hu/admin/felhom-controller:0.207.0` — healthy on **demo-hp guest 9201** and on **VM 325**. **Register:** highest ID moved **R-253 → R-254**. --- ## 10. What remains open - **R-254** — the two remaining render-then-hide instances. **The recommended next item.** - **R-242** — the bake + vouch of golden 0.207.0 this release now needs; and R-242's own half, that nothing gates the *vouch*. - **The discriminator's positive half** — the fifth walk exercised R-241's mint guard positively and the fingerprint comparison only negatively. **Proving the positive half needs a deliberate fixture (a box holding a divergent key), not a walk** — and v0.206.0's guard now prevents that state arising by itself, which is exactly why it needs constructing. - Untouched by design: **R-250, R-251, R-243, R-244, R-240, R-213, R-202, R-214, R-247, R-248.** ## 11. Observations — noticed, not acted on - **`shares_restore.go:62,81` carries the same „nincs elérhető adatmeghajtó" string** with no route, for the SMB-shares restore path. Out of scope (R-252 is the app path); the same one-line treatment would fix it. - **The reveal endpoint has no rate limit.** It is behind session auth + CSRF, so it is not a brute-force surface, but the escrow start handler deliberately rides the login limiter and this does not. Deliberate omission, flagged. - **The Hetzner token in `~/.config/credentials` cannot see storage box 611421** — `GET /v1/storage_boxes/611421/subaccounts` → 404 and `GET /v1/storage_boxes` → 200 with **0 entries**. It is scoped to a different project than the hub's. Recorded so the next teardown does not re-derive it.