R-294/R-295: orphan card stops promising restorability; one name per secret
gates / gates (push) Successful in 14s

The orphan card told a customer their set-aside off-site history may be
restorable later with their recovery code. The discriminator lives on the hub
and no wire field carries it, so the box rendering that card cannot evaluate the
promise. Copy replaced per the spec: state what happens, decline what we cannot
know and say why, name a route.

The claim page called the same three-word dashboard code two different names
depending on branch, one of which collides with the ten-word escrow code.
Retired 'Visszaallito kod'; the name is now constant and the sentence changes.
Naming only - a test pins that a reset code is still accepted.

secret_in_markup_gate no longer convicts Go template comments, which are
stripped before render; still convicts a real rendered secret.
This commit is contained in:
2026-08-10 13:53:21 +02:00
parent b762a37097
commit 86a78c6767
7 changed files with 276 additions and 7 deletions
+33 -1
View File
@@ -1,4 +1,36 @@
## v0.211.0 — the wall a rebuilt box could not get past (2026-08-10, R-280) — MinAgent 0.127.0 ## v0.211.0 — the wall a rebuilt box could not get past (2026-08-10, R-280 / R-294 / R-295) — MinAgent 0.127.0
**R-294 / R-202 — the orphan card stops promising what it cannot know.** The card told a customer,
at the moment they had just lost their off-site history, that the old copies *„a hozzá tartozó
helyreállítási kóddal később visszaállítható lehet"*. The discriminator is
`host_escrow_superseded.identity_blob` and it lives on the **hub**; the box caches only
`HubEscrowIdentityPresent` (the CURRENT escrow) and no report or ACK field carries superseded-blob
retention. **The renderer could not evaluate the condition it was stating**, and for everything set
aside before hub v0.93.0 (2026-08-04 ~11:11Z) it is false and unfixable. Copy replaced verbatim from
`documentation/design/SPEC-orphan-card-copy-2026-08-10.md` §4: it states what happens, declines the
claim it cannot evaluate and says why, and names a route (write to us). Four render tests, per branch
of the gate.
**SPEC DEFECT FOUND AND NOT ACTED ON — `backups_remote.html:98` makes the same promise.** The spec
lists that line as *"Accurate; keep"*, but it ends *„a hozzájuk tartozó helyreállítási kóddal később
visszaállíthatók lehetnek"* — the identical claim in a different conjugation, which the spec's own
regression guard (`visszaállító` + `lehet`, singular) does not match. Left as-is deliberately: the
instruction is not to improvise Hungarian at the customer. **Needs a wording decision → R-296.**
**R-295 — one name per secret (controller half).** The claim page called the SAME three-word
dashboard code „Beállító kód" on the first-time branch and „Visszaállító kód" on the reset branch,
while the TEN-word escrow code is „Helyreállítási kód". Two near-homographs for two different
secrets; the collision cost a real code. „Visszaállító kód" is **retired**: the dashboard code is
„Beállító kód" on both branches (`claim.html`) and in both operator-facing strings (`claim.go`
the `print-reset-code` output and the lockout message), and where the one secret serves two
situations the **name is constant and the sentence changes**. **Naming only — no acceptance logic
moved**, pinned by `TestResetCode_StillAcceptedOnTheSetupPage`.
**Gate fix (instrument, not product).** `secret_in_markup_gate.py` treated a Go template comment
`{{/* ... */}}` as a rendered expression and convicted the prose explaining a fix for containing the
word "secret". Template comments are stripped by `html/template` and cannot reach the response body,
so they are now skipped — `<!-- -->` comments deliberately are NOT, because those do ship. Proven in
both directions: the gate passes the comment and still convicts a planted `{{.RecoveryPassword}}`.
**R-280 — after a reinstall the data drive can be re-attached, and the page stops promising a click **R-280 — after a reinstall the data drive can be re-attached, and the page stops promising a click
that does not exist.** Measured on the rebuilt demo-hp 2026-08-09: the restore page diagnosed the that does not exist.** Measured on the rebuilt demo-hp 2026-08-09: the restore page diagnosed the
+2 -2
View File
@@ -497,7 +497,7 @@ func PrintLocalResetCode(sett ClaimHatchSettings, cfg ClaimHatchConfig) int {
fmt.Fprintf(os.Stderr, "print-reset-code: saving code: %v\n", err) fmt.Fprintf(os.Stderr, "print-reset-code: saving code: %v\n", err)
return 1 return 1
} }
fmt.Printf("Egyszer használható helyi beállító/visszaállító kód (generation %d):\n\n %s\n\nAdd meg a vezérlőpult beállító oldalán (/claim), majd válassz új jelszót.\n", nextGen, code) fmt.Printf("Egyszer használható helyi beállító kód (generation %d):\n\n %s\n\nAdd meg a vezérlőpult beállító oldalán (/claim), majd válassz új jelszót.\n", nextGen, code)
return 0 return 0
} }
@@ -537,7 +537,7 @@ func (s *Server) reportClaimLockout(ip string) {
s.logger.Printf("[WARN] [web] claim: code lockout tripped (source %s) — 15 min", ip) s.logger.Printf("[WARN] [web] claim: code lockout tripped (source %s) — 15 min", ip)
if s.notifier != nil { if s.notifier != nil {
s.notifier.PushEvent("claim_lockout", "warning", s.notifier.PushEvent("claim_lockout", "warning",
"Túl sok hibás beállító/visszaállító kód — a beállító oldal 15 percre zárolva", "Túl sok hibás beállító kód — a beállító oldal 15 percre zárolva",
map[string]interface{}{"source": ip}) map[string]interface{}{"source": ip})
} }
} }
@@ -0,0 +1,107 @@
package web
import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// ── R-295 — ONE NAME PER SECRET ─────────────────────────────────────────────────────────────────
//
// Two different secrets were both called „Visszaállító kód":
//
// - the THREE-word code that gives a person control of the dashboard (the claim/reset code), and
// - the TEN-word code that opens the sealed off-site backups (the escrow recovery code).
//
// The names are near-homographs of each other and of „Helyreállítási kód", and the collision cost a
// real code. The ruling: the dashboard code is „Beállító kód" everywhere — the name the box already
// showed on the page where it is typed — the escrow code is „Helyreállítási kód", and „Visszaállító
// kód" is retired. Where one secret serves two situations the NAME stays constant and the SENTENCE
// changes.
//
// THIS IS NAMING, NOT FUNCTION. TestResetCode_StillAcceptedOnTheSetupPage below is the pin that says
// so: the code kept working throughout, and a rename that quietly broke acceptance would be a far
// worse outcome than the collision it fixed.
// claimPageHTML renders the claim page in one of its two branches through the PRODUCTION template
// tree. IsReset is the only field varied — in production it is `s.authEnabled()` (a set password
// means this is the reset flow), and it is the branch that used to rename the secret.
func claimPageHTML(t *testing.T, isReset bool) string {
t.Helper()
s := testServer(t)
s.loadTemplates()
var buf bytes.Buffer
data := map[string]interface{}{
"Title": "A szerver beállítása", "CustomerName": "Teszt Ügyfél", "Domain": "pelda.hu",
"ClaimCSRF": "t", "IsReset": isReset, "HasCode": true, "MinPassword": 12,
}
if err := s.tmpl.ExecuteTemplate(&buf, "claim", data); err != nil {
t.Fatalf("render claim (IsReset=%v): %v", isReset, err)
}
return buf.String()
}
// RED-PROOF: restore `{{if .IsReset}}Visszaállító kód{{else}}Beállító kód{{end}}` on the label in
// claim.html and the reset branch fails here, with the retired name quoted back.
func TestClaimPage_BothBranchesNameTheSameSecretTheSameWay(t *testing.T) {
for _, isReset := range []bool{false, true} {
branch := "first-time"
if isReset {
branch = "reset"
}
html := claimPageHTML(t, isReset)
if strings.Contains(html, "isszaállító kód") {
t.Errorf("[%s branch] the retired name „Visszaállító kód" +
"\" is still on the page — it collides with the escrow „Helyreállítási kód", branch)
}
if !strings.Contains(html, "eállító kód") {
t.Errorf("[%s branch] the page no longer names the secret „Beállító kód" +
"\" at all", branch)
}
}
}
// The escrow code's name must NOT appear on the dashboard-claim page — that confusion is the whole
// finding. (Substring is ASCII-safe on purpose: an accented pattern that fails to match reads exactly
// like the string being absent.)
func TestClaimPage_DoesNotMentionTheEscrowCodeName(t *testing.T) {
for _, isReset := range []bool{false, true} {
if html := claimPageHTML(t, isReset); strings.Contains(html, "elyreállítási kód") {
t.Error("the claim page names the ESCROW code — the two secrets are different, and " +
"naming one on the other's page is how a customer types the wrong one")
}
}
}
// ── The pin the ruling explicitly asks for: acceptance did not move ─────────────────────────────
// A reset-issued code is still accepted on the setup page and still sets the password. The rename
// touched copy only; if this ever fails, a naming change has broken a recovery path.
func TestResetCode_StillAcceptedOnTheSetupPage(t *testing.T) {
s, code, sett := claimTestServer(t)
form := url.Values{
"_csrf": {s.claimCSRFToken()}, "code": {code},
"new_password": {"a-strong-passphrase-12"}, "confirm_password": {"a-strong-passphrase-12"},
}
req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
rr := httptest.NewRecorder()
s.handleClaimSubmit(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("a valid code was REFUSED after the rename: got %d, body=%q — the ruling was that "+
"this is naming, not function", rr.Code, claimFirstLine(rr.Body.String()))
}
if !sett.GetClaimed() {
t.Error("the box was not marked claimed — acceptance logic moved with the copy")
}
if !s.authEnabled() {
t.Error("the password was not set — acceptance logic moved with the copy")
}
}
@@ -0,0 +1,104 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── R-294 / R-202 — THE ORPHAN CARD STOPS PROMISING WHAT IT CANNOT KNOW ─────────────────────────
//
// The card told a customer, at the moment they had just lost their off-site history, that the old
// copies "may be restorable later with their recovery code". The discriminator is
// host_escrow_superseded.identity_blob and it lives on the HUB; the box caches only
// HubEscrowIdentityPresent, which describes the CURRENT escrow, and no report or ACK field carries
// superseded-blob retention. The renderer therefore could not evaluate the condition it stated.
//
// For everything set aside before hub v0.93.0 (in force 2026-08-04 ~11:11Z) the promise is false and
// unfixable, and on 2026-08-10 it was being made to a real machine in exactly that state.
//
// Implements documentation/design/SPEC-orphan-card-copy-2026-08-10.md §5. Render tests per branch of
// the gate, because a template gate without one is the seam-wiring lesson.
// orphanCardData renders the backups_remote page with the offbox repo in the given state. Only
// RepoState varies between the branches below, so what each test proves is attributable.
func orphanCardData(repoState string) map[string]interface{} {
d := splitTestData()
d["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
EscrowState: "escrowed", RepoState: repoState, QuotaGB: 50, StatsKnown: true,
}
d["OffboxQuotaPct"] = 0
return d
}
// ── Branch 1: the card IS shown ─────────────────────────────────────────────────────────────────
// SPEC §5.1 — the regression guard. The promise must not return in any form.
//
// RED-PROOF: restore the old sentence („…és a hozzá tartozó helyreállítási kóddal később
// visszaállítható lehet.") in backups_remote.html and this fails on the first assertion, with the
// promise quoted back in the failure message.
func TestOrphanCard_DoesNotPromiseRestorability(t *testing.T) {
html := renderBackupPage(t, "backups_remote", orphanCardData("orphaned"))
if !strings.Contains(html, "offbox-orphan-card") {
t.Fatal("the orphan card did not render at all — this test would then pass vacuously, " +
"which is the way a copy guard silently stops guarding")
}
if strings.Contains(html, "visszaállítható lehet") {
t.Error("R-294: the card still promises the set-aside copies may be restorable later. The box " +
"cannot evaluate that — the discriminator (superseded identity_blob) is on the hub and no " +
"wire field carries it — and for everything set aside before 2026-08-04 it is false")
}
}
// SPEC §5.2 — a refusal that names no route is a defect in this project. Pin the route, not only the
// absence of the promise.
//
// RED-PROOF: delete the „írj nekünk" sentence and this fails — the customer is told we cannot promise
// anything and given nowhere to go.
func TestOrphanCard_NamesARouteAfterDeclining(t *testing.T) {
html := renderBackupPage(t, "backups_remote", orphanCardData("orphaned"))
if !strings.Contains(html, "nem tudjuk megígérni") {
t.Error("R-294: the card no longer DECLINES the claim — stating nothing is not the same as " +
"saying plainly that we cannot promise it")
}
if !strings.Contains(html, "írj nekünk") {
t.Error("R-294: the card declines the promise but names no route the customer can take")
}
// It must still say the copy is kept — otherwise "we cannot promise" reads as "it is gone".
if !strings.Contains(html, "nem töröljük") {
t.Error("R-294: the card no longer says the set-aside copy is kept; without that, declining " +
"the promise reads to the customer as data loss")
}
}
// The reason the card exists — the orphan EXPLANATION — is accurate and must survive the copy change.
func TestOrphanCard_KeepsTheExplanation(t *testing.T) {
html := renderBackupPage(t, "backups_remote", orphanCardData("orphaned"))
if !strings.Contains(html, "korábbi, már nem elérhető kulccsal") {
t.Error("the explanation of WHY the store is orphaned was lost — the customer is then shown a " +
"refusal with no cause")
}
}
// ── Branch 2: the card is NOT shown ─────────────────────────────────────────────────────────────
// SPEC §5.3 — per branch of the gate. A healthy store must not see any of this copy: R-215's shape is
// a screen about a situation the customer is not in.
func TestOrphanCard_HealthyStoreSeesNoneOfIt(t *testing.T) {
html := renderBackupPage(t, "backups_remote", orphanCardData("ok"))
if strings.Contains(html, "offbox-orphan-card") {
t.Fatal("the orphan card rendered for a healthy store")
}
for _, s := range []string{"nem tudjuk megígérni", "írj nekünk", "félretéve marad"} {
if strings.Contains(html, s) {
t.Errorf("orphan copy %q leaked onto a healthy box's page", s)
}
}
}
@@ -98,7 +98,16 @@
<p class="form-hint" style="margin:0 0 .5rem">A távoli tárhelyen lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Emiatt új mentés jelenleg nem írható a tárolóba. A meglévő mentések nem sérültek — a hozzájuk tartozó helyreállítási kóddal később visszaállíthatók lehetnek.</p> <p class="form-hint" style="margin:0 0 .5rem">A távoli tárhelyen lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Emiatt új mentés jelenleg nem írható a tárolóba. A meglévő mentések nem sérültek — a hozzájuk tartozó helyreállítási kóddal később visszaállíthatók lehetnek.</p>
<button type="button" class="btn btn-sm btn-outline" id="orphan-reveal" onclick="var c=document.getElementById('orphan-confirm');c.style.display='block';this.style.display='none'">Új távoli mentés indítása…</button> <button type="button" class="btn btn-sm btn-outline" id="orphan-reveal" onclick="var c=document.getElementById('orphan-confirm');c.style.display='block';this.style.display='none'">Új távoli mentés indítása…</button>
<div id="orphan-confirm" style="display:none;margin-top:.6rem"> <div id="orphan-confirm" style="display:none;margin-top:.6rem">
<p class="form-hint" style="margin:0 0 .5rem">A régi előzmény <strong>félretéve marad</strong> (nem törlődik), és a hozzá tartozó helyreállítási kóddal később visszaállítható lehet. Egy üres, új tároló jön létre a mostani kulccsal, és a következő mentés ide készül.</p> {{/* R-294 / R-202: this paragraph promised the set-aside copies "may be restorable later
with the recovery code". The discriminator is host_escrow_superseded.identity_blob,
which lives on the HUB; the box caches only HubEscrowIdentityPresent (about the
CURRENT escrow) and no report or ACK field carries superseded-blob retention. So the
renderer cannot evaluate the condition it was stating — and a conditional promise the
system cannot evaluate is the same defect as an unconditional false one. It now
states what it knows, declines what it does not, and names a route.
Copy is verbatim from documentation/design/SPEC-orphan-card-copy-2026-08-10.md §4. */}}
<p class="form-hint" style="margin:0 0 .5rem">A régi előzmény <strong>félretéve marad a tárhelyen — nem töröljük</strong>. Új, üres tárolót hozunk létre, és a következő mentés oda készül.</p>
<p class="form-hint" style="margin:0 0 .5rem"><strong>A félretett mentések megnyithatóságát itt nem tudjuk megígérni.</strong> Ez attól függ, megvan-e még a hozzájuk tartozó kulcs, és ezt ez a gép nem tudja megállapítani. Ha szeretnéd, hogy utánanézzünk, <strong>írj nekünk</strong> — a félretett másolat addig is a helyén marad.</p>
<form method="POST" action="/backup/offbox/reset" style="display:inline">{{.CSRFField}} <form method="POST" action="/backup/offbox/reset" style="display:inline">{{.CSRFField}}
<input type="hidden" name="confirm" value="1"> <input type="hidden" name="confirm" value="1">
<button type="submit" class="btn btn-sm btn-primary">Megerősítés — új távoli mentés indítása</button> <button type="submit" class="btn btn-sm btn-primary">Megerősítés — új távoli mentés indítása</button>
+12 -3
View File
@@ -16,14 +16,23 @@
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}} {{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}} {{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
{{/* R-295 — ONE NAME PER SECRET. This page used to call the SAME three-word code „Beállító
kód" on the first-time branch and „Visszaállító kód" on the reset branch, while the
TEN-word code that opens the sealed backups is called „Helyreállítási kód" elsewhere in
this UI. Two of those three names are near-homographs, and the collision cost a real code.
The ruling: the code that gives a person control of the DASHBOARD is „Beállító kód"
everywhere — the name this page already showed where it is typed — and where one secret
serves two situations the NAME stays constant and the SENTENCE changes. „Visszaállító
kód" is retired. This is naming only: no acceptance logic moved, and a test pins that a
reset code is still accepted here. */}}
{{if .HasCode}} {{if .HasCode}}
<p style="font-size:0.85rem;color:var(--text-muted,#8a94a6);margin:0 0 1rem"> <p style="font-size:0.85rem;color:var(--text-muted,#8a94a6);margin:0 0 1rem">
{{if .IsReset}}Add meg az e-mailben kapott visszaállító kódot, majd válassz új jelszót.{{else}}Add meg az e-mailben kapott beállító kódot, majd válassz saját jelszót a vezérlőpult védelméhez.{{end}} {{if .IsReset}}Add meg az e-mailben kapott beállító kódot, majd válassz új jelszót.{{else}}Add meg az e-mailben kapott beállító kódot, majd válassz saját jelszót a vezérlőpult védelméhez.{{end}}
</p> </p>
<form method="POST" action="/claim"> <form method="POST" action="/claim">
<input type="hidden" name="_csrf" value="{{.ClaimCSRF}}"> <input type="hidden" name="_csrf" value="{{.ClaimCSRF}}">
<div class="form-group"> <div class="form-group">
<label for="code">{{if .IsReset}}Visszaállító kód{{else}}Beállító kód{{end}}</label> <label for="code">Beállító kód</label>
<input type="text" id="code" name="code" required autofocus autocomplete="off" <input type="text" id="code" name="code" required autofocus autocomplete="off"
placeholder="szó-szó-szó" class="form-control"> placeholder="szó-szó-szó" class="form-control">
</div> </div>
@@ -45,7 +54,7 @@
<form method="POST" action="/claim/request-new-code" style="margin-top:1rem"> <form method="POST" action="/claim/request-new-code" style="margin-top:1rem">
<input type="hidden" name="_csrf" value="{{.ClaimCSRF}}"> <input type="hidden" name="_csrf" value="{{.ClaimCSRF}}">
<button type="submit" class="btn btn-outline btn-full">{{if .IsReset}}Visszaállító kód kérése{{else}}Nem kaptad meg a kódot? Új kód kérése{{end}}</button> <button type="submit" class="btn btn-outline btn-full">{{if .IsReset}}Új beállító kód kérése{{else}}Nem kaptad meg a kódot? Új kód kérése{{end}}</button>
</form> </form>
<p class="login-footer">Felhom — Otthoni szerver kezelés<br> <p class="login-footer">Felhom — Otthoni szerver kezelés<br>
@@ -75,6 +75,14 @@ def check(path):
src = open(path, encoding="utf-8").read() src = open(path, encoding="utf-8").read()
for m in ACTION.finditer(src): for m in ACTION.finditer(src):
expr = m.group(1).strip() expr = m.group(1).strip()
# A TEMPLATE comment `{{/* ... */}}` is stripped by html/template and never reaches the
# response body, so it cannot leak anything into markup — unlike an HTML `<!-- -->` comment,
# which does ship and is deliberately NOT skipped here. Without this the gate convicted the
# prose explaining a fix, purely for containing the word "secret" (2026-08-10), which is a
# false positive that teaches people to write worse comments or to widen the ALLOWLIST —
# both of which cost more than the check is worth.
if expr.startswith("/*"):
continue
if not SECRETY.search(expr): if not SECRETY.search(expr):
continue continue
if expr in ALLOWLIST: if expr in ALLOWLIST: