feat(gateway): refuse credentials on /bns/ path form (4-layer defense)

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 <input type="password"> 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 <input type=password>, while the same URL under /bns/sirius.x/ gets
the disabled+readonly rewrite.
This commit is contained in:
Local Dev 2026-10-07 19:42:25 +02:00
parent 384d3dd3c7
commit f61b88f942

View file

@ -464,6 +464,28 @@ function rewriteBnsLinks(html) {
}
);
}
// Disable every <input type="password"> 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(
/<input\b([^>]*?)\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 `<input${pre}type="password" disabled readonly autocomplete="off" ` +
`placeholder="Blocked on the navigate.st relay — use the isolated view" ` +
`title="Credentials cannot be entered through the relay — scripts here run under the gateway's shared origin. Open this site in its isolated view to sign in." ` +
`data-nav-relay-blocked="password"${post}>`;
}
);
}
// 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) {
</button>
<div id="__nav_relay_chip_pop" role="dialog" aria-label="Relay info" hidden style="all:initial;display:none;position:absolute;bottom:calc(100% + 8px);right:0;width:280px;background:#1a2030;color:#e7eaf1;border:1px solid rgba(214,255,61,.3);border-radius:12px;padding:14px 16px;box-shadow:0 10px 28px rgba(0,0,0,.45);font:13px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">
<div style="all:initial;display:block;color:#d6ff3d;font-weight:600;margin-bottom:6px;font:inherit">Relayed site</div>
<div style="all:initial;display:block;color:#e7eaf1;margin-bottom:10px;font:inherit"><b style="all:initial;color:#fff;font-weight:600;font:inherit">${esc(name)}</b> is being served through <b style="all:initial;color:#fff;font-weight:600;font:inherit">navigate.st</b>. ${pathForm ? "Scripts here run under the gateway's origin — do not sign in or paste a recovery phrase." : "Each name has its own origin here. Scripts can't reach other BCDN sites."}</div>
<div style="all:initial;display:block;color:#e7eaf1;margin-bottom:10px;font:inherit"><b style="all:initial;color:#fff;font-weight:600;font:inherit">${esc(name)}</b> is being served through <b style="all:initial;color:#fff;font-weight:600;font:inherit">navigate.st</b>. ${pathForm ? "Scripts here run under the gateway's shared origin. <b style=\"all:initial;color:#ff8f9c;font:inherit;font-weight:600\">Password fields, seed phrases, and private keys are refused here</b> — open the isolated view to sign in." : "Each name has its own origin here. Scripts can't reach other BCDN sites."}</div>
${pathForm ? `<a href="${esc(subUrl)}" style="all:initial;display:inline-block;background:#d6ff3d;color:#0b0e14;padding:6px 12px;border-radius:999px;text-decoration:none;font-weight:600;margin-right:8px;font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer">Isolated view ↗</a>` : ""}
<a href="https://silentmode.st/theseus" style="all:initial;color:#a9b8d0;text-decoration:underline;cursor:pointer;font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">Install Theseus for trustless resolution</a>
</div></div><!--nav-relay-chip-end-->`;
@ -616,6 +643,131 @@ ${pathForm ? `<a href="${esc(subUrl)}" style="all:initial;display:inline-block;b
if (!PATH_FORM) return; // subdomain form stops here — only the chip is shown.
// ====================================================================
// Credential-rejection guard — path form only.
//
// Static HTML rewrite disables <input type=password> at first paint.
// This runtime side catches:
// * dynamically-added password inputs (SPA renders)
// * inputs re-enabled by site JS
// * paste or typed-in material that looks like a seed phrase, hex
// private key, WIF key, or long hex secret
// * WebAuthn API calls (also blocked at Permissions-Policy level,
// but shim gives a human-readable error if a site tries anyway)
// Every rejection shows a floating warning so the user understands
// why their input vanished.
// ====================================================================
function flashWarning(message) {
var w = document.getElementById("__nav_relay_warn");
if (!w) {
w = document.createElement("div");
w.id = "__nav_relay_warn";
w.setAttribute("role", "alert");
w.setAttribute("style", "all:initial;position:fixed;bottom:72px;right:12px;z-index:2147483645;max-width:320px;background:#2a1a1f;color:#fff;border:1px solid #ff6b7a;border-left:4px solid #ff6b7a;border-radius:10px;padding:12px 14px;font:13px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;box-shadow:0 10px 28px rgba(0,0,0,.45)");
document.body && document.body.appendChild(w);
}
w.innerHTML = "<b style=\"all:initial;color:#ff8f9c;font:inherit;font-weight:600;display:block;margin-bottom:4px\">⚠ Blocked on the relay</b><span style=\"all:initial;color:#f3e9ea;font:inherit\">" + message + "</span>";
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 ? `<a href="${esc(subUrl)}" style="all:initial;display:inline-block;b
// or rewriteBnsLinks.
function transformHtml(html, baseHref, opts) {
let out = stripHtmlExt(rewriteBnsLinks(injectBase(html, baseHref)));
// Credential rejection on the path form only — subdomain form has its
// own origin and can safely hold passwords/keys. Static rewrite covers
// the first-paint case; the runtime guard inside injectRelayChrome
// catches dynamic adds and seed-phrase paste.
if (opts && opts.pathForm) {
out = rewritePasswordFields(out);
}
// Relay chrome: full banner + chip on the path form, chip only on the
// subdomain form. Always runs last so later transforms can't rewrite it.
if (opts && opts.name) {