From 0b1486df8f96e9fbdda4b8407876d81df788c1ad Mon Sep 17 00:00:00 2001
From: kisfenyo
Date: Fri, 18 Sep 2026 14:28:24 +0200
Subject: [PATCH] fix: the recovery page's globe must write the HOUSEHOLD's
setting, not a cookie nothing reads
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/recovery is in the AUTHENTICATED route table — its reader is the household, not a visitor. The
first draft of v0.254.0 gave it the anonymous form, which sets the felhom_lang cookie that
langFor deliberately ignores once there is a session: the button would have appeared to work
and done nothing. Found by the live probe on demo-hp reporting no globe on /recovery (it 302s
to /login without a session) and then reading the route table.
executeTemplateLang now branches on hasSession: household form with its session CSRF, or the
visitor form without. The parity harness and TestI18nDirectRenderPagesFollowLanguage carry the
same branch, so the fixture is the form the real page serves — the trap this release already
walked into once with the shells.
A per-session CSRF token cannot be a fixture value, so it is blanked on both sides of every
parity comparison, exactly as relative ages already were. What stays pinned is that the field is
THERE and WHICH form it sits in — the half that says whether the globe writes the household's
setting or the visitor's cookie.
Evidence regenerated: 3 change shapes across 106 fixtures, 5 byte-identical (both guest share
pages and the catch-all — the three that must not change).
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
---
controller/internal/web/i18n_parity_test.go | 28 +++++++++++++++++--
controller/internal/web/i18n_wiring_test.go | 13 +++++++--
controller/internal/web/server.go | 25 +++++++++++++----
.../web/testdata/i18n_parity/app_export.html | 4 +--
.../i18n_parity/app_import_bundles.html | 4 +--
.../i18n_parity/app_import_empty.html | 4 +--
.../i18n_parity/app_info_available.html | 4 +--
.../i18n_parity/app_info_deployed.html | 4 +--
.../i18n_parity/app_info_installable.html | 4 +--
.../i18n_parity/app_info_operator_creds.html | 4 +--
.../i18n_parity/app_info_updating.html | 4 +--
.../i18n_parity/backups_apps_empty.html | 4 +--
.../i18n_parity/backups_apps_error.html | 4 +--
.../testdata/i18n_parity/backups_apps_ok.html | 4 +--
.../i18n_parity/backups_apps_other.html | 4 +--
.../i18n_parity/backups_apps_running.html | 4 +--
.../i18n_parity/backups_apps_stale_copy.html | 4 +--
.../i18n_parity/backups_degraded.html | 4 +--
.../testdata/i18n_parity/backups_empty.html | 4 +--
.../testdata/i18n_parity/backups_full.html | 4 +--
.../i18n_parity/backups_nobackup_yet.html | 4 +--
.../i18n_parity/backups_remote_empty.html | 4 +--
.../i18n_parity/backups_remote_error.html | 4 +--
.../i18n_parity/backups_remote_escrowed.html | 4 +--
.../i18n_parity/backups_remote_full.html | 4 +--
.../backups_remote_incomplete.html | 4 +--
.../i18n_parity/backups_remote_notconf.html | 4 +--
.../backups_remote_notconf_hub.html | 4 +--
.../backups_remote_pending_agent.html | 4 +--
.../backups_remote_pending_old.html | 4 +--
.../i18n_parity/backups_remote_running.html | 4 +--
.../i18n_parity/backups_remote_stale.html | 4 +--
.../i18n_parity/backups_remote_stale_old.html | 4 +--
.../i18n_parity/backups_restore_empty.html | 4 +--
.../i18n_parity/backups_restore_full.html | 4 +--
.../backups_restore_known_empty.html | 4 +--
.../i18n_parity/backups_restore_notarget.html | 4 +--
.../i18n_parity/backups_tier_due.html | 4 +--
.../i18n_parity/claim_reset_code.html | 4 +--
.../i18n_parity/claim_reset_nocode.html | 2 +-
.../i18n_parity/claim_setup_code.html | 4 +--
.../i18n_parity/claim_setup_nocode.html | 2 +-
.../i18n_parity/dashboard_diskcrit.html | 4 +--
.../testdata/i18n_parity/dashboard_full.html | 4 +--
.../i18n_parity/dashboard_nobackup.html | 4 +--
.../i18n_parity/dashboard_sparse.html | 4 +--
.../web/testdata/i18n_parity/debug.html | 4 +--
.../i18n_parity/deploy_deployed_running.html | 4 +--
.../i18n_parity/deploy_deployed_stopped.html | 4 +--
.../web/testdata/i18n_parity/deploy_new.html | 4 +--
.../i18n_parity/deploy_new_blocked.html | 4 +--
.../deploy_new_memory_blocked.html | 4 +--
.../i18n_parity/escrow_agent_old.html | 4 +--
.../testdata/i18n_parity/escrow_first.html | 4 +--
.../i18n_parity/escrow_not_ready.html | 4 +--
.../i18n_parity/escrow_receremony.html | 4 +--
.../testdata/i18n_parity/launcher_empty.html | 4 +--
.../testdata/i18n_parity/launcher_full.html | 4 +--
.../i18n_parity/launcher_nopassword.html | 4 +--
.../i18n_parity/launcher_reminder_tier0.html | 4 +--
.../i18n_parity/launcher_reminder_tier3.html | 4 +--
.../i18n_parity/launcher_reminder_tiers.html | 4 +--
.../i18n_parity/launcher_share_password.html | 2 +-
.../web/testdata/i18n_parity/logs.html | 4 +--
.../i18n_parity/monitoring_hub_down.html | 4 +--
.../i18n_parity/monitoring_hub_off.html | 4 +--
.../i18n_parity/monitoring_hub_ok.html | 4 +--
.../i18n_parity/recovery_locked_can.html | 4 +--
.../i18n_parity/recovery_locked_cannot.html | 4 +--
.../i18n_parity/recovery_locked_confirm.html | 4 +--
.../i18n_parity/recovery_unlocked_apps.html | 4 +--
.../i18n_parity/recovery_unlocked_empty.html | 4 +--
.../recovery_unlocked_unavailable.html | 4 +--
.../recovery_unlocked_untagged.html | 4 +--
.../i18n_parity/restore_wizard_blocked.html | 4 +--
.../i18n_parity/restore_wizard_exec.html | 4 +--
.../restore_wizard_nodumptime.html | 4 +--
.../restore_wizard_prepare_confirm.html | 4 +--
.../i18n_parity/restore_wizard_ready.html | 4 +--
.../settings_notifications_hub.html | 4 +--
.../settings_notifications_nohub.html | 4 +--
.../i18n_parity/settings_security_full.html | 4 +--
.../i18n_parity/settings_security_noauth.html | 4 +--
.../i18n_parity/settings_system_failed.html | 4 +--
.../i18n_parity/settings_system_full.html | 4 +--
.../i18n_parity/settings_system_nomemory.html | 4 +--
.../i18n_parity/settings_system_pending.html | 4 +--
.../i18n_parity/sharing_error_skipped.html | 4 +--
.../testdata/i18n_parity/sharing_full.html | 4 +--
.../i18n_parity/sharing_no_offsite_yet.html | 4 +--
.../i18n_parity/sharing_notarget_blocked.html | 4 +--
.../web/testdata/i18n_parity/sharing_off.html | 4 +--
.../i18n_parity/sharing_other_none.html | 4 +--
.../web/testdata/i18n_parity/stacks_full.html | 4 +--
.../testdata/i18n_parity/storage_attach.html | 4 +--
.../testdata/i18n_parity/storage_empty.html | 6 ++--
.../testdata/i18n_parity/storage_full.html | 6 ++--
.../testdata/i18n_parity/storage_init.html | 4 +--
.../i18n_parity/storage_network_full.html | 4 +--
.../storage_network_nosupport.html | 4 +--
.../testdata/i18n_parity/tier2_not_hdd.html | 4 +--
.../i18n_parity/tier2_notarget_disabled.html | 4 +--
.../i18n_parity/tier2_target_auto.html | 4 +--
.../i18n_parity/tier2_target_ssd.html | 4 +--
104 files changed, 257 insertions(+), 211 deletions(-)
diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go
index 996b6f8..4b9af7d 100644
--- a/controller/internal/web/i18n_parity_test.go
+++ b/controller/internal/web/i18n_parity_test.go
@@ -306,19 +306,43 @@ func renderI18nCase(t *testing.T, s *Server, lang string, c i18nCase) string {
// is exactly what the first attempt at this release did, and what the diff caught.
if i18nDirectTemplates[c.tmpl] {
data["Lang"] = lang
- addLangOptions(data, lang, langCookiePath, "/i18n-fixture", "")
+ // And WITHIN the direct-render set, two shapes again: /recovery is an AUTHENTICATED route, so
+ // its globe writes the household's setting; /login and /claim are met with no session, so
+ // theirs writes the visitor's cookie. The fixture has to be the one the real page serves.
+ if i18nSessionTemplates[c.tmpl] {
+ addLangOptions(data, lang, "/settings/language", "/i18n-fixture", s.csrfField(r))
+ } else {
+ addLangOptions(data, lang, langCookiePath, "/i18n-fixture", "")
+ }
} else {
s.addLanguageData(data, r, lang)
}
if err := s.templatesFor(lang).ExecuteTemplate(&buf, c.tmpl, data); err != nil {
t.Fatalf("%s [%s]: render: %v", c.name, lang, err)
}
- return relativeAgeRe.ReplaceAllString(buf.String(), "# $1")
+ return normaliseI18nRender(buf.String())
+}
+
+// csrfValueRe matches a CSRF field's value. A session CSRF token is random per session, so it can
+// never be a fixture value; it is blanked on BOTH sides of every parity comparison, exactly as
+// relative ages are. What the fixture still pins is that the field is THERE and which form it is in —
+// which is the part that says whether the page posts to the household switch or to /lang.
+var csrfValueRe = regexp.MustCompile(`(name="_csrf" value=")[^"]*"`)
+
+// normaliseI18nRender removes the two things that legitimately differ between two renders of the same
+// page: a relative age off the wall clock, and a per-session CSRF token.
+func normaliseI18nRender(s string) string {
+ s = relativeAgeRe.ReplaceAllString(s, "# $1")
+ return csrfValueRe.ReplaceAllString(s, "${1}CSRF\"")
}
// i18nDirectTemplates are the templates rendered by executeTemplateLang — the pages a request with no
// household session meets. Kept beside the harness that has to branch on it; i18nDirectPages (in
// i18n_wiring_test.go) names the same set with one case and an English probe each.
+// i18nSessionTemplates are the direct-render templates whose reader is SIGNED IN. /recovery is in the
+// authenticated route table; the rest of the direct set is met with no session.
+var i18nSessionTemplates = map[string]bool{"recovery": true}
+
var i18nDirectTemplates = map[string]bool{
"login": true, "claim": true, "recovery": true,
"launcher_shared": true, "launcher_share_password": true, "catchall": true,
diff --git a/controller/internal/web/i18n_wiring_test.go b/controller/internal/web/i18n_wiring_test.go
index 9360200..182bb02 100644
--- a/controller/internal/web/i18n_wiring_test.go
+++ b/controller/internal/web/i18n_wiring_test.go
@@ -306,10 +306,19 @@ func TestI18nDirectRenderPagesFollowLanguage(t *testing.T) {
// The SAME request path the parity harness renders with. Since v0.254.0 the page carries a
// language globe whose `back` is the path the visitor is on, so a different path here would
// differ from the fixture in one attribute and say nothing about the language.
- if err := s.executeTemplateLang(&buf, httptest.NewRequest(http.MethodGet, "/i18n-fixture", nil), p.tmpl, c.data()); err != nil {
+ //
+ // And the same SESSION state: /recovery is an authenticated route, so its reader is the
+ // household and its globe writes their setting; /login and /claim are met with no session.
+ // Rendering recovery session-less here would compare the real page against a form it never
+ // serves — the mistake this release already made once, in the parity harness.
+ req := httptest.NewRequest(http.MethodGet, "/i18n-fixture", nil)
+ if i18nSessionTemplates[p.tmpl] {
+ req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: newTestSession(s)})
+ }
+ if err := s.executeTemplateLang(&buf, req, p.tmpl, c.data()); err != nil {
t.Fatalf("%s [%s]: %v", p.tmpl, lang, err)
}
- got := relativeAgeRe.ReplaceAllString(buf.String(), "# $1")
+ got := normaliseI18nRender(buf.String())
if lang == "hu" {
want, err := os.ReadFile(filepath.Join("testdata", "i18n_parity", p.caseName+".html"))
if err != nil {
diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go
index dc8ba6a..572eaca 100644
--- a/controller/internal/web/server.go
+++ b/controller/internal/web/server.go
@@ -915,17 +915,30 @@ func (s *Server) executeTemplateLang(w io.Writer, r *http.Request, name string,
lang := s.langFor(r)
if data != nil {
data["Lang"] = lang
- // v0.254.0: the globe on the pages a VISITOR meets. It posts to /lang, not to the household
- // switch, and carries NO CSRF field — there is no session to mint one from, and the handler
- // writes only a display cookie in the visitor's own browser (CsrfProtect carries the reasoning).
+ // v0.254.0: the globe, on a page rendered outside the dashboard chrome. WHICH FORM it posts to
+ // depends on WHO is reading, and getting that wrong makes the globe do nothing:
//
- // `back` is the path the visitor is ON, so the switch returns them to it. safeBackPath in the
- // handler is what makes that safe to take from an anonymous form.
+ // * NO SESSION — the sign-in and claim pages. The reader is a visitor: they have no setting
+ // and may not write the household's, so the globe posts to /lang and their choice lives in
+ // their own browser. No CSRF field: there is no session to mint one from.
+ // * WITH A SESSION — /recovery is in the AUTHENTICATED route table; its reader IS the
+ // household. Their globe must write their SETTING, because langFor deliberately ignores the
+ // cookie once there is a session — so an anonymous form here would set a cookie nothing
+ // reads and the button would appear to do nothing. Measured live on demo-hp before this
+ // branch existed.
+ //
+ // Only the language form's own fields are added, never the dashboard chrome R-543 keeps off
+ // these pages (TestI18nDirectRenderPagesHaveNoAdminChrome still passes: it names CSRFField,
+ // CSRFToken and the escrow banner, none of which appear here).
back := "/"
if r != nil && r.URL != nil && r.URL.Path != "" {
back = r.URL.Path
}
- addLangOptions(data, lang, langCookiePath, back, "")
+ if s.hasSession(r) {
+ addLangOptions(data, lang, "/settings/language", back, s.csrfField(r))
+ } else {
+ addLangOptions(data, lang, langCookiePath, back, "")
+ }
}
return s.templatesFor(lang).ExecuteTemplate(w, name, data)
}
diff --git a/controller/internal/web/testdata/i18n_parity/app_export.html b/controller/internal/web/testdata/i18n_parity/app_export.html
index 2418587..48e4b02 100644
--- a/controller/internal/web/testdata/i18n_parity/app_export.html
+++ b/controller/internal/web/testdata/i18n_parity/app_export.html
@@ -150,8 +150,8 @@
0.247.0
Kijelentkezés ↗
diff --git a/controller/internal/web/testdata/i18n_parity/claim_reset_code.html b/controller/internal/web/testdata/i18n_parity/claim_reset_code.html
index a4ddc78..cc336e4 100644
--- a/controller/internal/web/testdata/i18n_parity/claim_reset_code.html
+++ b/controller/internal/web/testdata/i18n_parity/claim_reset_code.html
@@ -29,7 +29,7 @@
Add meg az e-mailben kapott beállító kódot, majd válassz új jelszót.