From b93dd03f0a613e48422bc0745e4c2731ddbf9412 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 9 Jul 2026 08:23:41 +0200 Subject: [PATCH] GL-7 Part 1: passphrase hardening on the customer page (security) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- hub/internal/web/render_test.go | 61 +++++++++++++++++++ .../web/templates/customer_unified.html | 36 +++++++++-- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/hub/internal/web/render_test.go b/hub/internal/web/render_test.go index ae2c36d..6580632 100644 --- a/hub/internal/web/render_test.go +++ b/hub/internal/web/render_test.go @@ -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: ). + 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:], "")+i] + if !strings.Contains(codeText, "•••") { + 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)") + } +} diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index be817f3..da0b6c2 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -360,9 +360,11 @@
Retrieval Password
- {{.Config.RetrievalPassword}} - + •••••••••••••••• + +
+ 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).
@@ -410,9 +412,9 @@

Option 3: Manual config fetch (debug only)

-

The same payload the controller pulls itself — for inspection, not normal provisioning.

+

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.

- curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml + curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: <YOUR-RETRIEVAL-PASSWORD>" -o controller.yaml
@@ -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 = '✓'; + 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');