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:
parent
1ec4fdde7d
commit
53fd569b0d
1 changed files with 159 additions and 46 deletions
|
|
@ -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) => ({ "&":"&", "<":"<", ">":">", "\"":""", "'":"'" }[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":
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue