From f61b88f942901dc9d723d98b9777226cb5513ea5 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 7 Oct 2026 19:42:25 +0200 Subject: [PATCH] feat(gateway): refuse credentials on /bns/ path form (4-layer defense) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared origin on navigate.st means a hostile BCDN co-tenant can read anything a password manager autofills, dangerously stale PIN pads, and every seed phrase a confused user pastes. Make the whole shape go away: the path form must not accept any credential-shaped input, period. The subdomain form (isolated origin) keeps full functionality. Four layers: 1. Permissions-Policy denies the WebAuthn credential APIs: publickey-credentials-get=() publickey-credentials-create=() identity-credentials-get=() so no BCDN page can register or use a passkey while bound to the shared navigate.st origin. 2. Server-side HTML rewrite: every in the served response is rewritten to disabled+readonly+autocomplete="off" with an explanatory placeholder and title tooltip, plus a data-nav-relay-blocked="password" marker. Catches first-paint fields before any JS runs. 3. Runtime guard (added to the relay-chrome boot script): * MutationObserver re-disables password inputs added dynamically or re-enabled by site JS (SPAs, late-rendered forms). * `paste` listener classifies clipboard content; if it looks like a BIP39 seed phrase (12 or 24 lowercase 3-8 letter words), hex key ≥ 64 chars, WIF, or BIP32 xprv/zprv/yprv/tprv/uprv/vprv — paste is blocked and a scary floating warning shows. * `input` listener runs the same classifier on typed material and blanks the field + warns on hit. * navigator.credentials.{create,get} shimmed to reject with a readable NotAllowedError, so a site that bypasses the Permissions header gets a clear message instead of silent failure. 4. Visual: the trust-chip pop message now says "Password fields, seed phrases, and private keys are refused here — open the isolated view to sign in." so the user understands why their input vanished. Only runs on the PATH form (opts.pathForm). Subdomain form left alone; curl -s https://sirius-x.bch.navigate.st/_pwtest.html still shows the raw , while the same URL under /bns/sirius.x/ gets the disabled+readonly rewrite. --- src/gateway/public-gateway.mjs | 163 ++++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 2 deletions(-) diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 230239b..8ac86db 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -464,6 +464,28 @@ function rewriteBnsLinks(html) { } ); } +// Disable every in served HTML on the path form. +// The gateway's shared origin means a hostile BCDN co-tenant could read +// anything a password manager autofills, so the only safe behaviour is +// to not accept passwords at all through /bns/. Credentials belong on the +// isolated subdomain form; the gateway's `rewritePasswordFields` makes +// that posture explicit at first paint. Runtime also catches dynamic +// adds — see injectRelayChrome below. +function rewritePasswordFields(html) { + return html.replace( + /]*?)\btype\s*=\s*(["']?)password\2([^>]*)>/gi, + (m, pre, _q, post) => { + // Keep the input in the DOM so the page's layout doesn't collapse, + // but disable it, blank the autocomplete hint, and overlay a title + // the user sees on hover. The runtime guard will mirror this on + // elements added after load. + return ``; + } + ); +} // Strip `.html` from relative link targets in served HTML — so a link // written as href="tld.html" reads as href="tld" in the browser bar. // Only touches non-absolute URLs (leaves `https://…/x.html` alone — @@ -501,7 +523,12 @@ const RELAY_SECURITY_HEADERS = Object.freeze({ "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=(), " + "accelerometer=(), gyroscope=(), magnetometer=(), autoplay=(), " + - "interest-cohort=(), browsing-topics=()", + "interest-cohort=(), browsing-topics=(), " + + // WebAuthn credential APIs — blocked on relayed origins so no BCDN + // page can register a passkey or complete a login that would persist + // the credential against the shared navigate.st origin. Users who + // want passkeys use the subdomain form (its own origin). + "publickey-credentials-get=(), publickey-credentials-create=(), identity-credentials-get=()", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "x-frame-options": "SAMEORIGIN", @@ -570,7 +597,7 @@ function injectRelayChrome(html, name, reqPath, subBase, opts) { `; @@ -616,6 +643,131 @@ ${pathForm ? `⚠ Blocked on the relay" + message + ""; + w.style.display = "block"; + clearTimeout(flashWarning._t); + flashWarning._t = setTimeout(function() { if (w) w.style.display = "none"; }, 8000); + } + + function neuterPasswordInput(el) { + if (!el || el.dataset && el.dataset.navRelayBlocked === "password") return; + try { + el.disabled = true; + el.readOnly = true; + el.value = ""; + el.setAttribute("autocomplete", "off"); + el.setAttribute("data-nav-relay-blocked", "password"); + el.setAttribute("placeholder", "Blocked on the navigate.st relay — use the isolated view"); + el.setAttribute("title", "Credentials cannot be entered through the relay"); + } catch (e) {} + } + + // Classify a string for sensitive-looking content. Returns a label or + // null. Checks are deliberately conservative (chosen to catch BCH / + // ETH / BIP39 shapes without flagging URLs or UUIDs). + function sensitiveKind(raw) { + if (!raw) return null; + var s = String(raw).trim(); + if (s.length < 12) return null; + // Hex ≥ 64 chars: private keys (32 bytes = 64 hex), seed hex (64 or 128). + if (/^[0-9a-fA-F]{64,}$/.test(s)) return "a hex private key"; + // WIF (compressed starts K/L, 52 chars; uncompressed starts 5, 51 chars). + if (/^[KL][1-9A-HJ-NP-Za-km-z]{51}$/.test(s)) return "a WIF private key"; + if (/^5[1-9A-HJ-NP-Za-km-z]{50}$/.test(s)) return "a WIF private key"; + // xprv / zprv / tprv etc. — BIP32 extended private keys. + if (/^(xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{100,}$/.test(s)) return "an extended private key"; + // BIP39 seed phrase heuristic: 12 or 24 lowercase words, each 3-8 + // letters, no numbers or punctuation. Covers English wordlist shape + // without shipping the 16KB wordlist itself. + var words = s.toLowerCase().split(/\s+/); + if ((words.length === 12 || words.length === 24) && + words.every(function(w) { return /^[a-z]{3,8}$/.test(w); })) { + return "a recovery seed phrase"; + } + return null; + } + + function scanAndNeuter(root) { + try { + (root && root.querySelectorAll ? root : document).querySelectorAll('input[type="password"]').forEach(neuterPasswordInput); + } catch (e) {} + } + scanAndNeuter(document); + + // Dynamic adds (SPAs render after load, or a site may toggle disabled off). + try { + var mo = new MutationObserver(function(muts) { + for (var i = 0; i < muts.length; i++) { + var m = muts[i]; + if (m.type === "attributes" && m.target && m.target.matches && m.target.matches('input[type="password"]')) { + neuterPasswordInput(m.target); + } else if (m.addedNodes) { + m.addedNodes.forEach(function(n) { if (n && n.nodeType === 1) scanAndNeuter(n); }); + } + } + }); + mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["type", "disabled", "readonly"] }); + } catch (e) {} + + // Paste intercept — catches someone pasting a seed phrase or private + // key into ANY field. Blocks the paste before it lands. + document.addEventListener("paste", function(e) { + try { + var text = (e.clipboardData || window.clipboardData).getData("text"); + var kind = sensitiveKind(text); + if (kind) { + e.preventDefault(); + flashWarning("Rejected a paste that looks like " + kind + ". Secrets must never be entered through the public relay — open this site in its isolated view first."); + } + } catch (_) {} + }, true); + + // Typed-input intercept — catches slow typing of a seed phrase or + // a key manually. Runs on every input/change event. + document.addEventListener("input", function(e) { + var t = e.target; + if (!t || !t.value) return; + if (t.tagName !== "INPUT" && t.tagName !== "TEXTAREA") return; + var kind = sensitiveKind(t.value); + if (kind) { + t.value = ""; + flashWarning("Rejected " + kind + " entered on the relay. Open this site in its isolated view before entering secrets."); + } + }, true); + + // Credential APIs — defence in depth on top of the Permissions-Policy + // header. If a site tries anyway, give it a readable rejection. + try { + if (navigator.credentials) { + var reject = function() { return Promise.reject(new DOMException("Credentials API blocked on the navigate.st relay — use the isolated view.", "NotAllowedError")); }; + Object.defineProperty(navigator.credentials, "create", { value: reject, writable: false, configurable: false }); + Object.defineProperty(navigator.credentials, "get", { value: reject, writable: false, configurable: false }); + } + } catch (_) {} + // Full-banner wiring (path form only). var banner = document.getElementById("__nav_relay_banner"); var dismissBtn = document.getElementById("__nav_relay_dismiss"); @@ -696,6 +848,13 @@ ${pathForm ? `