gateway: switch /bns/ tenant isolation to CSP sandbox, retire subdomain form

The old 4-layer credential blocker (sealed-off password rewriter + runtime
guards) + wildcard-subdomain-origin plan is replaced by a single CSP
`sandbox` directive on every /bns/ response. The browser assigns each
document a unique opaque origin, so localStorage, IndexedDB, cookies,
WebAuthn, service workers and the Credential Management API are all
unavailable to the page — nothing to store, nothing to steal, no cross-
tenant anything. No runtime guards required; the browser does it.

Changes:
- Add `sandbox allow-scripts allow-forms allow-popups
  allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation
  allow-modals allow-downloads` to RELAY_SECURITY_HEADERS. Never
  `allow-same-origin` — that would hand the real origin back.
- Rip out SUB_BASE / SUB_BASE_ALL / nameToSubdomainLabel /
  subdomainLabelToName / inbound `*.bns.navigate.st` Host-routing / apex
  catch-all / pathForm plumbing. No more wildcard DNS or wildcard cert
  needed; the sandbox gives us the same isolation on the path form.
- Drop the static `rewritePasswordFields` HTML transform. With sandbox,
  there are no credentials to protect at the field level.
- injectRelayChrome collapses to the bottom-right info chip plus one
  passive sign-in nudge. The nudge appears the first time the visitor
  focuses `<input type="password">` or pastes a BIP39 seed phrase / xprv /
  WIF / long hex key. It explains why sign-in won't persist (sealed view)
  and offers one CTA: install Theseus. Does not block typing or submit;
  the sandbox has already prevented the storage side.
- MutationObserver wires dynamically-added password fields: wireOne
  matches the node itself (not just descendants), DOMContentLoaded hook
  catches late body content. Chip + nudge chrome stay wrapped in the
  existing sentinel markers so native BCDN clients can still strip them.
- Chip pop text + home() landing both now point visitors at /about on
  navigate.st for the sealed-view explanation (shipped alongside this).
- Startup log line reads "(path /bns)" only, matching the retired
  subdomain form.
This commit is contained in:
Local Dev 2026-10-16 21:58:40 +03:00
parent f61b88f942
commit 7bd87f2458

View file

@ -1,15 +1,18 @@
// BNS public gateway (the "remote relay", convenience tier).
// Runs on the VPS. Resolves .bch from the chain (zero-dep resolver) and serves
// the content over plain HTTPS so ANY device's browser can view .bch sites with
// no install. Two URL forms:
// path form: https://navigate.st/bns/<name>.bch/... (<base> injected)
// subdomain form: https://<name-flattened>.bch.navigate.st/ (own origin, no <base>)
// where dots in a multi-level name become dashes: coinspectrum.deviant.bch
// -> coinspectrum-deviant.bch.navigate.st
// Runs on the VPS. Resolves BCDN names from the chain (zero-dep resolver) and
// serves the content over plain HTTPS so ANY device's browser can view BCDN
// sites with no install. One URL form:
// path form: https://navigate.st/bns/<name>.<tld>/... (<base> injected)
// Every response carries a CSP `sandbox` directive so each page gets a UNIQUE
// OPAQUE ORIGIN — tenants can't share state, store credentials, or reach each
// other's cookies / storage / passkeys. See RELAY_SECURITY_HEADERS below.
// A previous drop also served *.bch.navigate.st (own origin per name); it has
// been retired — the sandbox header gives us the same isolation guarantee on
// the path form, without wildcard DNS or wildcard certs.
//
// Honest disclosure (convenience tier, not the trustless one): the operator can
// see which .bch sites you request. No logs — but that's trust. Install the
// resolver or Theseus for trustless resolution; use Tor for IP privacy.
// see which BCDN sites you request. No logs — but that's trust. Install
// Theseus for trustless resolution; use Tor for IP privacy.
import http from "http";
import https from "https";
import tls from "tls";
@ -257,22 +260,6 @@ async function activeListings() {
return keep;
}
const S3_ENDPOINT = process.env.BNS_S3 || "http://127.0.0.1:8500";
// Subdomain suffix for the relay. "bns.navigate.st" because "bns"
// (Bitcoin Cash Name Service — the protocol) is accurate on every BCDN
// TLD; the old "bch.navigate.st" lied about non-.bch names (sirius.x
// was served as sirius-x.bch.navigate.st, reading like a .bch name).
// The .bch form has been dropped — URL bookmarks or links that hit
// *.bch.navigate.st 404 at the nginx layer. Fallback env var keeps
// one knob available if an operator deploys with the old suffix.
const SUB_BASE = (process.env.BNS_SUB_BASE || "bns.navigate.st").toLowerCase();
// Operator-side override: comma-separated extra suffixes to accept on
// inbound. Empty by default (dropping .bch means dropping it). Set
// BNS_SUB_BASE_LEGACY=bch.navigate.st for a temporary grace period.
const SUB_BASE_ALL = Array.from(new Set([
SUB_BASE,
...(process.env.BNS_SUB_BASE_LEGACY || "")
.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean),
]));
// ---------- VPN exit catalogue + session leases ----------
// The pool file holds, per exit, the Reality parameters a client needs AND a
@ -464,28 +451,6 @@ 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,13 +466,31 @@ function stripHtmlExt(html) {
}
);
}
// Security headers applied to every relayed HTML response — both the /bns/
// path form and the subdomain form. The CSP stays permissive on scripts /
// styles because real BCDN sites use inline scripts today, but closes the
// highest-impact HTML smuggling vectors: no framing (defeats clickjacking),
// no <object>/<embed>, no <base> poisoning, no cross-origin form submits.
// Permissions-Policy denies the most dangerous feature requests by default
// so a malicious BCDN page cannot ask the user for camera / mic / geolocation.
// Security headers applied to every relayed HTML response under /bns/.
//
// The sandbox directive is the real isolation primitive. It assigns every
// /bns/<name>/ document a UNIQUE OPAQUE ORIGIN, computed per document load
// by the browser. Consequences the browser enforces, not us:
// - localStorage / sessionStorage / IndexedDB: SecurityError on access
// - document.cookie: partitioned to opaque origin, unreadable/unwritable
// - WebAuthn / navigator.credentials.*: rp.id check fails against opaque
// - Service workers: registration refused (opaque can't own a scope)
// - fetch("/bns/other/private"): cross-origin from opaque → CORS blocked
// - window.open() to another /bns/ page: new tab gets its OWN fresh
// opaque origin — same-URL ≠ same-origin, so no cross-tenant state
// Tenant A's page and tenant B's page cannot share credentials, cookies,
// storage, or any API-level state. By construction. No runtime guard needed.
//
// Tokens we grant: enough for ordinary content browsing (scripts, forms,
// popups, user-click top-nav, modals, downloads). The one we NEVER grant
// is allow-same-origin — adding it defeats the whole mechanism by handing
// the document back its real navigate.st origin.
//
// The rest of the CSP stays permissive on scripts/styles because real BCDN
// sites use inline scripts today, but closes the highest-impact smuggling
// vectors (object-src 'none', base-uri 'self', form-action 'self' https:).
// Permissions-Policy is kept as defense-in-depth even though an opaque
// origin already can't satisfy most of these features' origin checks.
const RELAY_SECURITY_HEADERS = Object.freeze({
"content-security-policy":
"default-src 'self' data: blob: https:; " +
@ -516,10 +499,12 @@ const RELAY_SECURITY_HEADERS = Object.freeze({
"img-src 'self' data: blob: https:; " +
"font-src 'self' data: https:; " +
"connect-src 'self' https: wss:; " +
"frame-ancestors 'none'; " +
"frame-ancestors 'self'; " +
"form-action 'self' https:; " +
"base-uri 'self'; " +
"object-src 'none'",
"object-src 'none'; " +
"sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox " +
"allow-top-navigation-by-user-activation allow-modals allow-downloads",
"permissions-policy":
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=(), " +
"accelerometer=(), gyroscope=(), magnetometer=(), autoplay=(), " +
@ -537,89 +522,63 @@ function withSecurityHeaders(extra = {}) {
return { ...RELAY_SECURITY_HEADERS, ...extra };
}
// Subdomain-form name <-> host encoding.
// Scheme: dashes in the label become "--" (so they survive the round-trip),
// then dots become "-". Decoding reverses it: a "--" pair is a literal "-",
// a single "-" is a ".".
// hello.bch <-> hello-bch
// sirius.x <-> sirius-x
// coinspectrum.deviant.bch <-> coinspectrum-deviant-bch
// hello-world.bch <-> hello--world-bch
// The previous handler hardcoded "+ .bch" on reverse, which only worked for
// 2-level .bch names; non-.bch TLDs and multi-level names fell through to
// NXDOMAIN. This encoding is lossless for every BCDN name.
function nameToSubdomainLabel(name) {
return String(name).replace(/-/g, "--").replace(/\./g, "-");
}
function subdomainLabelToName(label) {
let out = "", i = 0;
while (i < label.length) {
if (label[i] === "-" && label[i + 1] === "-") { out += "-"; i += 2; }
else if (label[i] === "-") { out += "."; i += 1; }
else { out += label[i]; i += 1; }
}
return out;
}
// Trust chrome injected into every relayed HTML response. Two surfaces,
// shared helper markup:
// Trust chrome injected into every relayed HTML response.
//
// PATH FORM (/bns/<name>/...) — the risk form: scripts share navigate.st
// origin with every other relayed BCDN site, so a signed-in session on one
// is reachable from any other. Shows the full banner at top of page +
// COUNTDOWN auto-redirect to the subdomain form (default 10s, cancelled
// instantly on any user interaction or if the visitor clicks Stay here),
// AND a persistent bottom-right chip that survives banner dismissal so
// the trust context never goes fully invisible.
// A single small persistent chip in the bottom-right, nothing else. The
// chip is a passive info indicator: it names the BCDN name being viewed,
// says the site is being relayed through navigate.st, and expands on
// click to a short explanation + an Install-Theseus link for trustless
// resolution.
//
// SUBDOMAIN FORM (<flattened>.bch.navigate.st) — the safer form: each
// name has its own browser origin, nothing crosses between relayed sites.
// Shows only the small persistent chip so a visitor on mobile (where the
// URL bar hides subdomains) can still tell which name they're on and that
// it's being relayed through navigate.st.
// Isolation is provided by the sandbox directive in RELAY_SECURITY_HEADERS
// (opaque origin per document), not by any runtime JS. The old
// credential-detection + auto-redirect runtime is gone — with sandbox,
// the credential-storing APIs (localStorage, cookies, IndexedDB,
// WebAuthn, service workers) are unavailable to the page, so there is
// nothing to detect and nothing to steal.
//
// All inline, `all:initial` on every node so the host page's CSS cannot
// style the chrome into oblivion; dismissible for 24h via localStorage;
// click-away collapses the expanded chip tooltip.
function injectRelayChrome(html, name, reqPath, subBase, opts) {
const pathForm = !!(opts && opts.pathForm);
const subUrl = `https://${nameToSubdomainLabel(name)}.${subBase}${reqPath}`;
// style the chrome into oblivion; click-away collapses the tooltip.
function injectRelayChrome(html, name) {
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", "\"":"&quot;", "'":"&#39;" }[c]));
// Persistent chip — shown on both forms. On the subdomain form it's the
// only trust indicator; on the path form it stays after the banner is
// dismissed so the user still knows they're on a relay.
const chip = `<!--nav-relay-chip-start--><div id="__nav_relay_chip" style="all:initial;position:fixed;bottom:12px;right:12px;z-index:2147483646;font:12.5px/1.3 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">
<button type="button" id="__nav_relay_chip_btn" aria-expanded="false" aria-controls="__nav_relay_chip_pop" style="all:initial;display:inline-flex;align-items:center;gap:7px;background:rgba(26,32,48,.92);color:#e7eaf1;padding:7px 12px;border-radius:999px;border:1px solid rgba(214,255,61,.25);cursor:pointer;font:inherit;backdrop-filter:blur(8px);box-shadow:0 2px 10px rgba(0,0,0,.28)">
<span style="all:initial;color:#d6ff3d;font:inherit">📡</span>
<span style="all:initial;color:#fff;font:inherit;font-weight:600">${esc(name)}</span>
<span style="all:initial;color:#8b98a9;font:inherit">· via navigate.st</span>
</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 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:300px;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 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 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>. This site runs in a sealed browser view — it <b style="all:initial;color:#d6ff3d;font:inherit;font-weight:600">cannot store passwords, cookies, keys or credentials</b> by design. To sign in or use features that need an account, install Theseus.</div>
<a href="https://silentmode.st/theseus" style="all:initial;display:inline-block;background:#d6ff3d;color:#0b0e14;padding:7px 14px;border-radius:999px;text-decoration:none;font-weight:600;font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer">Install Theseus ↗</a>
</div></div><!--nav-relay-chip-end-->`;
// Full banner — path form only.
const banner = pathForm ? `<!--nav-relay-banner-start--><div id="__nav_relay_banner" style="all:initial;display:block;position:fixed;top:0;left:0;right:0;z-index:2147483647;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#1a2030;color:#e7eaf1;border-bottom:2px solid #d6ff3d;padding:10px 14px;box-shadow:0 2px 10px rgba(0,0,0,.3);box-sizing:border-box">
<div style="all:initial;display:flex;max-width:1200px;margin:0 auto;align-items:center;gap:14px;flex-wrap:wrap;color:inherit;font:inherit">
<span style="all:initial;color:#d6ff3d;font-weight:600;font:inherit">⚠ Relayed site</span>
<span style="all:initial;color:#e7eaf1;flex:1;min-width:220px;font:inherit"><b style="all:initial;color:#fff;font:inherit;font-weight:600">${esc(name)}</b> is being served through <b style="all:initial;color:#fff;font:inherit;font-weight:600">navigate.st</b>. Scripts here run under the gateway's origin — do not sign in or paste a recovery phrase.</span>
<span id="__nav_relay_countdown" style="all:initial;color:#8b98a9;font:12.5px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;display:inline-flex;align-items:center;gap:6px">Moving in <b id="__nav_relay_countdown_n" style="all:initial;color:#d6ff3d;font-weight:700;font:inherit">10</b>s</span>
<a href="${esc(subUrl)}" id="__nav_relay_go" style="all:initial;display:inline-block;background:#d6ff3d;color:#0b0e14;padding:6px 14px;border-radius:999px;text-decoration:none;font-weight:600;font:14px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer">Isolated view ↗</a>
<button type="button" id="__nav_relay_stay" style="all:initial;color:#a9b8d0;text-decoration:underline;cursor:pointer;background:transparent;border:none;font:14px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:0">Stay here</button>
<a href="https://silentmode.st/theseus" style="all:initial;color:#a9b8d0;text-decoration:underline;cursor:pointer;font:14px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">Install Theseus</a>
<button type="button" id="__nav_relay_dismiss" aria-label="Dismiss" style="all:initial;color:#8b98a9;cursor:pointer;padding:4px 8px;font:14px/1 system-ui;background:transparent;border:none">✕</button>
</div></div><!--nav-relay-banner-end-->` : "";
// The sign-in nudge. Shown once per page load the first time the user
// interacts with a password field (focus is enough — we don't wait for
// them to type). The sandbox CSP header already prevents credential
// STORAGE (cookies, localStorage, IndexedDB, WebAuthn, service workers
// all fail), so even if they typed a password and submitted it, no
// session could persist and the login would silently not work. This
// nudge explains that in advance, before they waste the attempt, and
// points at Theseus as the single supported path for credential UX.
// Also fires on a paste of a seed phrase / xprv / WIF / hex key into
// ANY field, since those are the high-stakes secrets we most want to
// keep off a relayed origin.
const nudge = `<!--nav-relay-nudge-start--><div id="__nav_relay_nudge" hidden style="all:initial;display:none;position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:min(460px,calc(100vw - 24px));background:#1a2030;color:#e7eaf1;border:1px solid #d6ff3d;border-radius:14px;padding:16px 18px;box-shadow:0 14px 36px rgba(0,0,0,.5);font:13.5px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">
<div style="all:initial;display:flex;align-items:flex-start;gap:12px;font:inherit">
<div style="all:initial;font-size:22px;line-height:1">🔒</div>
<div style="all:initial;flex:1;font:inherit">
<div style="all:initial;display:block;font-weight:700;color:#fff;margin-bottom:4px;font:inherit">Sign-in doesn't work here</div>
<div id="__nav_relay_nudge_body" style="all:initial;display:block;color:#c3cad8;margin-bottom:12px;font:inherit">The <b style="all:initial;color:#d6ff3d;font-weight:600;font:inherit">navigate.st</b> relay runs <b style="all:initial;color:#fff;font-weight:600;font:inherit">${esc(name)}</b> in a sealed view, so passwords, cookies and keys <b style="all:initial;color:#fff;font-weight:600;font:inherit">cannot be stored</b>. Even a successful sign-in won't persist. Install Theseus to use this site with full functionality.</div>
<a href="https://silentmode.st/theseus" style="all:initial;display:inline-block;background:#d6ff3d;color:#0b0e14;padding:8px 16px;border-radius:999px;text-decoration:none;font-weight:600;font:13px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer;margin-right:8px">Install Theseus</a>
<button type="button" id="__nav_relay_nudge_dismiss" style="all:initial;color:#8b98a9;cursor:pointer;font:12.5px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:8px 4px">Dismiss</button>
</div></div></div><!--nav-relay-nudge-end-->`;
const script = `<!--nav-relay-script-start--><script>(function(){
var SUB = ${JSON.stringify(subUrl)};
var PATH_FORM = ${pathForm ? "true" : "false"};
var COUNTDOWN_SECONDS = 10;
// Chip wiring (both forms).
// Chip wiring: click to expand/collapse the info tooltip, click-away
// closes it. Isolation itself is browser-side via CSP sandbox — this
// script does not enforce anything, only informs.
var chip = document.getElementById("__nav_relay_chip");
var chipBtn = document.getElementById("__nav_relay_chip_btn");
var chipPop = document.getElementById("__nav_relay_chip_pop");
@ -641,224 +600,124 @@ ${pathForm ? `<a href="${esc(subUrl)}" style="all:initial;display:inline-block;b
}, true);
}
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);
// Sign-in nudge: show once per page load on first credential interaction.
// Triggers:
// - focus on <input type="password">
// - paste of a BIP39 seed phrase / xprv / WIF / long hex key into any field
// Does NOT block typing or submission — sandbox already prevents the
// credential STORE, so this is purely explanatory. Dismiss sticks for
// the lifetime of this document (no storage to persist it longer).
var nudge = document.getElementById("__nav_relay_nudge");
var nudgeBody = document.getElementById("__nav_relay_nudge_body");
var nudgeDismiss = document.getElementById("__nav_relay_nudge_dismiss");
var nudgeShown = false, nudgeDismissed = false;
function showNudge(subtitle) {
if (nudgeShown || nudgeDismissed || !nudge) return;
nudgeShown = true;
if (subtitle && nudgeBody) {
try { nudgeBody.firstChild && nudgeBody.insertBefore(document.createTextNode(subtitle + " "), nudgeBody.firstChild); } catch (_) {}
}
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);
nudge.hidden = false;
nudge.style.display = "block";
}
if (nudgeDismiss) {
nudgeDismiss.addEventListener("click", function() {
nudgeDismissed = true;
nudge.hidden = true;
nudge.style.display = "none";
});
}
function neuterPasswordInput(el) {
if (!el || el.dataset && el.dataset.navRelayBlocked === "password") return;
// Password field focus — static or dynamic. wireOne wires a single
// element if it is a password input; wireTree scans descendants AND
// the root itself (querySelectorAll ignores self, so an SPA that
// appends an input directly to body would otherwise be missed).
function wireOne(el) {
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) {}
if (!el || el.nodeType !== 1) return;
if (el.matches && el.matches('input[type="password"]')) {
el.addEventListener("focus", function() { showNudge(""); }, { once: true });
}
} catch (_) {}
}
// 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) {
function wireTree(root) {
try {
(root && root.querySelectorAll ? root : document).querySelectorAll('input[type="password"]').forEach(neuterPasswordInput);
} catch (e) {}
wireOne(root);
var fields = (root || document).querySelectorAll('input[type="password"]');
for (var i = 0; i < fields.length; i++) wireOne(fields[i]);
} catch (_) {}
}
// Initial scan: body may not be parsed yet (this script sits right after
// <body>, so later elements aren't in the DOM yet). Run now AND on
// DOMContentLoaded to cover both the "already parsed" and "still parsing"
// cases; the MutationObserver below handles everything added later.
wireTree(document);
if (document.readyState !== "complete" && document.readyState !== "interactive") {
document.addEventListener("DOMContentLoaded", function() { wireTree(document); }, { once: true });
}
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); });
if (m.addedNodes) {
for (var j = 0; j < m.addedNodes.length; j++) wireTree(m.addedNodes[j]);
}
if (m.type === "attributes") wireOne(m.target);
}
});
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 });
}
mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["type"] });
} catch (_) {}
// Full-banner wiring (path form only).
var banner = document.getElementById("__nav_relay_banner");
var dismissBtn = document.getElementById("__nav_relay_dismiss");
var stayBtn = document.getElementById("__nav_relay_stay");
var countdownEl = document.getElementById("__nav_relay_countdown");
var countdownN = document.getElementById("__nav_relay_countdown_n");
var tick = null;
var stayed = false;
// Hide banner (not chip) if the visitor dismissed within the last 24h.
try {
var raw = localStorage.getItem("nav.relay.dismissed_at");
if (raw && (Date.now() - Number(raw)) < 86400000) {
if (banner) banner.style.display = "none";
return;
// High-stakes secret pasted into any field — surface the nudge immediately
// with a subtitle naming the thing we think they just pasted. We do NOT
// intercept the paste; the sandbox already prevents any script on the
// page from exfiltrating it anywhere persistent, and we don't want to
// break legitimate uses on native clients (Theseus / Ariadne) that strip
// this script before rendering.
function classifyPaste(raw) {
if (!raw) return null;
var s = String(raw).trim();
if (s.length < 12) return null;
if (/^[0-9a-fA-F]{64,}$/.test(s)) return "That looks like a hex key.";
if (/^[KL5][1-9A-HJ-NP-Za-km-z]{50,51}$/.test(s)) return "That looks like a WIF private key.";
if (/^(xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{100,}$/.test(s)) return "That looks like an extended private key.";
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 "That looks like a recovery phrase.";
}
} catch (e) {}
function stopCountdown() {
if (tick) { clearInterval(tick); tick = null; }
if (countdownEl) { countdownEl.style.display = "none"; }
stayed = true;
try { sessionStorage.setItem("nav.relay.stayed", "1"); } catch (e) {}
return null;
}
function startCountdown() {
// Honour previous Stay-here within this tab/session.
try { if (sessionStorage.getItem("nav.relay.stayed") === "1") { if (countdownEl) countdownEl.style.display = "none"; return; } } catch (e) {}
var left = COUNTDOWN_SECONDS;
tick = setInterval(function() {
left -= 1;
if (left <= 0) {
clearInterval(tick); tick = null;
try { window.location.replace(SUB); } catch (e) { window.location.href = SUB; }
return;
}
if (countdownN) countdownN.textContent = String(left);
}, 1000);
}
// Any sign of user activity cancels the auto-redirect — we don't yank
// someone away from a page they're already reading.
["click","keydown","wheel","touchstart","scroll","pointerdown"].forEach(function(ev) {
window.addEventListener(ev, function() { if (tick && !stayed) stopCountdown(); }, { once: true, passive: true, capture: true });
});
if (stayBtn) stayBtn.addEventListener("click", function() { stopCountdown(); });
if (dismissBtn) dismissBtn.addEventListener("click", function() {
stopCountdown();
try { localStorage.setItem("nav.relay.dismissed_at", String(Date.now())); } catch (e) {}
if (banner) banner.remove();
repad();
});
// Push the page down so the banner doesn't overlap it. Re-measure on
// resize and after removal so the layout stays correct.
function repad() {
if (!document.body) return;
var b = document.getElementById("__nav_relay_banner");
document.body.style.paddingTop = (b ? b.offsetHeight + 4 : 0) + "px";
}
if (document.readyState !== "loading") { repad(); startCountdown(); }
else document.addEventListener("DOMContentLoaded", function() { repad(); startCountdown(); });
window.addEventListener("resize", repad);
document.addEventListener("paste", function(e) {
try {
var raw = (e.clipboardData || window.clipboardData).getData("text");
var subtitle = classifyPaste(raw);
if (subtitle) showNudge(subtitle);
} catch (_) {}
}, true);
})();</script><!--nav-relay-script-end-->`;
const combined = banner + chip + script;
const combined = chip + nudge + script;
// Insert just after <body> if present, otherwise at the start of the document.
if (/<body[^>]*>/i.test(html)) return html.replace(/<body[^>]*>/i, (m) => m + combined);
return combined + html;
}
// Apply the three transforms (base first, link rewrite, extension strip),
// then the banner if we're on the PATH form (opts.pathForm). Order doesn't
// matter across the first three — they touch disjoint constructs — but
// grouping them here means every text/html response goes through the same
// path. The banner always runs last so it isn't rewritten by stripHtmlExt
// or rewriteBnsLinks.
// then inject the chip. Order doesn't matter across the first three —
// they touch disjoint constructs — but grouping them here means every
// text/html response goes through the same path. The chip always runs
// last so it isn't rewritten by stripHtmlExt or rewriteBnsLinks.
//
// Isolation itself is header-side: RELAY_SECURITY_HEADERS carries a
// `sandbox` directive that gives every /bns/<name>/ response its own
// opaque origin. No runtime credential detector any more — the APIs
// that would store credentials simply don't exist in a sandboxed
// document, so there's nothing to detect.
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) {
out = injectRelayChrome(out, opts.name, opts.reqPath || "/", SUB_BASE, { pathForm: !!opts.pathForm });
out = injectRelayChrome(out, opts.name);
}
return out;
}
@ -992,16 +851,14 @@ async function serve(res, name, reqPath, baseHref, req = null) {
// Short lifetime: without it browsers apply heuristic caching to files from
// Sia (no ETag) and keep old scripts for hours after a publish.
res.setHeader("cache-control", "public, max-age=120");
// baseHref !== null means the PATH form (/bns/<name>/...) — the full banner
// is injected only there. The trust chip is injected on BOTH forms (chip
// is purely informative and useful on mobile where URL bars hide the
// subdomain structure).
// Resolver clients (Theseus, Ariadne) fetch a page through the path form and
// show it under the name's own origin, so the relay chrome would be a false
// warning inside them. They say who they are with "x-bns-policy: client"; a
// browser navigation cannot send that header, so visitors still get it.
// Resolver clients (Theseus, Ariadne) fetch a page through this gateway
// and render it under the name's own origin, so the relay chip would be a
// false warning inside them. They identify themselves with the header
// "x-bns-policy: client"; a browser navigation cannot send that header,
// so visitors still get the chip + nudge. When the chip is suppressed,
// injectRelayChrome is a no-op (opts.name is null).
const fromClient = String(req?.headers?.["x-bns-policy"] || "").toLowerCase() === "client";
const htmlOpts = { pathForm: baseHref !== null, name: fromClient ? null : name, reqPath };
const htmlOpts = { name: fromClient ? null : name };
// Security headers for every HTML response below. Also strips the broad
// `access-control-allow-origin: *` set at the top of serve() — HTML pages
// being readable cross-origin is a surveillance surface (any site can
@ -2664,36 +2521,10 @@ const server = http.createServer(async (req, res) => {
return;
}
// --- subdomain form: <flattened>.<SUB_BASE> ---
// Encoding: dashes in the label become "--" (so they survive the round
// trip), then dots become "-". `subdomainLabelToName` reverses both.
// hello-bch -> hello.bch
// sirius-x -> sirius.x
// coinspectrum-deviant-bch -> coinspectrum.deviant.bch
// hello--world-bch -> hello-world.bch
// Legacy form (back-compat): a dot-less single label implicitly meant
// "<label>.bch" — `hello.bch.navigate.st` used to resolve "hello.bch".
// We preserve that so existing bookmarks keep working.
const matchedBase = SUB_BASE_ALL.find((b) => hostHeader.endsWith("." + b));
if (matchedBase) {
const inner = hostHeader.slice(0, -("." + matchedBase).length);
if (inner) {
let name = subdomainLabelToName(inner).toLowerCase();
if (!name.includes(".") && /^[a-z0-9-]+$/i.test(name)) {
name = name + ".bch"; // legacy short form
}
// Only forward names that match the gateway's own name regex (label.tld).
// Anything else — stray wildcards hitting the subdomain cert — falls to
// 404 instead of attempting to resolve nonsense on chain.
if (/^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i.test(name)) {
return serve(res, name, req.url || "/", null, req); // own origin: no <base>
}
}
}
// bare apex of any accepted SUB_BASE -> home
if (SUB_BASE_ALL.includes(hostHeader)) { res.writeHead(200, { "content-type": "text/html" }); return res.end(home()); }
// --- path form: /bns/<name>.bch/... ---
// --- path form: /bns/<name>.<tld>/... ---
// (The subdomain form has been retired — CSP sandbox on path-form
// responses provides per-page opaque origins, so a wildcard subdomain
// with its own cert is no longer needed for tenant isolation.)
let p = req.url;
if (p === MOUNT || p === MOUNT + "/") { res.writeHead(200, { "content-type": "text/html" }); return res.end(home()); }
if (!p.startsWith(MOUNT + "/")) { res.writeHead(404); return res.end("bns gateway"); }
@ -2713,18 +2544,24 @@ const server = http.createServer(async (req, res) => {
return serve(res, name, rest, `${MOUNT}/${name}${restDir}`, req);
} catch (e) { res.writeHead(500); res.end("gateway error: " + e.message); }
});
server.listen(PORT, "127.0.0.1", () => console.log(`bns public gateway on 127.0.0.1:${PORT} (path ${MOUNT}, subdomain *.${SUB_BASE})`));
server.listen(PORT, "127.0.0.1", () => console.log(`bns public gateway on 127.0.0.1:${PORT} (path ${MOUNT})`));
function home() {
return `<!doctype html><meta charset=utf-8><title>BNS Gateway</title>
<body style="font:16px system-ui;max-width:640px;margin:3rem auto;padding:0 1rem;color:#e7eaf1;background:#0f1420">
<h1 style="color:#d6ff3d">⛓ BNS Gateway</h1>
<p>View Bitcoin Cash <b>.bch</b> names in any browser — no install.</p>
<p>View Bitcoin Cash Name Service sites in any browser — no install.</p>
<ul>
<li><a href="/bns/hello.bch">/bns/hello.bch</a> · or <a href="https://hello.${SUB_BASE}/">hello.${SUB_BASE}</a></li>
<li><a href="/bns/hello.bch">/bns/hello.bch</a></li>
<li><a href="/bns/sirius.x">/bns/sirius.x</a></li>
<li><a href="/bns/coinspectrum.deviant.bch">/bns/coinspectrum.deviant.bch</a></li>
</ul>
<p style="color:#8b98a9;font-size:13px">This gateway resolves names for you, so it can see which .bch
sites you request (it keeps no logs — you're trusting that). For trustless resolution install the local
resolver/extension; for IP privacy use Tor. By Silent Mode (a Deviant project).</p></body>`;
<p style="color:#8b98a9;font-size:13px">Each relayed site runs in a sealed browser view
(no cookies, no passwords, no credentials — isolation by design;
<a href="/about" style="color:#d6ff3d">how this works</a>). To sign in or use features
that need an account, install <a href="https://silentmode.st/theseus" style="color:#d6ff3d">Theseus</a>
— it resolves names trustlessly from the chain, no relay in the middle.</p>
<p style="color:#8b98a9;font-size:13px">This gateway resolves names for you, so it can see which
BCDN sites you request (it keeps no logs — you're trusting that). For IP privacy use Tor.
By Silent Mode (a Deviant project).</p></body>`;
}