feat(gateway): security hardening — headers, relay banner, subdomain fix

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,
    <object>/<embed> smuggling, <base> 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 · <name> 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.
This commit is contained in:
Local Dev 2026-10-06 01:25:48 +02:00
parent 559eb6996a
commit 1ec4fdde7d

View file

@ -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 <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.
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) => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", "\"":"&quot;", "'":"&#39;" }[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 = `<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>
<a href="${esc(subUrl)}" 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>
<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>
<script>(function(){
try {
if (localStorage.getItem("nav.relay.dismissed") === "1") {
var b = document.getElementById("__nav_relay_banner");
if (b) b.style.display = "none";
return;
}
} catch (e) {}
var btn = document.getElementById("__nav_relay_dismiss");
if (btn) btn.addEventListener("click", function() {
try { localStorage.setItem("nav.relay.dismissed", "1"); } catch (e) {}
var b = document.getElementById("__nav_relay_banner");
if (b) b.remove();
});
// Push the page content down so the banner doesn't overlap it. 60px
// comfortably covers one row + the border on desktop; mobile wraps and
// uses more, which is fine because we re-measure on load.
function pad() {
var b = document.getElementById("__nav_relay_banner");
if (!b || !document.body) return;
document.body.style.paddingTop = (b.offsetHeight + 4) + "px";
}
if (document.readyState !== "loading") pad(); else document.addEventListener("DOMContentLoaded", pad);
window.addEventListener("resize", pad);
})();</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 + 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) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>Expired</h2><p><code>${h}</code> expired at block ${e.expiry} and was not renewed. It can be taken over.</p></div>`;
const nx = (h) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>NXDOMAIN</h2><p><code>${h}</code> is not registered on the Bitcoin Cash chain.</p></div>`;
@ -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/<name>/...) — 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,15 +2372,31 @@ const server = http.createServer(async (req, res) => {
return;
}
// --- subdomain form: <flattened>.bch.<SUB_BASE> ---
// --- 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.
if (hostHeader.endsWith("." + SUB_BASE)) {
const inner = hostHeader.slice(0, -("." + SUB_BASE).length); // "<flattened>.bch" or "<flattened>"
const flat = inner.endsWith(".bch") ? inner.slice(0, -4) : inner;
if (flat) {
const name = flat.replace(/-/g, ".") + ".bch"; // dashes back to dots
const inner = hostHeader.slice(0, -("." + SUB_BASE).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 SUB_BASE (e.g. bch.navigate.st) -> home
if (hostHeader === SUB_BASE) { res.writeHead(200, { "content-type": "text/html" }); return res.end(home()); }