Theseus: same-host bns subresource routing

Pages served from bns://<name>/… that fetch http(s)://<name>/… over
XHR / fetch / <img> / <script> are talking to one endpoint by every
reading except Chromium's scheme-strict one — the on-chain IP record,
the pinned TLS cert and the cookie jar for bns://<name> all describe
one server. Previously the subresource went out via clearnet DNS and
either hit hostile Wi-Fi (DNS hijack memory) or came back with a CORS
error, breaking socket.io on bns://game.x/checkers/play/.

Rewrite rules in session.webRequest.onBeforeRequest:
- Must be a SUBRESOURCE (resourceType !== mainFrame). Top-level nav
  stays on the clearnet scheme the author linked to.
- resourceType !== webSocket. bns:// is a fetch-only privileged scheme
  and Chromium refuses to redirect a ws request to a non-ws scheme.
  Modern socket.io falls back to XHR polling, which this does route.
- Initiator origin is bns://<samehost> where samehost matches the
  request's target host (case-insensitive, port stripped).

Settings toggle "Same-host subresource routing" under Registries lets
users turn it off; default is on. Add-on request filters still run
AFTER the rewrite (the rewritten bns:// URL does not re-enter the
http/https filter path, which is the right default — bns:// content
is already protocol-filtered by the serveBns handler).
This commit is contained in:
Hermes Dev 2026-10-30 17:36:53 +02:00
parent 69390f20fb
commit f54f711463
2 changed files with 59 additions and 0 deletions

53
main.js
View file

@ -738,6 +738,13 @@ const SETTINGS_DEFAULTS = {
// work regardless of this; the menu entry is just a discoverability
// escape hatch for users who hit the F12-vs-airplane-mode foot-gun.
contextMenuDevTools: true,
// Route http(s)://<samehost> SUBRESOURCE requests from a bns://<samehost>
// page back through the bns:// handler, so cookies, pinned TLS and the
// on-chain IP record all apply — matching what the author expected when
// they wrote `fetch("/api/...")` next to `fetch("https://game.x/api/...")`
// on the same page. See the installRequestFilter listener below. Top-
// level navigation and non-matching hosts are untouched.
bnsSameHostSubresource: true,
};
// Applying the theme via nativeTheme.themeSource makes prefers-color-scheme update
// in every renderer (chrome, settings, popover, page views) with no per-view IPC.
@ -3205,8 +3212,54 @@ function notifyTabChange() {
const t = activeTab(); const info = t ? { id: t.id, url: t.url || "" } : null;
for (const cb of tabChangeListeners) { try { cb(info); } catch (e) { console.warn("[addons] tab listener failed:", e?.message); } }
}
// Cross-scheme same-host subresource rewrite: a page served at
// bns://<name>/… that fetches https://<name>/… (or http://<name>/…) over
// XHR / fetch / <img> / <script> is talking to its own origin by every
// reading except Chromium's scheme-strict one. The on-chain IP record,
// the pinned TLS cert, and the cookie jar for bns://<name> all describe
// one endpoint; sub-resource requests should land on it, not on whatever
// clearnet DNS surfaces for the same name (which may be hostile Wi-Fi
// hijack, a stale CDN or nothing at all — see memory
// [[dns-hijack-aaaa-mangle]]).
//
// Rewrite rules:
// - Must be a SUBRESOURCE (not a mainFrame navigation — a page's own
// navigation to https://<name>/other-page is the user's choice and
// stays on the clearnet scheme the author linked to).
// - Initiator origin must be bns://<samehost> where samehost matches
// the request's target host, case-insensitively.
// - Scheme is http or https.
//
// WebSocket upgrades (wss://<samehost>) are NOT rewritten — bns:// is a
// fetch-only privileged scheme and Chromium cannot redirect a ws request
// to a non-ws scheme. Modern socket.io clients auto-fall-back to XHR
// polling, which this rewrite does route through bns://. A dedicated
// WebSocket tunnel sits in a follow-up.
function bnsSameHostRedirect(details) {
if (settings.bnsSameHostSubresource === false) return null;
const rt = details.resourceType;
if (!rt || rt === "mainFrame" || rt === "webSocket") return null;
const url = String(details.url || "");
const m = /^(https?:)\/\/([^/]+)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/i.exec(url);
if (!m) return null;
const scheme = m[1].toLowerCase();
if (scheme !== "https:" && scheme !== "http:") return null;
const targetHost = m[2].toLowerCase().replace(/:\d+$/, "");
const path = m[3] || "/", search = m[4] || "", hash = m[5] || "";
const init = String(details.initiatorOrigin || "").toLowerCase();
const im = /^bns:\/\/([^/:]+)/.exec(init);
if (!im) return null;
const initHost = im[1];
if (initHost !== targetHost) return null;
// A port suffix on the target (https://game.x:8443) is dropped; bns://
// addresses the on-chain endpoint for the name, which does not carry
// the author's clearnet port. Rewriting here keeps the author's path.
return `bns://${targetHost}${path}${search}${hash}`;
}
function installRequestFilter() {
session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
const redirect = bnsSameHostRedirect(details);
if (redirect) return callback({ redirectURL: redirect });
if (!requestFilters.size || !/^https?:/i.test(details.url)) return callback({});
let frameUrl = ""; try { frameUrl = (details.frame && details.frame.url) || details.referrer || ""; } catch { frameUrl = details.referrer || ""; }
const view = { url: details.url, resourceType: details.resourceType, method: details.method, frameUrl, initiator: details.initiatorOrigin || "", webContentsId: details.webContentsId ?? null };

View file

@ -395,6 +395,7 @@
<label class="polrow"><input type="radio" name="collisionPolicy" value="icann-first"><span><b>ICANN first</b> <span class="pmuted">— ICANN wins conflicts; BCDN fills gaps for non-ICANN TLDs.</span></span></label>
<label class="polrow"><input type="radio" name="collisionPolicy" value="soft"><span><b>Ask each time</b> <span class="pmuted">— an "Open with…" prompt on conflict, remembered per name or per TLD.</span></span></label>
</div>
<label class="polrow" style="margin-top:4px"><input type="checkbox" id="bnsSameHostSubresource"><span><b>Same-host subresource routing</b> <span class="pmuted">— a page on <code>bns://name</code> fetching <code>https://name/…</code> for XHR/fetch/images gets routed through the BNS pinned endpoint (cookies, CORS, TLS all under one origin). Top-level navigation and other hosts are untouched; WebSocket upgrades stay on <code>wss://</code>.</span></span></label>
</div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<div class="txt"><div class="t">Remembered choices</div>
@ -2527,6 +2528,11 @@
cmDev.checked = s.contextMenuDevTools !== false;
cmDev.addEventListener("change", () => { C.set("contextMenuDevTools", !!cmDev.checked); });
}
const bnsSH = document.getElementById("bnsSameHostSubresource");
if (bnsSH) {
bnsSH.checked = s.bnsSameHostSubresource !== false;
bnsSH.addEventListener("change", () => { C.set("bnsSameHostSubresource", !!bnsSH.checked); });
}
// ---- Plug-ins: Ariadne's Thread + Aegis --------------------------------
// Two views per plug-in that share one truth source: