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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -352,10 +352,10 @@
|
||||
<div class="credential-box">
|
||||
<code id="console-pw-{{.HostID}}">••••••••••••••••</code>
|
||||
<button type="button" class="copy-btn" id="console-reveal-{{.HostID}}" data-reveal-url="/hosts/{{.HostID}}/reveal-recovery-credential" onclick="revealConsolePassword('{{.HostID}}')">Reveal</button>
|
||||
<button type="button" class="copy-btn" id="console-copy-{{.HostID}}" onclick="copyConsolePassword('{{.HostID}}')" disabled>Copy</button>
|
||||
<button type="button" class="copy-btn" id="console-copy-{{.HostID}}" onclick="copyConsolePassword('{{.HostID}}')">Copy</button>
|
||||
</div>
|
||||
<p class="hint" id="console-hint-{{.HostID}}" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.5rem;">
|
||||
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). <strong>Copy</strong> puts it straight on the clipboard without showing it; <strong>Reveal</strong> 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.
|
||||
</p>
|
||||
{{else}}
|
||||
<p><span class="badge badge-neutral">not vaulted</span></p>
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user