v0.207.0 — R-249: the retrieval passphrase leaves the page body; R-252/R-253: two refusals learn to say what to do
gates / gates (push) Successful in 18s
gates / gates (push) Successful in 18s
R-249. settings_security.html rendered the passphrase into a display:none span behind a Megjelenit button. That toggle stops a browser DRAWING the value and nothing else — the plaintext was in the response body of every render, so a curl of the page returned it. Found by exactly that: it landed in a session transcript while driving the documented rebuild path. The codebase already stated this rule for the recovery code and this page did not follow it (escrow_handlers.go: 'reveal (claim XHR only — R is NEVER templated server-side into HTML)'). The page now carries only HasRetrievalPassword; the value comes from POST /settings/retrieval-password/reveal — CSRF-covered because POST, no-store, and LOGGED as an act, which reading it off the markup never was. The tests assert the RAW RESPONSE BODY. Every test that asked what the customer sees passed while the bytes carried the secret; that is why this survived. Census: the render-then-hide pattern appears twice more — app_info.html (a real per-install app password in a hidden span) and deploy.html. Filed as R-254, NOT fixed here. R-252. A rebuilt box keeps its drives but loses their REGISTRATION. The restore page now states that before the customer presses anything, says the backups and drives are both still there, and links to Tarhely > Meghajtok. Page and resolver ask ONE question — HasRestoreDestination() reads the same GetSchedulableStoragePaths() the scratch resolver reads. R-253. The list promised 'a visszaallitas elobb ujratelepiti' three lines above a refusal that fired BECAUSE the app was not installed. The promise was the wrong half: reconstitution writes to the app's own GetStackHDDPath, which exists only once the CUSTOMER has chosen a drive at deploy time. Auto-reinstalling would mean the product making that choice for them. Copy now says to install first and routes to /stacks/<app>/deploy. Both notices are conditional — a healthy box renders as before, pinned by a test that fails if either becomes unconditional.
This commit is contained in:
@@ -1030,6 +1030,11 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
rows, state := s.offsiteRestoreRows(r.Context())
|
||||
data["OffsiteRestoreRows"] = rows
|
||||
data["OffsiteStoreState"] = string(state)
|
||||
// R-252: a rebuilt box's drives survive but their REGISTRATION does not, and every restore
|
||||
// then refuses with a message that names no next step. Asked through the backup manager's own
|
||||
// predicate so the page and the resolver cannot disagree. FALSE on a healthy box, where the
|
||||
// template renders exactly as before (Scenario E).
|
||||
data["NoRestoreDestination"] = !s.backupMgr.HasRestoreDestination()
|
||||
}
|
||||
s.executeTemplate(w, r, "backups_restore", data)
|
||||
}
|
||||
@@ -1576,8 +1581,23 @@ func (s *Server) notificationsPageData() map[string]interface{} {
|
||||
func (s *Server) securityPageData() map[string]interface{} {
|
||||
data := s.settingsBaseData("settings-security", "Biztonság és hozzáférés")
|
||||
|
||||
// Recovery info for emergency section
|
||||
data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
|
||||
// Recovery info for emergency section.
|
||||
//
|
||||
// ⚠ R-249 — THE VALUE DOES NOT GO IN THE PAGE, AND THAT IS THE WHOLE FIX.
|
||||
//
|
||||
// Until v0.207.0 this line put the retrieval passphrase into the template data and
|
||||
// `settings_security.html` rendered it into a `display:none` span behind a „Megjelenít" button.
|
||||
// That toggle stops the browser DRAWING it and nothing else: the plaintext was in the response
|
||||
// body of every render, so a `curl` of the page returned it — which is how it was found, by
|
||||
// landing in a session transcript during the 2026-08-07 walk. It was therefore also in browser
|
||||
// caches, in history, in any saved page and in any screen-share of the page source.
|
||||
//
|
||||
// The product already had this exact rule and this page did not follow it — `escrow_handlers.go`
|
||||
// states it for the recovery code: *"reveal (claim XHR only — R is NEVER templated server-side
|
||||
// into HTML)"*. The passphrase now follows the same shape: the page carries only whether one
|
||||
// EXISTS, and the value comes from POST /settings/retrieval-password/reveal, which is an
|
||||
// explicit authenticated act and is logged as one.
|
||||
data["HasRetrievalPassword"] = strings.TrimSpace(s.settings.GetRetrievalPassword()) != ""
|
||||
data["HubURL"] = s.cfg.Hub.URL
|
||||
data["SupportEmail"] = "support@felhom.eu"
|
||||
data["SupportURL"] = "https://felhom.eu/kapcsolat"
|
||||
@@ -1611,6 +1631,37 @@ func (s *Server) securityPageData() map[string]interface{} {
|
||||
return data
|
||||
}
|
||||
|
||||
// settingsRetrievalPasswordRevealHandler — POST /settings/retrieval-password/reveal (v0.207.0, R-249).
|
||||
//
|
||||
// The ONLY path by which the retrieval passphrase reaches a browser. It is behind RequireAuth and
|
||||
// CsrfProtect like every other POST on this mux, so reaching it takes a live session AND a token
|
||||
// bound to it — where the old rendering took nothing but the ability to read a page the customer
|
||||
// merely opened.
|
||||
//
|
||||
// WHY A POST FOR A READ, deliberately and not by accident: a GET would be re-fetchable from history,
|
||||
// pre-fetchable by a browser, and loggable in any proxy's access log with the response cached. The
|
||||
// act of revealing a secret is a state change in every sense that matters here, and CsrfProtect only
|
||||
// covers unsafe methods — a GET would have no CSRF cover at all.
|
||||
//
|
||||
// `no-store` matters as much as the method: without it a back-navigation can re-present the response
|
||||
// body from the disk cache, which is the same defect one layer down.
|
||||
func (s *Server) settingsRetrievalPasswordRevealHandler(w http.ResponseWriter, r *http.Request) {
|
||||
pw := strings.TrimSpace(s.settings.GetRetrievalPassword())
|
||||
if pw == "" {
|
||||
// Not an error: a box that never stored one has nothing to reveal, and saying so is not a
|
||||
// leak. The page does not offer the button in that case (HasRetrievalPassword gates it).
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
escrowJSON(w, http.StatusNotFound, nil, "Ezen a gépen nincs tárolt visszaállítási jelszó.")
|
||||
return
|
||||
}
|
||||
// The reveal is an event, and it was not one before: the same act on the hub's break-glass
|
||||
// credential writes `recovery_credential_revealed`, while reading this value off the page markup
|
||||
// left no trace anywhere. The VALUE is never logged — only that it was asked for, and by whom.
|
||||
s.logger.Printf("[INFO] [web] retrieval passphrase revealed via the security page from %s (value never logged)", clientIP(r))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
escrowJSON(w, http.StatusOK, map[string]any{"password": pw}, "")
|
||||
}
|
||||
|
||||
func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.executeTemplate(w, r, "settings_system", s.systemPageData())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user