GL-7 Part 1: passphrase hardening on the customer page (security)

The Setup Command panel rendered the per-customer retrieval passphrase (the
secret that fetches the WHOLE config) in cleartext twice — as #retrieval-pw
text and baked into the Option-3 curl's X-Retrieval-Password header — which
contradicts the panel's own "never on the command line" guidance. Now:
the retrieval password is MASKED by default (bullet run) with Reveal/Hide +
copy-secret controls (value lives in data-secret — the existing reveal
model); the Option-3 debug command carries a <YOUR-RETRIEVAL-PASSWORD>
placeholder, never the secret. Render test asserts the secret is not baked
into any command + is masked by default; red-proof (bake it back) FAILS.
(A zero-secret-in-DOM reveal-on-demand fetch is a noted follow-up, not this task.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 08:23:41 +02:00
parent fb8e5111cf
commit b93dd03f0a
2 changed files with 93 additions and 4 deletions
+61
View File
@@ -113,3 +113,64 @@ func TestTemplates_DashboardCriticalBadge(t *testing.T) {
t.Errorf("critical badge renders AFTER the error badge (crit@%d, err@%d) — must be first", critIdx, errIdx)
}
}
// GL-7 Part 1: the retrieval passphrase must be MASKED by default and NEVER baked into a copyable
// command block. The value still ships in data-secret (the reveal/copy mechanism — the existing
// model), but the Option-3 debug curl must carry a placeholder, not the secret.
// RED-PROOF: revert the Option-3 block to `X-Retrieval-Password: {{.Config.RetrievalPassword}}`
// (or the #retrieval-pw code back to the raw value) → the secret appears in a command / unmasked →
// the "not in the -H command" / masked assertions FAIL.
func TestTemplates_PassphraseHardened(t *testing.T) {
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() })
const secret = "correct-horse-battery-staple-9f2a"
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "peti-felhom", CustomerName: "Peti", Domain: "sajatfelhom.hu",
RetrievalPassword: secret, APIKey: "apikey-xyz", Status: "active",
}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
s := New(st, "", "", "test", time.Hour, log.New(io.Discard, "", 0))
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/configs/peti-felhom", nil)
s.handleCustomerUnified(rr, req, "peti-felhom")
if rr.Code != 200 {
t.Fatalf("customer page status = %d", rr.Code)
}
html := rr.Body.String()
// (a) the secret must NOT appear inside the Option-3 curl command (no X-Retrieval-Password: <secret>).
if strings.Contains(html, "X-Retrieval-Password: "+secret) {
t.Errorf("passphrase is baked into the Option-3 command (must be a placeholder)")
}
// The Option-3 command carries the placeholder instead.
if !strings.Contains(html, "YOUR-RETRIEVAL-PASSWORD") {
t.Errorf("Option-3 command missing the passphrase placeholder")
}
// (b) the visible retrieval-pw node is masked by default (bullets), not the cleartext value.
if !strings.Contains(html, `id="retrieval-pw"`) {
t.Fatalf("retrieval-pw node missing")
}
// the reveal control + copy-secret wiring must be present (the value lives in data-secret).
if !strings.Contains(html, `onclick="toggleSecret('retrieval-pw')"`) ||
!strings.Contains(html, `onclick="copySecret('retrieval-pw')"`) {
t.Errorf("reveal/copy-secret controls missing")
}
if !strings.Contains(html, `data-secret="`+secret+`"`) {
t.Errorf("data-secret not populated for the reveal control")
}
// the DEFAULT visible masked text is a run of bullet entities; the raw secret is only in
// data-secret, never the code node's visible text content.
i := strings.Index(html, `id="retrieval-pw"`)
codeText := html[i : strings.Index(html[i:], "</code>")+i]
if !strings.Contains(codeText, "&#x2022;&#x2022;&#x2022;") {
t.Errorf("retrieval-pw is not masked by default (no bullet-entity run)")
}
if strings.Contains(codeText[strings.Index(codeText, ">")+1:], secret) {
t.Errorf("raw secret is the retrieval-pw node's visible default text (must be masked)")
}
}
@@ -360,9 +360,11 @@
<div>
<span class="label">Retrieval Password</span>
<div class="credential-box">
<code id="retrieval-pw">{{.Config.RetrievalPassword}}</code>
<button type="button" class="copy-btn" onclick="copyText('retrieval-pw')" title="Copy">&#x2398;</button>
<code id="retrieval-pw" data-secret="{{.Config.RetrievalPassword}}">&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;</code>
<button type="button" class="copy-btn" id="reveal-retrieval-pw" onclick="toggleSecret('retrieval-pw')" title="Reveal / hide">Reveal</button>
<button type="button" class="copy-btn" onclick="copySecret('retrieval-pw')" title="Copy">&#x2398;</button>
</div>
<span class="form-hint">The per-customer secret that fetches the whole config — masked by default; never place it on a command line (the installer reads it at a no-echo prompt).</span>
</div>
<form method="POST" action="/configs/{{.CustomerID}}/regen-password" style="margin-top: 0.5rem;"
onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')">
@@ -410,9 +412,9 @@
</div>
<h3 style="margin-top: 1rem;">Option 3: Manual config fetch (debug only)</h3>
<p class="text-muted" style="margin: 0 0 0.4rem; font-size: 0.8rem;">The same payload the controller pulls itself — for inspection, not normal provisioning.</p>
<p class="text-muted" style="margin: 0 0 0.4rem; font-size: 0.8rem;">The same payload the controller pulls itself — for inspection, not normal provisioning. Replace the placeholder with the Retrieval Password above (Reveal to see it) — it is intentionally NOT baked into this command.</p>
<div class="credential-box">
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml</code>
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: &lt;YOUR-RETRIEVAL-PASSWORD&gt;" -o controller.yaml</code>
<button type="button" class="copy-btn" onclick="copyText('cmd-curl')" title="Copy">&#x2398;</button>
</div>
</section>
@@ -694,6 +696,32 @@
});
}
// Masked-secret controls (retrieval password): the value lives in data-secret and is masked in
// the visible node by default; Reveal toggles it, Copy copies the real value. It is never baked
// into a copyable command (see the Option-3 placeholder).
var _secretMask = '•'.repeat(16);
function toggleSecret(elementId) {
var el = document.getElementById(elementId);
var btn = document.getElementById('reveal-' + elementId);
if (el.dataset.revealed === '1') {
el.textContent = _secretMask; el.dataset.revealed = '0';
if (btn) btn.textContent = 'Reveal';
} else {
el.textContent = el.getAttribute('data-secret') || ''; el.dataset.revealed = '1';
if (btn) btn.textContent = 'Hide';
}
}
function copySecret(elementId) {
var el = document.getElementById(elementId);
var val = (el.getAttribute('data-secret') || '').trim();
navigator.clipboard.writeText(val).then(function() {
var btn = el.parentElement.querySelectorAll('.copy-btn');
var b = btn[btn.length - 1]; var orig = b.innerHTML;
b.innerHTML = '&#x2713;';
setTimeout(function() { b.innerHTML = orig; }, 1500);
});
}
function disableGeo(customerID) {
if (!confirm('Összes geo-korlátozás eltávolítása?\n\nEz közvetlenül törli a Cloudflare WAF szabályokat és értesíti a controllert.')) return;
var btn = document.getElementById('btn-geo-disable');