feat(gateway): Tier 2 relay hardening — countdown, chip, ACAO lockdown

Three additions on top of last commit's security headers + banner.

Countdown auto-redirect (path form banner):
  Visible "Moving in <N> s" between the message and the Isolated-view
  button, default 10 s, counts down once per second and location.replace
  to the subdomain form on expiry. The whole thing stops instantly on:
    - any click / keydown / wheel / touchstart / scroll / pointerdown
      (we don't yank a page out from under someone who's reading it),
    - "Stay here" button (persists for the tab via sessionStorage),
    - "✕" dismiss (persists for 24 h via localStorage dismissed_at).
  First-time visitors drift to the isolated origin; return visitors
  who've once said Stay are left alone for the rest of the session.

Persistent trust chip (both forms):
  Bottom-right pill reading "📡 <name> · via navigate.st", with
  backdrop blur so it reads over any page. Click opens a 280 px pop
  that explains the trust context: on the PATH form it adds an
  Isolated-view button; on the SUBDOMAIN form it reads
  "Each name has its own origin here. Scripts can't reach other BCDN
  sites." Essential on mobile where URL bars hide the subdomain
  structure — the user can still see which BCDN name they are on and
  that it is being relayed through navigate.st. Click-away collapses.

ACAO lockdown on HTML:
  res.removeHeader("access-control-allow-origin") before every HTML
  writeHead so relayed PAGES are no longer readable cross-origin.
  Static assets (fonts, images, JSON) keep the broad ACAO because
  embedding those from BCDN names on other origins is a legit use
  case; HTML being readable cross-origin was a surveillance surface
  (any site could fetch BCDN pages and inspect their rendered content)
  with no legit case the subdomain form doesn't already cover.
This commit is contained in:
Local Dev 2026-10-06 07:02:07 +02:00
parent 1ec4fdde7d
commit 53fd569b0d

View file

@ -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/<name>/...) — 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 (<flattened>.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) => ({ "&":"&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">
// 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 = `<div id="__nav_relay_chip" style="all:initial;position:fixed;bottom:12px;right:12px;z-index:2147483646;font:12.5px/1.3 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">
<button type="button" id="__nav_relay_chip_btn" aria-expanded="false" aria-controls="__nav_relay_chip_pop" style="all:initial;display:inline-flex;align-items:center;gap:7px;background:rgba(26,32,48,.92);color:#e7eaf1;padding:7px 12px;border-radius:999px;border:1px solid rgba(214,255,61,.25);cursor:pointer;font:inherit;backdrop-filter:blur(8px);box-shadow:0 2px 10px rgba(0,0,0,.28)">
<span style="all:initial;color:#d6ff3d;font:inherit">📡</span>
<span style="all:initial;color:#fff;font:inherit;font-weight:600">${esc(name)}</span>
<span style="all:initial;color:#8b98a9;font:inherit">· via navigate.st</span>
</button>
<div id="__nav_relay_chip_pop" role="dialog" aria-label="Relay info" hidden style="all:initial;display:none;position:absolute;bottom:calc(100% + 8px);right:0;width:280px;background:#1a2030;color:#e7eaf1;border:1px solid rgba(214,255,61,.3);border-radius:12px;padding:14px 16px;box-shadow:0 10px 28px rgba(0,0,0,.45);font:13px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">
<div style="all:initial;display:block;color:#d6ff3d;font-weight:600;margin-bottom:6px;font:inherit">Relayed site</div>
<div style="all:initial;display:block;color:#e7eaf1;margin-bottom:10px;font:inherit"><b style="all:initial;color:#fff;font-weight:600;font:inherit">${esc(name)}</b> is being served through <b style="all:initial;color:#fff;font-weight:600;font:inherit">navigate.st</b>. ${pathForm ? "Scripts here run under the gateway's origin — do not sign in or paste a recovery phrase." : "Each name has its own origin here. Scripts can't reach other BCDN sites."}</div>
${pathForm ? `<a href="${esc(subUrl)}" style="all:initial;display:inline-block;background:#d6ff3d;color:#0b0e14;padding:6px 12px;border-radius:999px;text-decoration:none;font-weight:600;margin-right:8px;font:12px/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:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif">Install Theseus for trustless resolution</a>
</div></div>`;
// Full banner — path form only.
const banner = pathForm ? `<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>
<span id="__nav_relay_countdown" style="all:initial;color:#8b98a9;font:12.5px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;display:inline-flex;align-items:center;gap:6px">Moving in <b id="__nav_relay_countdown_n" style="all:initial;color:#d6ff3d;font-weight:700;font:inherit">10</b>s</span>
<a href="${esc(subUrl)}" id="__nav_relay_go" 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>
<button type="button" id="__nav_relay_stay" style="all:initial;color:#a9b8d0;text-decoration:underline;cursor:pointer;background:transparent;border:none;font:14px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:0">Stay here</button>
<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(){
</div></div>` : "";
const script = `<script>(function(){
var SUB = ${JSON.stringify(subUrl)};
var PATH_FORM = ${pathForm ? "true" : "false"};
var COUNTDOWN_SECONDS = 10;
// Chip wiring (both forms).
var chip = document.getElementById("__nav_relay_chip");
var chipBtn = document.getElementById("__nav_relay_chip_btn");
var chipPop = document.getElementById("__nav_relay_chip_pop");
if (chipBtn && chipPop) {
chipBtn.addEventListener("click", function(e) {
e.stopPropagation();
var open = chipPop.hidden === false;
chipPop.hidden = open;
chipPop.style.display = open ? "none" : "block";
chipBtn.setAttribute("aria-expanded", open ? "false" : "true");
});
document.addEventListener("click", function(e) {
if (!chip || chip.contains(e.target)) return;
if (chipPop.hidden === false) {
chipPop.hidden = true;
chipPop.style.display = "none";
chipBtn.setAttribute("aria-expanded", "false");
}
}, true);
}
if (!PATH_FORM) return; // subdomain form stops here — only the chip is shown.
// Full-banner wiring (path form only).
var banner = document.getElementById("__nav_relay_banner");
var dismissBtn = document.getElementById("__nav_relay_dismiss");
var stayBtn = document.getElementById("__nav_relay_stay");
var countdownEl = document.getElementById("__nav_relay_countdown");
var countdownN = document.getElementById("__nav_relay_countdown_n");
var tick = null;
var stayed = false;
// Hide banner (not chip) if the visitor dismissed within the last 24h.
try {
if (localStorage.getItem("nav.relay.dismissed") === "1") {
var b = document.getElementById("__nav_relay_banner");
if (b) b.style.display = "none";
var raw = localStorage.getItem("nav.relay.dismissed_at");
if (raw && (Date.now() - Number(raw)) < 86400000) {
if (banner) banner.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";
function stopCountdown() {
if (tick) { clearInterval(tick); tick = null; }
if (countdownEl) { countdownEl.style.display = "none"; }
stayed = true;
try { sessionStorage.setItem("nav.relay.stayed", "1"); } catch (e) {}
}
if (document.readyState !== "loading") pad(); else document.addEventListener("DOMContentLoaded", pad);
window.addEventListener("resize", pad);
function startCountdown() {
// Honour previous Stay-here within this tab/session.
try { if (sessionStorage.getItem("nav.relay.stayed") === "1") { if (countdownEl) countdownEl.style.display = "none"; return; } } catch (e) {}
var left = COUNTDOWN_SECONDS;
tick = setInterval(function() {
left -= 1;
if (left <= 0) {
clearInterval(tick); tick = null;
try { window.location.replace(SUB); } catch (e) { window.location.href = SUB; }
return;
}
if (countdownN) countdownN.textContent = String(left);
}, 1000);
}
// Any sign of user activity cancels the auto-redirect — we don't yank
// someone away from a page they're already reading.
["click","keydown","wheel","touchstart","scroll","pointerdown"].forEach(function(ev) {
window.addEventListener(ev, function() { if (tick && !stayed) stopCountdown(); }, { once: true, passive: true, capture: true });
});
if (stayBtn) stayBtn.addEventListener("click", function() { stopCountdown(); });
if (dismissBtn) dismissBtn.addEventListener("click", function() {
stopCountdown();
try { localStorage.setItem("nav.relay.dismissed_at", String(Date.now())); } catch (e) {}
if (banner) banner.remove();
repad();
});
// Push the page down so the banner doesn't overlap it. Re-measure on
// resize and after removal so the layout stays correct.
function repad() {
if (!document.body) return;
var b = document.getElementById("__nav_relay_banner");
document.body.style.paddingTop = (b ? b.offsetHeight + 4 : 0) + "px";
}
if (document.readyState !== "loading") { repad(); startCountdown(); }
else document.addEventListener("DOMContentLoaded", function() { repad(); startCountdown(); });
window.addEventListener("resize", repad);
})();</script>`;
const combined = banner + chip + 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;
if (/<body[^>]*>/i.test(html)) return html.replace(/<body[^>]*>/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/<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.
// baseHref !== null means the PATH form (/bns/<name>/...) — 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":