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:
2026-07-31 09:22:11 +02:00
parent 9e079c7883
commit 670ec35ece
3 changed files with 181 additions and 23 deletions
@@ -352,10 +352,10 @@
<div class="credential-box">
<code id="console-pw-{{.HostID}}">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</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://&lt;host-ip&gt;: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://&lt;host-ip&gt;: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&nbsp;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') {