Files
felhom.eu/hub/internal/web/hosts_recovery_reveal_test.go
T
admin 1956e5d390 hub v0.84.0 — break-glass console credential on the host page
The credential existed and was not reachable when it was wanted. Every box has
had a strong random root@pam password since TASK G1, vaulted in the hub at day 0
and used for real during the sshd incident — but the only way to read it back was
a hand-written curl carrying the global operator key, a secret kept out-of-band.
In practice the PVE web console on a demo box felt locked.

The host page grows a Console access card: presence + username + set_at by
default, Reveal fetches the plaintext on demand for 60 s with a Copy button.
Masking clears the JS variable, and also fires on a second click and on
visibilitychange. A host with nothing vaulted says so, and says why.

The secret is NEVER rendered into the page, and that constraint shapes the
change. The render path uses a new store.GetHostRecoveryMeta whose struct and
SELECT both omit the secret column, so it is structurally incapable of carrying
one. The plaintext crosses the wire only in the response to POST
/hosts/{id}/reveal-recovery-credential (Cache-Control: no-store, CSRF-gated at
the ServeHTTP level; POST precisely so that gate applies and so no secret is
retrievable by URL alone). Deliberately NOT the customer page's data-secret
widget, which embeds the plaintext on every load.

A delivered reveal writes one recovery_credential_revealed event on the host's
customer timeline (info, source hub, Hungarian) via SaveEvent alone — no
dispatcher, nobody emailed, the log_tail_requested shape. Two reveals write two
events: the register records accesses, not states. A 404 is not an access. An
unbound host reveals fine and writes no event; the [INFO] hub line, carrying the
username and a length only, is then the record.

The global-key API path is untouched by design — it is the route for when the
hub UI itself is broken, and coupling it to the session layer would delete the
independence that makes it a fallback.

Recorded as a real trade: the hub session password alone now unlocks console root
fleet-wide, where retrieval previously also needed the global key. Accepted for a
single-operator, HU-geo-fenced hub that already stores these passwords in
plaintext at rest (CONTEXT.md ruling S-4). The plaintext-at-rest half is filed as
R-133 — every hub DB backup is a fleet-wide console-credential dump.

Tests 550 -> 559; four red-proofs (page leak, audit event, CSRF gate, route
order) each run, observed failing, and reverted. The route-order proof is a seam
test driving ServeHTTP: a handler-level test cannot see that defect, because the
handler is correct and simply never runs.
2026-07-31 08:19:36 +02:00

361 lines
13 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)
}
}
}
// --- 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")
}
}