v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
gates / gates (push) Successful in 23s
gates / gates (push) Successful in 23s
The notes a background run SAVES — last night's backup line, the last error, the proof result, the restore outcome — are written in the BOX's language at the moment they are written. A household that switches sees the previous run's note in the old language until the next run rewrites it: the operator's §16 option 1, stated rather than hidden. EndRestoreOp no longer receives a Hungarian literal from anywhere. The language switch is a globe. Two text links wrapped in the sidebar footer and asked the reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is <details>/<summary> — a menu with no script, drawn inline because the icon sprite lives only in layout.html and the visitor pages have their own shell. Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household can never inherit a language a previous visitor picked in the same browser. POST /lang is CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the language of the page the victim's own browser shows them — and safeBackPath refuses //evil.example as well as https://, because "starts with /" alone is not the test. §16 taken: a successful claim carries the cookie into the household's setting. TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the three pages that must not change. I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings lock. Fixed by resolving the language before the callback, and guarded by a test that names the file and line in a second instead of hanging for 25 minutes. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -193,6 +193,10 @@ func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, l
|
||||
if r != nil {
|
||||
data["LangSwitch"] = true
|
||||
data["LangSwitchBack"] = r.URL.Path
|
||||
// v0.254.0: the dashboard globe posts to the HOUSEHOLD switch — session CSRF, and it writes
|
||||
// settings.json. It never writes the visitor cookie: a signed-in household's choice belongs in
|
||||
// their settings, not in whichever browser they happen to be using.
|
||||
addLangOptions(data, lang, "/settings/language", r.URL.Path, s.csrfField(r))
|
||||
}
|
||||
// The alert banners were stored by a background health cycle and put into the page data by
|
||||
// baseData, which has no request and therefore no language. Re-rendered here, where the language
|
||||
@@ -218,6 +222,38 @@ func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, l
|
||||
}
|
||||
}
|
||||
|
||||
// LangOption is one entry in the globe menu. Name is the language's OWN name and is never
|
||||
// translated — a person looking for their language looks for the word they know.
|
||||
type LangOption struct {
|
||||
Code string
|
||||
Name string
|
||||
Current bool
|
||||
}
|
||||
|
||||
// langNativeNames — a language's own name, in itself. Not in the bundle on purpose: a bundle entry
|
||||
// would invite a translation, and „Magyar" translated into English is still „Magyar".
|
||||
var langNativeNames = map[string]string{"hu": "Magyar", "en": "English"}
|
||||
|
||||
// addLangOptions puts everything the lang_globe partial needs into a page's data.
|
||||
//
|
||||
// `csrf` is template.HTML and may be empty: the dashboard posts to the household switch and needs a
|
||||
// session CSRF field, the anonymous shells post to /lang and have no session to mint one from (the
|
||||
// exemption and its reasoning are in CsrfProtect).
|
||||
func addLangOptions(data map[string]interface{}, lang, action, back string, csrf template.HTML) {
|
||||
opts := make([]LangOption, 0, len(i18n.Supported))
|
||||
for _, code := range i18n.Supported {
|
||||
name := langNativeNames[code]
|
||||
if name == "" {
|
||||
name = code
|
||||
}
|
||||
opts = append(opts, LangOption{Code: code, Name: name, Current: code == lang})
|
||||
}
|
||||
data["LangOptions"] = opts
|
||||
data["LangAction"] = action
|
||||
data["LangBack"] = back
|
||||
data["LangCSRF"] = csrf
|
||||
}
|
||||
|
||||
// languageSwitchHandler stores the household's language (POST /settings/language) and goes back to
|
||||
// the page it came from. CSRF is enforced by the CsrfProtect middleware wrapping "/" (main.go), as for
|
||||
// every other dashboard form; the switch form carries {{.CSRFField}}.
|
||||
@@ -358,3 +394,94 @@ func stateLabelKey(state stacks.ContainerState) string {
|
||||
return "func.state.unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ── The visitor's language, for a page with no household signed in (v0.254.0, R-557 release C) ──
|
||||
//
|
||||
// The sign-in, claim and recovery pages are met by someone who has not signed in. They have no
|
||||
// setting to read, and they must not be able to write the household's: a box's sign-in page is
|
||||
// reachable by anyone who can reach the box, and changing what the HOUSEHOLD reads from there would
|
||||
// be an anonymous write to something they own. Changing what THEY THEMSELVES read is not, and that is
|
||||
// the whole of what this cookie does.
|
||||
//
|
||||
// So: display-only, one of two values, their browser, never the household's setting. `langFor` reads
|
||||
// it only when there is no session (server.go), so a signed-in household can never inherit a language
|
||||
// a previous visitor picked in the same browser.
|
||||
|
||||
// langCookiePath is the anonymous switch's route.
|
||||
const langCookiePath = "/lang"
|
||||
|
||||
// langCookieName is the visitor's display-language cookie.
|
||||
const langCookieName = "felhom_lang"
|
||||
|
||||
// langCookieMaxAge — a year. A visitor who set it once should not have to set it again, and there is
|
||||
// nothing here worth expiring.
|
||||
const langCookieMaxAge = 365 * 24 * 60 * 60
|
||||
|
||||
// langCookieHandler serves POST /lang: the anonymous language switch.
|
||||
//
|
||||
// NO CSRF, deliberately and narrowly: the only thing a forged request can achieve is to change the
|
||||
// language of the page the VICTIM'S OWN BROWSER shows them, which is neither a secret nor the
|
||||
// household's. The handler cannot touch settings.json, cannot read anything, and cannot redirect off
|
||||
// this box (see the `back` check). If it ever gains a second effect, it needs CSRF that day.
|
||||
func (s *Server) langCookieHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
lang := r.FormValue("lang")
|
||||
if !i18n.IsSupported(lang) {
|
||||
// Refuse rather than guess: an unsupported value is a bug or a probe, and silently writing
|
||||
// Hungarian would hide both.
|
||||
http.Error(w, "unsupported language", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: langCookieName,
|
||||
Value: lang,
|
||||
Path: "/",
|
||||
HttpOnly: true, // nothing on the page needs to read it; the server does
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
||||
MaxAge: langCookieMaxAge,
|
||||
})
|
||||
http.Redirect(w, r, safeBackPath(r.FormValue("back")), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// safeBackPath keeps an open redirect out of the one handler that takes a destination from an
|
||||
// anonymous request.
|
||||
//
|
||||
// Only a same-origin PATH is allowed. `//evil.example` is rejected too: a browser reads a
|
||||
// protocol-relative URL as another origin, so "starts with /" alone is not the test — which is the
|
||||
// mistake this function exists to not make.
|
||||
func safeBackPath(back string) string {
|
||||
if back == "" || !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") {
|
||||
return "/"
|
||||
}
|
||||
if strings.Contains(back, "\\") || strings.ContainsAny(back, "\r\n") {
|
||||
return "/"
|
||||
}
|
||||
return back
|
||||
}
|
||||
|
||||
// ── Saved notes (v0.254.0, release C) ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// A restore runs in a goroutine with no request and finishes minutes later; its outcome is SAVED and
|
||||
// read on a page afterwards. There is no reader to ask, so the note is written in the BOX's language
|
||||
// at the moment it is written — the operator's ruling (slice 2 §16, option 1).
|
||||
//
|
||||
// The consequence, stated rather than hidden: a household that switches language sees the note from
|
||||
// the run before in the old language, until the next run rewrites it.
|
||||
|
||||
// boxLang is the language a SAVED note is written in.
|
||||
func (s *Server) boxLang() string {
|
||||
if s.settings == nil {
|
||||
return i18n.Default
|
||||
}
|
||||
return s.settings.GetLanguage()
|
||||
}
|
||||
|
||||
// note renders a saved note in the box's language.
|
||||
func (s *Server) note(key string, args ...interface{}) string {
|
||||
return util.Text(s.boxLang(), key, args...)
|
||||
}
|
||||
|
||||
// noteErr renders an error into a saved note in the box's language: its bundle message when it
|
||||
// carries one (release B), its own text otherwise.
|
||||
func (s *Server) noteErr(err error) string { return util.ErrText(s.boxLang(), err) }
|
||||
|
||||
Reference in New Issue
Block a user