From 1ec4fdde7dfb2ba47efea85c51664ee291be7318 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 6 Oct 2026 01:25:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20security=20hardening=20?= =?UTF-8?q?=E2=80=94=20headers,=20relay=20banner,=20subdomain=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option (a) from the security review: /bns/ path form stays live but now carries a visible warning banner + a one-click upgrade to the subdomain form where each BCDN name gets its own browser origin. Security headers go on every relayed HTML response regardless of form. New headers on every relayed HTML (both /bns/ and *.bch.navigate.st): content-security-policy: permissive on scripts/styles (real BCDN sites use inline), but frame-ancestors 'none', object-src 'none', base-uri 'self', form-action 'self' https: — closes clickjacking, / smuggling, poisoning, cross-origin form submits in one block. permissions-policy: camera, mic, geolocation, payment, usb, bluetooth, accelerometer, gyroscope, magnetometer, autoplay, interest-cohort, browsing-topics all denied by default. A malicious BCDN page can no longer ask the user for sensitive APIs with navigate.st-flavoured prompts. referrer-policy: no-referrer x-content-type-options: nosniff x-frame-options: SAMEORIGIN (belt-and-suspenders alongside CSP) Relay banner (PATH FORM only): Fixed top strip reading "⚠ Relayed site · is being served through navigate.st · Scripts here run under the gateway's origin — do not sign in or paste a recovery phrase". Offers an "Isolated view ↗" button to the subdomain form and a secondary "Install Theseus" link. All inline, no external deps, `all:initial` on every node so the host page's CSS cannot style it. Dismissible for 24h via localStorage. Body padding adjusts on load+resize so the banner doesn't overlap content. Subdomain-form decoder fix: Old handler hard-appended ".bch" on reverse — only worked for 2-level .bch names. sirius-x.bch.navigate.st decoded to the phantom "sirius.x.bch". New scheme is lossless for every BCDN name: hello-bch -> hello.bch sirius-x -> sirius.x coinspectrum-deviant-bch -> coinspectrum.deviant.bch hello--world-bch -> hello-world.bch (dashes in label survive) Legacy short form "hello.bch.navigate.st" still resolves — a dot-less single label is treated as an implicit ".bch" name for back-compat. --- src/gateway/public-gateway.mjs | 169 +++++++++++++++++++++++++++++---- 1 file changed, 152 insertions(+), 17 deletions(-) diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 37efa5a..3d81f00 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -464,12 +464,123 @@ function stripHtmlExt(html) { } ); } -// Apply the three transforms (base first, link rewrite, extension strip). -// Order doesn't matter across them because they touch disjoint constructs, -// but grouping them here means every text/html response goes through the -// same path. -function transformHtml(html, baseHref) { - return stripHtmlExt(rewriteBnsLinks(injectBase(html, baseHref))); +// 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. +const RELAY_SECURITY_HEADERS = Object.freeze({ + "content-security-policy": + "default-src 'self' data: blob: https:; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: https:; " + + "style-src 'self' 'unsafe-inline' https:; " + + "img-src 'self' data: blob: https:; " + + "font-src 'self' data: https:; " + + "connect-src 'self' https: wss:; " + + "frame-ancestors 'none'; " + + "form-action 'self' https:; " + + "base-uri 'self'; " + + "object-src 'none'", + "permissions-policy": + "camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=(), " + + "accelerometer=(), gyroscope=(), magnetometer=(), autoplay=(), " + + "interest-cohort=(), browsing-topics=()", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + "x-frame-options": "SAMEORIGIN", +}); +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; +} + +// Banner injected on PATH-FORM responses only, warning the visitor that +// scripts here run under navigate.st's origin (so a session set up on one +// relayed site is reachable by any other), and offering a one-click upgrade +// to the subdomain form where each name gets its own origin. Dismissible for +// 24h via localStorage so the banner isn't noise for return visitors who have +// already decided to keep using the path form. +function injectRelayBanner(html, name, reqPath, subBase) { + const subUrl = `https://${nameToSubdomainLabel(name)}.${subBase}${reqPath}`; + const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&":"&", "<":"<", ">":">", "\"":""", "'":"'" }[c])); + // Keep the inline markup self-contained (no external CSS/JS, no dependencies) + // so the banner renders even on pages with strict CSP of their own and even + // if the relayed site's own styling is broken. + const banner = `
+
+⚠ 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. +Isolated view ↗ +Install Theseus + +
+`; + // Insert just after if present, otherwise at the start of the document. + if (/]*>/i.test(html)) return html.replace(/]*>/i, (m) => m + banner); + return banner + 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. +function transformHtml(html, baseHref, opts) { + let out = stripHtmlExt(rewriteBnsLinks(injectBase(html, baseHref))); + if (opts && opts.pathForm && opts.name) { + out = injectRelayBanner(out, opts.name, opts.reqPath || "/", SUB_BASE); + } + return out; } const lapsed = (h, e) => `

Expired

${h} expired at block ${e.expiry} and was not renewed. It can be taken over.

`; const nx = (h) => `

NXDOMAIN

${h} is not registered on the Bitcoin Cash chain.

`; @@ -601,6 +712,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 banner is + // injected only there, because the subdomain form already isolates the name + // in its own origin. Pre-computed so each transformHtml call stays trivial. + const htmlOpts = { pathForm: baseHref !== null, name, reqPath }; + // Security headers for every HTML response below. Applied via writeHead + // overrides because setHeader() interleaves awkwardly with the explicit + // header objects some branches build. + const htmlHeaders = (extra) => withSecurityHeaders({ "content-type": "text/html; charset=utf-8", ...extra }); let entry; try { entry = await resolve(name); } catch (e) { res.writeHead(502); return res.end("resolve error: " + e.message); } if (!entry) { res.writeHead(404, { "content-type": "text/html" }); return res.end(nx(name)); } @@ -659,8 +778,8 @@ async function serve(res, name, reqPath, baseHref, req = null) { switch (pick) { case "h": if (reqPath !== "/") { res.writeHead(404); return res.end("not found"); } - res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - return res.end(transformHtml(r.h, baseHref)); + res.writeHead(200, htmlHeaders()); + return res.end(transformHtml(r.h, baseHref, htmlOpts)); case "s3": { const sub = subFolderFor(name, entry.name, manifest); let base = r.s3; if (base.endsWith("/") && sub) base += sub + "/"; @@ -726,7 +845,7 @@ async function serve(res, name, reqPath, baseHref, req = null) { } const buf = Buffer.from(await up.arrayBuffer()); let ct = up.headers.get("content-type"); if (!ct || ct === "application/octet-stream") ct = guess(key); - if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(transformHtml(buf.toString("utf8"), baseHref)); } + if (ct.includes("text/html")) { res.writeHead(up.status, withSecurityHeaders({ "content-type": ct })); return res.end(transformHtml(buf.toString("utf8"), baseHref, htmlOpts)); } res.writeHead(up.status, { "content-type": ct }); return res.end(buf); } case "ip": { @@ -748,7 +867,7 @@ async function serve(res, name, reqPath, baseHref, req = null) { if (lk === "content-type") continue; outHeaders[k] = v; } - if (ct.includes("text/html")) { res.writeHead(up.status, outHeaders); return res.end(transformHtml(buf.toString("utf8"), baseHref)); } + if (ct.includes("text/html")) { res.writeHead(up.status, withSecurityHeaders(outHeaders)); return res.end(transformHtml(buf.toString("utf8"), baseHref, htmlOpts)); } res.writeHead(up.status, outHeaders); return res.end(buf); } case "p": { @@ -767,7 +886,7 @@ async function serve(res, name, reqPath, baseHref, req = null) { if (lk === "content-type") continue; outHeaders[k] = v; } - if (ct.includes("text/html")) { res.writeHead(up.status, outHeaders); return res.end(transformHtml(buf.toString("utf8"), baseHref)); } + if (ct.includes("text/html")) { res.writeHead(up.status, withSecurityHeaders(outHeaders)); return res.end(transformHtml(buf.toString("utf8"), baseHref, htmlOpts)); } res.writeHead(up.status, outHeaders); return res.end(buf); } case "u": @@ -2253,13 +2372,29 @@ const server = http.createServer(async (req, res) => { return; } - // --- subdomain form: .bch. --- + // --- 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 + // "