From 7bd87f2458ac3d9227b49f5bfdf37cffc254b466 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Fri, 16 Oct 2026 21:58:40 +0300 Subject: [PATCH] gateway: switch /bns/ tenant isolation to CSP sandbox, retire subdomain form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- src/gateway/public-gateway.mjs | 549 ++++++++++++--------------------- 1 file changed, 193 insertions(+), 356 deletions(-) diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 8ac86db..9667376 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -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/.bch/... ( injected) -// subdomain form: https://.bch.navigate.st/ (own origin, no ) -// 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/./... ( 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 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,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 /, no 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// 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//...) — 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 (.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) => ({ "&":"&", "<":"<", ">":">", "\"":""", "'":"'" }[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 = `
-`; - // Full banner — path form only. - const banner = pathForm ? `
-
-⚠ Relayed site -${esc(name)} is being served through navigate.st. Scripts here run under the gateway's origin — do not sign in or paste a recovery phrase. -Moving in 10s -Isolated view ↗ - -Install Theseus - -
` : ""; + // 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 = ``; const script = ``; - const combined = banner + chip + script; + const combined = chip + nudge + script; // Insert just after if present, otherwise at the start of the document. if (/]*>/i.test(html)) return html.replace(/]*>/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// 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//...) — 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: . --- - // 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 - // "