Files
felhom-controller/controller/internal/web/retrieval_password_exposure_test.go
T
admin 8dbbc98ff2
gates / gates (push) Successful in 18s
v0.207.0 — R-249: the retrieval passphrase leaves the page body; R-252/R-253: two refusals learn to say what to do
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.
2026-08-07 18:04:26 +02:00

148 lines
6.5 KiB
Go

package web
import (
"bytes"
"io"
"log"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-249 — THE PASSPHRASE MUST NOT BE IN THE RESPONSE BODY OF A PAGE THE CUSTOMER MERELY OPENS.
//
// WHY THESE TESTS ASSERT ON THE BODY AND NOT ON A RENDERED VIEW, which is the whole reason the
// defect survived: the old markup put the value inside `<span style="display:none">`. Every test
// that asked "what does the customer SEE" passed, because a browser drew asterisks. The value was
// in the bytes the whole time, and a `curl` of the page returned it — which is how it was found, by
// landing in a session transcript on 2026-08-07.
//
// So: render the real page and search the raw HTML. A test that cannot see a display:none span
// cannot see this defect at all.
const testRetrievalPassphrase = "edeni-oshalom-disztok-harul-Zsolna-TESTONLY"
// securityHarness builds a Server complete enough for securityPageData, which also reads the stack
// list for the geo per-app override selector.
func securityHarness(t *testing.T) *Server {
t.Helper()
dir := t.TempDir()
lg := log.New(io.Discard, "", 0)
cfg := config.Default()
cfg.Customer.ID = "c1"
cfg.Customer.Domain = "example.hu"
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Web.SessionSecret = "test-session-secret-abcdef"
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatalf("settings: %v", err)
}
mgr, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatalf("stacks: %v", err)
}
return &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
}
// renderSecurityPage drives the REAL page-data builder and the REAL template, and returns the bytes
// a browser would receive.
func renderSecurityPage(t *testing.T, passphrase string) string {
t.Helper()
s := securityHarness(t)
if passphrase != "" {
if err := s.settings.SetRetrievalPassword(passphrase); err != nil {
t.Fatalf("seed retrieval password: %v", err)
}
}
s.loadTemplates()
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, "settings_security", s.securityPageData()); err != nil {
t.Fatalf("render settings_security: %v", err)
}
return buf.String()
}
// ── SCENARIO A — the password is not in the page ────────────────────────────────────────────────
// RED-PROOF (run and confirmed failing before the fix was restored): put the value back in the page
// data and the template —
//
// handlers.go: data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
// template: <code class="mono">{{.RetrievalPassword}}</code> inside the display:none span
//
// — and this test fails on the first assertion, printing the plaintext's presence in the body. That
// is the defect, reproduced. The mutation was applied and observed failing; see the session report.
func TestSecurityPage_DoesNotContainTheRetrievalPassphrase(t *testing.T) {
html := renderSecurityPage(t, testRetrievalPassphrase)
if strings.Contains(html, testRetrievalPassphrase) {
t.Error("R-249: the retrieval passphrase is in the response body of the security page — " +
"a fetch of this page returns the plaintext, and the reveal toggle only stops a " +
"browser DRAWING it (this is the defect, and it is invisible to any test that asserts " +
"on what is displayed)")
}
// The card must still be there — the fix is to remove the VALUE, not the feature (Scenario B).
if !strings.Contains(html, "Visszaállítási jelszó") {
t.Error("the recovery-info card vanished — the fix must not remove the customer's access, " +
"only the value from the markup")
}
if !strings.Contains(html, `id="retrieval-pw-btn"`) {
t.Error("no reveal control rendered, so the customer has no way to obtain the passphrase at all")
}
}
// The card is gated on EXISTENCE, and existence is not the value. A box with no stored passphrase
// must not render the card — the old gate was `{{if .RetrievalPassword}}`, which read the secret to
// decide whether to show the secret.
func TestSecurityPage_NoCardWhenNoPassphraseStored(t *testing.T) {
html := renderSecurityPage(t, "")
if strings.Contains(html, "Visszaállítási jelszó") {
t.Error("the recovery-info card rendered on a box with no stored retrieval passphrase")
}
}
// ── SCENARIO B — the customer can still get it ──────────────────────────────────────────────────
// RED-PROOF: delete the `/settings/retrieval-password/reveal` case from server.go (or make the
// handler return 404 unconditionally) and this fails — the customer is shown unable to obtain the
// passphrase at all, which is the wrong fix for Scenario A.
func TestRevealEndpoint_ReturnsThePassphraseToAnAuthenticatedCaller(t *testing.T) {
s := securityHarness(t)
if err := s.settings.SetRetrievalPassword(testRetrievalPassphrase); err != nil {
t.Fatalf("seed: %v", err)
}
rr := httptest.NewRecorder()
s.settingsRetrievalPasswordRevealHandler(rr, httptest.NewRequest("POST", "/settings/retrieval-password/reveal", nil))
if rr.Code != 200 {
t.Fatalf("reveal returned %d, want 200 — the customer cannot get their own passphrase", rr.Code)
}
if !strings.Contains(rr.Body.String(), testRetrievalPassphrase) {
t.Error("the reveal endpoint did not return the passphrase — Scenario A's fix must not " +
"protect the secret by removing the customer's access to it")
}
// A cached reveal is the same defect one layer down: a back-navigation would re-present the body.
if got := rr.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Errorf("reveal response Cache-Control = %q, want no-store", got)
}
}
// A box with nothing stored answers cleanly rather than leaking the distinction as a 500.
func TestRevealEndpoint_404sWhenNothingStored(t *testing.T) {
s := securityHarness(t)
rr := httptest.NewRecorder()
s.settingsRetrievalPasswordRevealHandler(rr, httptest.NewRequest("POST", "/settings/retrieval-password/reveal", nil))
if rr.Code != 404 {
t.Errorf("reveal on a box with no passphrase returned %d, want 404", rr.Code)
}
if strings.Contains(rr.Body.String(), testRetrievalPassphrase) {
t.Error("the empty-case response carried a passphrase")
}
}