diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 3d81f00..28657bf 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -519,54 +519,158 @@ function subdomainLabelToName(label) { 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) { +// Trust chrome injected into every relayed HTML response. Two surfaces, +// shared helper markup: +// +// 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. +// +// 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. +// +// 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}`; 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 = `
+ + // 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. -Isolated view โ†— +Moving in 10s +Isolated view โ†— + Install Theseus -
-`; + + const combined = banner + chip + script; // 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; + if (/]*>/i.test(html)) return html.replace(/]*>/i, (m) => m + combined); + return combined + html; } // Apply the three transforms (base first, link rewrite, extension strip), @@ -577,8 +681,10 @@ function injectRelayBanner(html, name, reqPath, subBase) { // 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); + // 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 }); } return out; } @@ -712,14 +818,21 @@ 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. + // 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). 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 }); + // 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 + // fetch BCDN pages and inspect their rendered content), with no legit + // use case the subdomain form doesn't already cover. Static assets keep + // the broad ACAO so fonts/images/JSON can still be embedded elsewhere. + const htmlHeaders = (extra) => { + try { res.removeHeader("access-control-allow-origin"); } catch (_) {} + return 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)); } @@ -845,7 +958,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, withSecurityHeaders({ "content-type": ct })); return res.end(transformHtml(buf.toString("utf8"), baseHref, htmlOpts)); } + if (ct.includes("text/html")) { res.writeHead(up.status, htmlHeaders({ "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": { @@ -867,7 +980,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, withSecurityHeaders(outHeaders)); return res.end(transformHtml(buf.toString("utf8"), baseHref, htmlOpts)); } + if (ct.includes("text/html")) { res.writeHead(up.status, htmlHeaders(outHeaders)); return res.end(transformHtml(buf.toString("utf8"), baseHref, htmlOpts)); } res.writeHead(up.status, outHeaders); return res.end(buf); } case "p": { @@ -886,7 +999,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, withSecurityHeaders(outHeaders)); return res.end(transformHtml(buf.toString("utf8"), baseHref, htmlOpts)); } + if (ct.includes("text/html")) { res.writeHead(up.status, htmlHeaders(outHeaders)); return res.end(transformHtml(buf.toString("utf8"), baseHref, htmlOpts)); } res.writeHead(up.status, outHeaders); return res.end(buf); } case "u":