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 + // "