Files
felhom.eu/hub/internal/web/hosts_recovery_reveal_test.go
T
admin 670ec35ece hub v0.86.0 — Copy works without revealing, and every copy branch reports itself
Found by the operator, in the way that matters: it cost a real login.

The v0.84.0 Console access card shipped its Copy button DISABLED until a Reveal.
Clicking it did nothing, silently, so the clipboard kept whatever was already in
it — another host's console password from an earlier reveal. That got pasted into
demo-hp's PVE login, which failed with no explanation: the box logged a plain
`password check failed for user (root)`, the credential was never at fault, and
nothing on screen said the copy had not happened.

A copy button that silently no-ops is worse than no copy button. The operator
cannot tell "copied" from "did nothing", and the stale value left behind is a
VALID secret for a DIFFERENT machine — so the failure looks like a stale
credential and sends you diagnosing the wrong thing.

Copy now works without revealing, and that is the safer default rather than a
concession: the secret goes straight to the clipboard and never renders on
screen, so it cannot be shoulder-surfed or caught in a screenshot. Reveal remains
for when it must be read.

Three silent-failure branches closed, all in the same eight-line function:
  - not yet revealed        -> was a disabled no-op; now fetches and copies
  - navigator.clipboard absent -> was silently skipped; now shows it and says why
  - writeText() REJECTED    -> promise was ignored, so the operator believed it
                               copied; now shows it and reports the refusal

The success path names the host ("Copied demo-hp-bb76ea's root@pam password"),
because the clipboard is fleet-wide and every box has a different console
password — "copied" alone cannot say for WHICH box, which is the confusion that
produced the incident.

One retrieval path, shared: the endpoint is defined once (data-reveal-url) and
read back with getAttribute, so Copy cannot drift onto a different, unaudited URL
than Reveal. Server-side is unchanged — both buttons hit the same CSRF-gated
endpoint and both write the same recovery_credential_revealed event, which is
correct: the register records accesses, and a copy is an access.

Tests 566 -> 568. Red-proof: re-adding `disabled` reproduces the shipped bug.
2026-07-31 09:22:11 +02:00

443 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
// Scenarios AG (hub v0.84.0) — the break-glass Console access card + the operator-session reveal
// endpoint.
//
// SEAM DISCIPLINE: every test here drives RequireAuth(ServeHTTP), never a handler function
// directly. Scenario E (route ordering) is INVISIBLE to a handler-level test — the handler is
// correct and simply never runs — and that is the shape of the inert-seam defects on record.
//
// The load-bearing assertion is negative in the way that matters: the plaintext must not appear
// ANYWHERE in a rendered host page. revealCanary is deliberately distinctive so an accidental leak
// is greppable across the tree.
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
const revealCanary = "REVEAL-CANARY-9f2b7c"
// newRevealServer builds a hub with a live operator password (so RequireAuth + the ServeHTTP CSRF
// check are both armed) and a CAPTURED logger, so the "the secret never reaches the log" assertion
// has something to read.
func newRevealServer(t *testing.T) (*Server, *store.Store, *bytes.Buffer) {
t.Helper()
var logBuf bytes.Buffer
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { st.Close() })
s := New(st, "", "", "test", 30*time.Minute, log.New(&logBuf, "", 0))
h, err := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
s.configPasswordHash = string(h)
return s, st, &logBuf
}
// newRevealSession mints a live operator session and returns (cookie, csrfToken).
func newRevealSession(t *testing.T, s *Server) (*http.Cookie, string) {
t.Helper()
s.sessionsMu.Lock()
s.sessions["sess-token-reveal"] = &hubSession{
expiresAt: time.Now().Add(time.Hour),
csrfToken: "csrf-token-reveal",
}
s.sessionsMu.Unlock()
return &http.Cookie{Name: "hub_session", Value: "sess-token-reveal"}, "csrf-token-reveal"
}
// seedRevealHost creates a host (optionally bound to a customer) with a vaulted credential.
func seedRevealHost(t *testing.T, st *store.Store, hostID, customerID, secret string) {
t.Helper()
if err := st.UpsertHost(&store.Host{HostID: hostID, CustomerID: customerID, APIKey: "k-" + hostID}); err != nil {
t.Fatal(err)
}
if secret != "" {
if err := st.SaveHostRecoveryCredential(hostID, "root@pam", secret); err != nil {
t.Fatal(err)
}
}
}
// serveReveal drives the REAL stack: RequireAuth → ServeHTTP → routing → handler.
func serveReveal(t *testing.T, s *Server, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
rr := httptest.NewRecorder()
s.RequireAuth(http.HandlerFunc(s.ServeHTTP)).ServeHTTP(rr, req)
return rr
}
func countEvents(t *testing.T, st *store.Store, customerID, eventType string) int {
t.Helper()
evs, err := st.GetRecentEvents(customerID, 100)
if err != nil {
t.Fatal(err)
}
n := 0
for _, e := range evs {
if e.EventType == eventType {
n++
}
}
return n
}
// --- Scenario A: the rendered host page carries presence + username + set_at, NEVER the secret ---
// RED-PROOF A: add `data-secret="{{.RecoverySecret}}"` to the card and a "RecoverySecret" key
// holding cred.Secret to hostDetailData → the canary assertion below goes RED.
func TestReveal_A_PageNeverCarriesTheSecret(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
req := httptest.NewRequest(http.MethodGet, "/hosts/demo-felhom-8363b5", nil)
req.AddCookie(cookie)
rr := serveReveal(t, s, req)
if rr.Code != http.StatusOK {
t.Fatalf("host page = %d, want 200", rr.Code)
}
body := rr.Body.String()
// THE load-bearing assertion — the whole response, attributes, comments, scripts and all.
if strings.Contains(body, revealCanary) {
t.Fatal("SECRET LEAK: the vaulted console password appears in the rendered host page")
}
for _, want := range []string{
"Console access",
"root@pam",
"/hosts/demo-felhom-8363b5/reveal-recovery-credential",
"Reveal",
} {
if !strings.Contains(body, want) {
t.Errorf("host page is missing %q", want)
}
}
}
// --- Copy must work WITHOUT a reveal (regression, 2026-07-31) ---
//
// THE INCIDENT: Copy shipped `disabled` until a Reveal. Clicking it did nothing, silently, so the
// operator's clipboard kept its previous contents — ANOTHER HOST'S console password — which was then
// pasted into a PVE login that failed with no explanation. The box logged a plain
// `password check failed for user (root)` and the credential was never at fault.
//
// Copying without revealing is also the SAFER path: the secret never renders on screen, so it cannot
// be shoulder-surfed or captured in a screenshot.
//
// RED-PROOF: restore `disabled` on the Copy button → this test goes red.
func TestReveal_CopyIsNotGatedOnReveal(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedRevealHost(t, st, "demo-hp-bb76ea", "demo-hp", revealCanary)
req := httptest.NewRequest(http.MethodGet, "/hosts/demo-hp-bb76ea", nil)
req.AddCookie(cookie)
body := serveReveal(t, s, req).Body.String()
// Locate the Copy button and assert it ships ENABLED.
i := strings.Index(body, `id="console-copy-demo-hp-bb76ea"`)
if i < 0 {
t.Fatal("no Copy button on the card")
}
end := strings.Index(body[i:], ">")
if end < 0 {
t.Fatal("malformed Copy button tag")
}
tag := body[i : i+end]
if strings.Contains(tag, "disabled") {
t.Fatalf("the Copy button ships DISABLED — clicking it is a silent no-op that leaves a stale "+
"secret in the clipboard: %s", tag)
}
// It must still be the case that no secret is in the document.
if strings.Contains(body, revealCanary) {
t.Fatal("SECRET LEAK: enabling Copy put the password in the page")
}
// And the copy path must be wired to the SAME audited endpoint, not a second one.
if !strings.Contains(body, "copyConsolePassword('demo-hp-bb76ea')") {
t.Error("the Copy button is not wired to a handler")
}
// The endpoint URL must be DEFINED exactly once (the data-reveal-url attribute); the script
// reads it back with getAttribute rather than rebuilding it, so Copy cannot drift onto a
// different — unaudited — path than Reveal.
if n := strings.Count(body, "/hosts/demo-hp-bb76ea/reveal-recovery-credential"); n != 1 {
t.Errorf("the retrieval URL is written %d times; it must be defined once and read back", n)
}
}
// Every failure branch of the copy path must report itself. A disabled button, a missing clipboard
// API and a refused clipboard write all previously ended in silence.
func TestReveal_CopyPathHasNoSilentFailureBranch(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedRevealHost(t, st, "demo-hp-bb76ea", "demo-hp", revealCanary)
req := httptest.NewRequest(http.MethodGet, "/hosts/demo-hp-bb76ea", nil)
req.AddCookie(cookie)
body := serveReveal(t, s, req).Body.String()
for _, want := range []struct{ frag, why string }{
{"will not give the page clipboard access", "no clipboard API → must say so, not no-op"},
{"clipboard write was refused", "a rejected writeText → must never claim success"},
{"Could not copy the credential", "a failed fetch → must surface the status"},
{"Copied ", "a SUCCESSFUL copy must confirm, or the operator cannot tell it worked"},
} {
if !strings.Contains(body, want.frag) {
t.Errorf("missing outcome message %q (%s)", want.frag, want.why)
}
}
// The confirmation must NAME the host: the clipboard is fleet-wide and every box has a different
// console password, so "copied" alone cannot say copied for WHICH box — the exact confusion that
// produced the incident.
if !strings.Contains(body, "' + hostID + '") {
t.Error("the copy confirmation does not name the host it copied for")
}
}
// --- Scenario B: reveal delivers the secret, records exactly one event, and never logs it ---
// RED-PROOF B: delete the SaveEvent call in handleHostRevealRecoveryCredential → the event
// assertion goes RED.
func TestReveal_B_RevealDeliversAndAudits(t *testing.T) {
s, st, logBuf := newRevealServer(t)
cookie, csrf := newRevealSession(t, s)
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
req := httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", csrf)
rr := serveReveal(t, s, req)
if rr.Code != http.StatusOK {
t.Fatalf("reveal = %d, want 200 (body %q)", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", got)
}
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var got struct {
HostID string `json:"host_id"`
Username string `json:"username"`
Password string `json:"password"`
SetAt string `json:"set_at"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v (body %q)", err, rr.Body.String())
}
if got.Password != revealCanary {
t.Errorf("password = %q, want the vaulted secret", got.Password)
}
if got.Username != "root@pam" || got.HostID != "demo-felhom-8363b5" {
t.Errorf("unexpected payload: %+v", got)
}
if got.SetAt == "" {
t.Error("set_at missing — the operator cannot judge staleness without it")
}
// Exactly ONE timeline row, on the bound customer, info severity, source "hub", Hungarian.
evs, err := st.GetRecentEvents("demo-felhom", 100)
if err != nil {
t.Fatal(err)
}
n := 0
for _, e := range evs {
if e.EventType != "recovery_credential_revealed" {
continue
}
n++
if e.Severity != "info" {
t.Errorf("severity = %q, want info", e.Severity)
}
if e.Source != "hub" {
t.Errorf("source = %q, want hub", e.Source)
}
if !strings.Contains(e.Message, "konzolos hozzáférési jelszavát") {
t.Errorf("message is not the Hungarian customer-facing line: %q", e.Message)
}
if strings.Contains(e.Message, revealCanary) || strings.Contains(e.DetailsJSON, revealCanary) {
t.Fatal("SECRET LEAK: the password is in the event row")
}
}
if n != 1 {
t.Errorf("recovery_credential_revealed rows = %d, want exactly 1", n)
}
// The hub log records the access — username + length only.
if strings.Contains(logBuf.String(), revealCanary) {
t.Fatal("SECRET LEAK: the password reached the hub log")
}
if !strings.Contains(logBuf.String(), "operator revealed break-glass console credential") {
t.Errorf("the access is not recorded in the hub log: %q", logBuf.String())
}
// No dispatcher call exists on this path — nothing was emailed.
notifs, err := st.GetRecentNotifications("demo-felhom", 100)
if err != nil {
t.Fatal(err)
}
if len(notifs) != 0 {
t.Errorf("reveal produced %d notification_log row(s); it must email nobody", len(notifs))
}
}
// --- Scenario C: nothing vaulted → the explanatory card, no control, 404 on POST, zero events ---
func TestReveal_C_NotVaulted(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, csrf := newRevealSession(t, s)
seedRevealHost(t, st, "sess-f-2670b5", "sess-f", "") // no credential row
req := httptest.NewRequest(http.MethodGet, "/hosts/sess-f-2670b5", nil)
req.AddCookie(cookie)
body := serveReveal(t, s, req).Body.String()
if !strings.Contains(body, "not vaulted") {
t.Error("the not-vaulted state does not render its badge")
}
if !strings.Contains(body, "byo host") {
t.Error("the not-vaulted state does not explain WHY (byo / step 4b)")
}
if strings.Contains(body, "/hosts/sess-f-2670b5/reveal-recovery-credential") {
t.Error("a Reveal control is offered for a host with nothing vaulted")
}
req = httptest.NewRequest(http.MethodPost, "/hosts/sess-f-2670b5/reveal-recovery-credential", nil)
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", csrf)
rr := serveReveal(t, s, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("reveal on an unvaulted host = %d, want 404", rr.Code)
}
// A 404 is not an access: only a DELIVERED secret is recorded.
if n := countEvents(t, st, "sess-f", "recovery_credential_revealed"); n != 0 {
t.Errorf("a 404 wrote %d event row(s); it must write none", n)
}
}
// --- Scenario D: the CSRF gate (security) ---
// RED-PROOF D: the companion below sends the token and asserts 200, proving this test
// discriminates on CSRF rather than passing because the request was malformed some other way.
func TestReveal_D_CSRFRequired(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, csrf := newRevealSession(t, s)
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
req := httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
req.AddCookie(cookie) // session, but NO CSRF token
rr := serveReveal(t, s, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("reveal without CSRF = %d, want 403", rr.Code)
}
if strings.Contains(rr.Body.String(), revealCanary) {
t.Fatal("SECRET LEAK: the 403 body carries the password")
}
if n := countEvents(t, st, "demo-felhom", "recovery_credential_revealed"); n != 0 {
t.Errorf("a CSRF refusal wrote %d event row(s); it must write none", n)
}
// The discriminator: the SAME request WITH the token succeeds.
req = httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", csrf)
if rr := serveReveal(t, s, req); rr.Code != http.StatusOK {
t.Fatalf("the same request WITH a CSRF token = %d, want 200 — the 403 above proves nothing", rr.Code)
}
}
// --- Scenario E: the method gate — and, through it, the ROUTE ORDER (seam test) ---
// RED-PROOF E: move the new case BELOW `case strings.HasPrefix(path, "/hosts/")` → the GET falls
// through to the catch-all, renders the host detail page 200, and this test goes RED.
func TestReveal_E_MethodGateAndRouteOrder(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
req := httptest.NewRequest(http.MethodGet, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
req.AddCookie(cookie)
rr := serveReveal(t, s, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET on the reveal route = %d, want 405", rr.Code)
}
// The route-order half: a fall-through to /hosts/ would render the host page instead.
if strings.Contains(rr.Body.String(), "Console access") {
t.Fatal("the reveal route fell through to the /hosts/ catch-all — the case is registered AFTER it")
}
if strings.Contains(rr.Body.String(), revealCanary) {
t.Fatal("SECRET LEAK: the 405 body carries the password")
}
}
// --- Scenario F: unknown host ---
func TestReveal_F_UnknownHost(t *testing.T) {
s, _, _ := newRevealServer(t)
cookie, csrf := newRevealSession(t, s)
req := httptest.NewRequest(http.MethodPost, "/hosts/does-not-exist/reveal-recovery-credential", nil)
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", csrf)
if rr := serveReveal(t, s, req); rr.Code != http.StatusNotFound {
t.Fatalf("reveal on an unknown host = %d, want 404", rr.Code)
}
}
// --- Scenario G: unauthenticated ---
func TestReveal_G_Unauthenticated(t *testing.T) {
s, st, _ := newRevealServer(t)
seedRevealHost(t, st, "demo-felhom-8363b5", "demo-felhom", revealCanary)
req := httptest.NewRequest(http.MethodPost, "/hosts/demo-felhom-8363b5/reveal-recovery-credential", nil)
req.Header.Set("X-Requested-With", "XMLHttpRequest") // API-like → 401 rather than a login redirect
rr := serveReveal(t, s, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated reveal = %d, want 401", rr.Code)
}
if strings.Contains(rr.Body.String(), revealCanary) {
t.Fatal("SECRET LEAK: the 401 body carries the password")
}
if n := countEvents(t, st, "demo-felhom", "recovery_credential_revealed"); n != 0 {
t.Errorf("an unauthenticated call wrote %d event row(s); it must write none", n)
}
}
// --- Edge case (§8): an UNBOUND host reveals fine and writes no event (SaveEvent needs a customer) ---
func TestReveal_UnboundHostRevealsWithoutAnEvent(t *testing.T) {
s, st, logBuf := newRevealServer(t)
cookie, csrf := newRevealSession(t, s)
seedRevealHost(t, st, "unbound-01", "", revealCanary)
req := httptest.NewRequest(http.MethodPost, "/hosts/unbound-01/reveal-recovery-credential", nil)
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", csrf)
rr := serveReveal(t, s, req)
if rr.Code != http.StatusOK {
t.Fatalf("reveal on an unbound host = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), revealCanary) {
t.Error("the unbound host's secret was not delivered")
}
// No placeholder customer id is invented — the hub log is the only record.
if n := countEvents(t, st, "", "recovery_credential_revealed"); n != 0 {
t.Errorf("an unbound host wrote %d event row(s) against an empty customer id", n)
}
if !strings.Contains(logBuf.String(), "unbound-01") {
t.Error("the unbound host's reveal is recorded nowhere at all")
}
}