diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index dc87bf1..c57410a 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,44 @@ +## v0.86.0 — Copy works without revealing, and every copy branch reports itself (2026-07-31) + +**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, which was **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 there +was nothing on screen to say the copy had not happened. + +**A copy button that silently no-ops is worse than no copy button**, because the operator has no way +to distinguish "copied" from "did nothing" — and the stale value it leaves behind is a *valid secret +for a different machine*, so the resulting failure looks like a stale-credential problem and sends you +diagnosing the wrong thing. + +**Copy now works without revealing — and that is the safer default, not 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 is still there for when you need to read it (typing at a console). + +**Three silent-failure branches closed, all in the same eight-line function:** + +| Branch | Was | Now | +|---|---|---| +| Not yet revealed | button `disabled`, click = no-op | fetches and copies | +| `navigator.clipboard` absent (insecure context) | `if (navigator.clipboard)` → silently skipped | shows the password instead and says why | +| `writeText()` promise REJECTED (permission / no user gesture) | promise ignored — the operator believes it copied | shows the password instead and reports the refusal | + +**The success path now names the host:** *"✓ Copied demo-hp-bb76ea's root@pam password to the +clipboard."* The clipboard is fleet-wide and every box has a different console password, so "copied" +alone cannot say copied for *which* box — precisely the confusion that produced the incident. + +**One retrieval path, shared.** `fetchConsolePassword` is used by both buttons, and the endpoint is +defined once (the `data-reveal-url` attribute) and read back with `getAttribute`, so Copy cannot drift +onto a different — unaudited — URL than Reveal. A test asserts the URL appears exactly once. +Server-side nothing changed: 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, both pinning this regression: the Copy button must not ship `disabled`, and every +outcome branch must carry a message. Red-proof: re-adding `disabled` reproduces the shipped bug and +turns the first test red. + ## v0.85.0 — Network card: a host's addresses are visible at last (2026-07-31) **Pairs with agent v0.119.0 and is useless without it** — the agent is what reports the addresses. diff --git a/hub/internal/web/hosts_recovery_reveal_test.go b/hub/internal/web/hosts_recovery_reveal_test.go index 3cf6d11..cdadb08 100644 --- a/hub/internal/web/hosts_recovery_reveal_test.go +++ b/hub/internal/web/hosts_recovery_reveal_test.go @@ -130,6 +130,88 @@ func TestReveal_A_PageNeverCarriesTheSecret(t *testing.T) { } } +// --- 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. diff --git a/hub/internal/web/templates/host_detail_body.html b/hub/internal/web/templates/host_detail_body.html index 0950181..714e5ac 100644 --- a/hub/internal/web/templates/host_detail_body.html +++ b/hub/internal/web/templates/host_detail_body.html @@ -352,10 +352,10 @@
••••••••••••••••
-
+
- Break-glass credential for the PVE web console at https://<host-ip>:8006 (realm: Linux PAM standard authentication). Revealing it is recorded on the customer's event timeline. Last vaulted value — if root@pam was changed on the box without re-vaulting, this is stale. + Break-glass credential for the PVE web console at https://<host-ip>:8006 (realm: Linux PAM standard authentication). Copy puts it straight on the clipboard without showing it; Reveal displays it for 60 s. Either one is recorded on the customer's event timeline. Last vaulted value — if root@pam was changed on the box without re-vaulting, this is stale.
{{else}}not vaulted
@@ -372,6 +372,11 @@ // re-assignment would drop a sibling card's mask timer on the floor. var consolePwState = typeof consolePwState !== 'undefined' ? consolePwState : {}; var consolePwMask = '••••••••••••••••'; + + function consoleHint(hostID, msg) { + var h = document.getElementById('console-hint-' + hostID); + if (h) { h.textContent = msg; } + } function maskConsolePassword(hostID) { var st = consolePwState[hostID]; if (st && st.timer) { clearTimeout(st.timer); } @@ -380,39 +385,69 @@ if (code) { code.textContent = consolePwMask; } var reveal = document.getElementById('console-reveal-' + hostID); if (reveal) { reveal.textContent = 'Reveal'; } - var copy = document.getElementById('console-copy-' + hostID); - if (copy) { copy.disabled = true; } } - function revealConsolePassword(hostID) { - if (consolePwState[hostID]) { maskConsolePassword(hostID); return; } // second click hides - var hint = document.getElementById('console-hint-' + hostID); - var code = document.getElementById('console-pw-' + hostID); + // fetchConsolePassword is the ONE retrieval path, shared by Reveal and Copy — so Copy cannot + // drift onto a different endpoint, and both are audited identically server-side. + function fetchConsolePassword(hostID, onOK, onErr) { var btn = document.getElementById('console-reveal-' + hostID); - code.textContent = 'Revealing…'; - // The endpoint comes from the button's data-reveal-url — the SAME string a render test - // asserts, so the assertion cannot pass while the fetch targets somewhere else. fetch(btn.getAttribute('data-reveal-url'), { method: 'POST', headers: {'X-CSRF-Token': '{{.CSRFToken}}'} }).then(function(r){ if (!r.ok) { throw new Error('HTTP ' + r.status); } return r.json(); - }).then(function(d){ - code.textContent = d.password; - consolePwState[hostID] = {pw: d.password, timer: setTimeout(function(){ maskConsolePassword(hostID); }, 60000)}; - document.getElementById('console-reveal-' + hostID).textContent = 'Hide'; - document.getElementById('console-copy-' + hostID).disabled = false; - }).catch(function(e){ - code.textContent = consolePwMask; - hint.textContent = 'Could not reveal the credential (' + e.message + '). The global-key curl path in the break-glass runbook §3.1 still works.'; + }).then(function(d){ onOK(d.password); }).catch(onErr); + } + function showConsolePassword(hostID, pw) { + document.getElementById('console-pw-' + hostID).textContent = pw; + consolePwState[hostID] = {pw: pw, timer: setTimeout(function(){ maskConsolePassword(hostID); }, 60000)}; + document.getElementById('console-reveal-' + hostID).textContent = 'Hide'; + } + function revealConsolePassword(hostID) { + if (consolePwState[hostID]) { maskConsolePassword(hostID); return; } // second click hides + document.getElementById('console-pw-' + hostID).textContent = 'Revealing…'; + fetchConsolePassword(hostID, function(pw){ + showConsolePassword(hostID, pw); + }, function(e){ + document.getElementById('console-pw-' + hostID).textContent = consolePwMask; + consoleHint(hostID, 'Could not reveal the credential (' + e.message + '). The global-key curl path in the break-glass runbook §3.1 still works.'); }); } + // Copy works WITHOUT revealing — the safer default, since the secret never renders on screen + // and so never lands in a screenshot or a shoulder-surf. + // + // Every branch below reports its outcome. The version this replaced was disabled until a + // Reveal and did nothing when clicked, which left the operator's clipboard holding whatever + // was in it before — in the incident that prompted this, ANOTHER HOST'S console password, + // pasted into a login that then failed with no clue why. A copy button that silently + // no-ops is worse than no copy button. function copyConsolePassword(hostID) { var st = consolePwState[hostID]; - if (!st) { return; } - var pw = st.pw; - if (navigator.clipboard) { navigator.clipboard.writeText(pw); } - maskConsolePassword(hostID); // re-mask after use + if (st) { writeConsoleClipboard(hostID, st.pw); return; } // already revealed — reuse it + consoleHint(hostID, 'Copying…'); + fetchConsolePassword(hostID, function(pw){ + writeConsoleClipboard(hostID, pw); + }, function(e){ + consoleHint(hostID, 'Could not copy the credential (' + e.message + '). The global-key curl path in the break-glass runbook §3.1 still works.'); + }); + } + function writeConsoleClipboard(hostID, pw) { + // No clipboard API at all (an insecure context) — degrade to showing it, and SAY so. + if (!navigator.clipboard || !navigator.clipboard.writeText) { + showConsolePassword(hostID, pw); + consoleHint(hostID, 'This browser will not give the page clipboard access, so the password is shown instead — copy it manually. It hides again in 60 s.'); + return; + } + navigator.clipboard.writeText(pw).then(function(){ + maskConsolePassword(hostID); + // Name the HOST in the confirmation: the clipboard is fleet-wide and every box has a + // different console password, so "copied" alone does not say copied for WHICH box. + consoleHint(hostID, '✓ Copied ' + hostID + '\u2019s root@pam password to the clipboard. It stays there until you copy something else.'); + }).catch(function(e){ + // The write was REFUSED (permissions, or not a user gesture). Never claim success. + showConsolePassword(hostID, pw); + consoleHint(hostID, 'The clipboard write was refused (' + (e && e.message ? e.message : 'no reason given') + '), so the password is shown instead — copy it manually. It hides again in 60 s.'); + }); } document.addEventListener('visibilitychange', function(){ if (document.visibilityState === 'hidden') {