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:
parent
384d3dd3c7
commit
f61b88f942
1 changed files with 161 additions and 2 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue