fix(theseus,gateway): don't leak relay chrome into BNS-aware clients

Diagnosis: Theseus does not actually redirect to navigate.st — but for
`s3`-record names, it fetches content THROUGH /bns/<name>/ on the
gateway (Sia keys cannot ship in a public build). Since 0.3.80 that
fetch carries `x-bns-policy: client` so the gateway suppresses the
relay banner + chip; older Theseus releases got the chrome bleed-
through, which read as "redirected to navigate.st" even though the
address bar still showed https://<name>.

Two-layer defense:

Gateway:
  Each injected block is now wrapped in `<!--nav-relay-X-start-->` /
  `<!--nav-relay-X-end-->` sentinel comments so any downstream client
  can strip it surgically without parsing the nested markup. Function
  is unchanged for browsers (chrome shown by default; suppressed on
  x-bns-policy: client).

Theseus main.js serveBns() s3 branch:
  Already stripped the injected `<base href="/bns/...">` tag. Now also
  strips any relay banner / chip / boot script found between the
  sentinel comments. Catches older gateways, misconfigured deploys,
  or any future edge cache that returns a chrome-bearing response.
  No change to h / ip / p / u paths — those don't touch the gateway.

Other BNS-aware clients (Ariadne daemon, Ariadne mobile) that also
proxy s3 through the gateway should apply the same sentinel-based
stripping or send x-bns-policy: client. The gateway honours either.
This commit is contained in:
Local Dev 2026-10-06 22:34:25 +02:00
parent 0044a157f0
commit d162b745f7

253
main.js
View file

@ -4,7 +4,7 @@
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
// page, and optional Tor onion routing. No system daemon; the app is the trust
// boundary.
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess, safeStorage, webFrameMain } = require("electron");
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess, safeStorage, webFrameMain, webContents, desktopCapturer } = require("electron");
const path = require("path");
const url = require("url");
const http = require("http");
@ -475,6 +475,7 @@ const SETTINGS_DEFAULTS = {
webrtcMode: "public_only", // WebRTC IP policy: default | public_only | public_private | disable_udp
blockCamera: true, // deny camera by default (also hides camera labels from fingerprinting)
blockMicrophone: true, // deny microphone by default (also hides mic labels)
blockScreenCapture: false, // getDisplayMedia always shows the source picker, so prompt rather than pre-deny
hideMediaDevices: true, // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox
restoreSession: true, // reopen last session's tabs on launch
backgroundThrottle: true, // throttle inactive tabs / the window when unfocused
@ -1669,8 +1670,116 @@ function mediaAllowed(kinds) {
if (kinds.includes("audio") && settings.blockMicrophone) return false;
return true;
}
// Screen capture (navigator.mediaDevices.getDisplayMedia).
//
// Electron routes getDisplayMedia through setDisplayMediaRequestHandler and
// NOT through setPermissionRequestHandler; with no handler installed the call
// rejects with NotSupportedError("Not supported") before any policy of ours
// runs. That is why screen sharing silently did not work in any tab until
// this existed — Meet/Jitsi/Whereby all failed at the first click.
//
// Unlike camera and microphone (which are decided by a setting alone, because
// there is no prompt for them), every display-capture request shows the source
// picker, and nothing is captured unless the user picks a source and confirms.
// The picker IS the consent, so the default is allow-with-prompt rather than
// deny; `blockScreenCapture` exists for anyone who wants a hard no.
//
// Add-on panels are held to a second gate: the add-on must declare the
// "screen-capture" capability. An add-on that never asked for it cannot reach
// the picker at all, so an installed community extension cannot put a
// screen-share prompt on screen and hope the user clicks through it.
async function handleDisplayMediaRequest(request, callback) {
// callback(null) is how Electron spells "no". callback({}) looks like it
// should mean the same thing and does not: with video requested it throws
// "Video was requested, but no video stream was provided", which turns a
// plain user cancellation into an exception inside the handler.
const deny = (why) => {
if (why) console.log(`[screen-capture] denied — ${why}`);
callback(null);
};
if (settings.blockScreenCapture) return deny("blocked in Settings › Privacy");
if (!request.videoRequested) return deny("audio-only display capture is not offered");
const frame = request.frame;
if (!frame) return deny("requesting frame is gone");
const wc = (() => { try { return webContents.fromFrame(frame); } catch { return null; } })();
// Who is asking? An add-on panel (file:// under the extensions dir) needs
// the capability; anything else is ordinary web content and is named by its
// origin in the prompt.
const addonId = wc ? addonIdForSender(wc) : null;
let who = request.securityOrigin || "";
if (addonId) {
const inst = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId);
const caps = (inst && inst.manifest && inst.manifest.capabilities) || [];
if (!caps.includes("screen-capture")) {
return deny(`add-on "${addonId}" must declare the "screen-capture" capability in addon.json`);
}
who = (inst && inst.manifest.name) || addonId;
}
// thumbnailSize 0x0: we present a text list, and capturing a bitmap of every
// open window just to throw it away is the expensive half of getSources().
let sources = [];
try {
sources = await desktopCapturer.getSources({
types: ["screen", "window"], thumbnailSize: { width: 0, height: 0 },
});
} catch (e) { return deny(`desktopCapturer failed: ${e?.message || e}`); }
if (!sources.length) return deny("no capturable screens or windows");
// Screens first, then windows; a window with no title is not worth offering.
const screens = sources.filter((s) => s.id.startsWith("screen:"));
const windows = sources.filter((s) => !s.id.startsWith("screen:") && String(s.name || "").trim());
const options = [
...screens.map((s, i) => ({ value: s.id, label: screens.length > 1 ? `Entire screen ${i + 1}` : "Entire screen" })),
...windows.map((s) => ({ value: s.id, label: `Window — ${String(s.name).slice(0, 70)}` })),
];
const tabId = wc ? (tabs.find((t) => t.view.webContents === wc)?.id ?? null) : null;
const wantsAudio = !!request.audioRequested;
const audioNote = wantsAudio
? (process.platform === "win32"
? "\n\nIt has also asked for your computer's audio."
: "\n\nIt has also asked for your computer's audio, which this platform cannot provide — the recording will be silent unless you add a microphone.")
: "";
const picked = await showApprovalModal({
title: "Share your screen?",
body: `${addonId ? "The add-on" : "This site"} wants to see a screen or window. `
+ `Whatever you choose is visible to it until you stop sharing.${audioNote}`,
origin: who,
select: { id: "source", label: "Share", options },
actions: [
{ id: "cancel", label: "Cancel" },
{ id: "share", label: "Share", primary: true },
],
}, addonId || null, tabId);
// "share+source=<id>" on approval; "cancel" (or a bare "share" if the
// dropdown somehow came back empty) means no.
const m = /^share\+source=(.+)$/.exec(String(picked || ""));
if (!m) return deny("user declined");
const chosen = sources.find((s) => s.id === m[1]);
if (!chosen) return deny("chosen source disappeared");
const streams = { video: { id: chosen.id, name: chosen.name } };
// Loopback (system audio) is Windows-only in Electron; elsewhere we grant
// video alone rather than fail the whole request.
if (wantsAudio && process.platform === "win32") streams.audio = "loopback";
console.log(`[screen-capture] granted ${chosen.id} ("${String(chosen.name).slice(0, 40)}")`
+ `${streams.audio ? " + system audio" : ""} to ${who}`);
callback(streams);
}
function applyPermissions() {
const ses = session.defaultSession;
ses.setDisplayMediaRequestHandler((request, callback) => {
// The handler must call back exactly once and must not throw, or
// getDisplayMedia() hangs forever instead of failing.
handleDisplayMediaRequest(request, callback).catch((e) => {
console.warn("[screen-capture] handler threw:", e?.message || e);
try { callback(null); } catch {}
});
});
ses.setPermissionRequestHandler((_wc, permission, callback, details) => {
if (permission === "media") return callback(mediaAllowed(details?.mediaTypes || []));
if (permission === "geolocation") return callback(effLocation() !== "deny"); // allow unless "hide"
@ -1685,6 +1794,10 @@ function applyPermissions() {
return !(settings.blockCamera && settings.blockMicrophone);
}
if (permission === "geolocation") return effLocation() !== "deny";
// Chromium checks display-capture before it issues the request. Answering
// the check honestly keeps feature-detection ("can I offer a Share
// button?") in step with what the request would actually do.
if (permission === "display-capture") return !settings.blockScreenCapture;
if (SENSITIVE_DEVICE.has(permission) || DENIED_PERMISSIONS.has(permission)) return false;
return true;
});
@ -2250,7 +2363,21 @@ async function serveBns(request) {
if (ct.includes("text/html")) {
// Strip the gateway's path-form <base href="/bns/<name>/"> so assets
// resolve against the bns:// origin, not back through the relay.
body = Buffer.from(body.toString("utf8").replace(/<base\s+href="\/bns\/[^"]*">/i, ""), "utf8");
// Also strip the gateway's relay chrome (banner + chip + their boot
// script) in case an older gateway, a misconfigured deployment, or
// a cached edge missed the x-bns-policy signal and injected it
// anyway — the user is already in Theseus under the name's own
// origin, so navigate.st's warning would be a false alarm. The
// gateway wraps each piece in <!--nav-relay-X-start/end--> comments
// for exactly this kind of surgical removal.
body = Buffer.from(
body.toString("utf8")
.replace(/<base\s+href="\/bns\/[^"]*">/i, "")
.replace(/<!--nav-relay-banner-start-->[\s\S]*?<!--nav-relay-banner-end-->/gi, "")
.replace(/<!--nav-relay-chip-start-->[\s\S]*?<!--nav-relay-chip-end-->/gi, "")
.replace(/<!--nav-relay-script-start-->[\s\S]*?<!--nav-relay-script-end-->/gi, ""),
"utf8"
);
}
return upstreamResponse({ ...up, buffer: body }, ct);
}
@ -2482,7 +2609,7 @@ function firstPartyAddonIds() {
// leave the bundle: their extensions-data/<id>.json and vault.derive
// namespace (e.g. pithos/sia-recovery/v1) outlive them, and a community
// add-on installed under a dropped id would inherit both.
for (const id of ["bchwallet", "siawallet", "aegis", "blocker", "consent", "docx-editor", "notepad", "pdf-editor", "pithos", "screenshot", "translate", "vpn"]) {
for (const id of ["bchwallet", "siawallet", "aegis", "blocker", "consent", "docx-editor", "notepad", "pdf-editor", "pithos", "recorder", "screenshot", "translate", "vpn"]) {
if (!bundled.has(id)) absorbed.add(id);
}
firstPartyIdsCache = { bundled, absorbed };
@ -3175,17 +3302,7 @@ function initAddons() {
// Strip path separators — add-on-provided filename must not escape the
// downloads folder.
const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 200) || "screenshot.png";
const dlDir = app.getPath("downloads");
let target = path.join(dlDir, safe);
// Uniquify: append " (n)" before the extension if the name is taken.
if (fs.existsSync(target)) {
const ext = path.extname(safe);
const stem = safe.slice(0, safe.length - ext.length);
for (let i = 2; i < 10000; i++) {
const cand = path.join(dlDir, `${stem} (${i})${ext}`);
if (!fs.existsSync(cand)) { target = cand; break; }
}
}
const target = uniqueDownloadPath(safe);
try { fs.writeFileSync(target, bytes); }
catch (e) { throw new Error(`saveCapture: write failed: ${e?.message || e}`); }
const id = nextDlId++;
@ -3205,6 +3322,85 @@ function initAddons() {
console.log(`[addons] [${addonId}] saveCapture wrote ${bytes.length} bytes → ${target}`);
return { savePath: target };
},
// screen-capture: a recording written incrementally.
//
// The file is built under a ".part" name — the same convention a normal
// download uses — and renamed only once the add-on says it is finished,
// so a crash or a quit mid-recording cannot leave something that looks
// like a complete video. Main holds the only file descriptor; the add-on
// holds an opaque id.
recordingBegin: async (opts, addonId) => {
const raw = String(opts?.filename || "recording.webm");
const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 200) || "recording.webm";
const target = uniqueDownloadPath(safe);
const partPath = target + ".part";
let fd;
try { fd = fs.openSync(partPath, "w"); }
catch (e) { throw new Error(`recordingBegin: cannot create ${path.basename(partPath)}: ${e?.message || e}`); }
const id = nextRecordingId++;
liveRecordings.set(id, {
addonId, fd, partPath, target, bytes: 0,
mime: String(opts?.mime || "video/webm"), startedAt: Date.now(),
});
console.log(`[addons] [${addonId}] recordingBegin #${id} → ${path.basename(partPath)}`);
return { id, filename: path.basename(target) };
},
recordingWrite: async (opts, addonId) => {
const r = liveRecordings.get(Number(opts?.id));
if (!r) throw new Error("recordingWrite: no such recording (already finished?)");
if (r.addonId !== addonId) throw new Error("recordingWrite: not your recording");
// Accept a Uint8Array/ArrayBuffer (what structured clone delivers from
// the panel) or a plain byte array, and nothing else — a string here
// would silently write mojibake instead of video.
const b = opts?.bytes;
let buf;
if (b instanceof Uint8Array) buf = Buffer.from(b.buffer, b.byteOffset, b.byteLength);
else if (b instanceof ArrayBuffer) buf = Buffer.from(b);
else if (Array.isArray(b)) buf = Buffer.from(b);
else throw new Error("recordingWrite: bytes must be a Uint8Array");
if (!buf.length) return { written: r.bytes };
// A runaway recorder should hit a wall rather than fill the disk.
if (r.bytes + buf.length > MAX_RECORDING_BYTES) {
throw new Error(`recordingWrite: recording exceeded the ${Math.round(MAX_RECORDING_BYTES / 1e9)} GB cap`);
}
try { fs.writeSync(r.fd, buf); }
catch (e) { throw new Error(`recordingWrite: write failed: ${e?.message || e}`); }
r.bytes += buf.length;
return { written: r.bytes };
},
recordingEnd: async (opts, addonId) => {
const id = Number(opts?.id);
const r = liveRecordings.get(id);
if (!r) throw new Error("recordingEnd: no such recording");
if (r.addonId !== addonId) throw new Error("recordingEnd: not your recording");
liveRecordings.delete(id);
try { fs.closeSync(r.fd); } catch {}
if (opts?.cancel || r.bytes === 0) {
try { fs.unlinkSync(r.partPath); } catch {}
console.log(`[addons] [${addonId}] recordingEnd #${id} discarded (${r.bytes} bytes)`);
return { discarded: true, bytes: r.bytes };
}
// The chosen name may have been taken while we were recording.
let final = r.target;
if (fs.existsSync(final)) final = uniqueDownloadPath(path.basename(r.target));
try { fs.renameSync(r.partPath, final); }
catch (e) { throw new Error(`recordingEnd: could not finalise ${path.basename(final)}: ${e?.message || e}`); }
const rec = {
id: nextDlId++,
filename: path.basename(final),
url: `internal://addons/${addonId}/${path.basename(final)}`,
mime: r.mime,
total: r.bytes,
received: r.bytes,
state: "completed",
savePath: final,
startedAt: r.startedAt,
};
downloads.unshift(rec);
emitDownloads();
console.log(`[addons] [${addonId}] recordingEnd #${id} wrote ${r.bytes} bytes → ${final}`);
return { savePath: final, bytes: r.bytes };
},
// revealSidebar hook — used by add-ons declaring "context-menu-item" so
// a right-click handler can pull the sidebar open to its own panel.
// AddonHost has already gated by ownership before calling us; here we
@ -3253,6 +3449,34 @@ function addonIdForSender(sender) {
// In-memory download list. Not persisted: closing the browser clears history
// (the files are still on disk; only the list of "recent downloads" is dropped).
const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem
// Pick a free path in Downloads for `safe`, appending " (n)" before the
// extension if the name is taken — the same shape Chromium uses.
function uniqueDownloadPath(safe) {
const dlDir = app.getPath("downloads");
const first = path.join(dlDir, safe);
if (!fs.existsSync(first)) return first;
const ext = path.extname(safe);
const stem = safe.slice(0, safe.length - ext.length);
for (let i = 2; i < 10000; i++) {
const cand = path.join(dlDir, `${stem} (${i})${ext}`);
if (!fs.existsSync(cand)) return cand;
}
return first;
}
// In-flight screen recordings (screen-capture capability). id -> handle; main
// owns the file descriptor so an add-on can never hold one open past quit.
const liveRecordings = new Map(); let nextRecordingId = 1;
const MAX_RECORDING_BYTES = 8e9; // 8 GB — a stop-the-disk-filling backstop
function closeAllRecordings() {
for (const [id, r] of liveRecordings) {
try { fs.closeSync(r.fd); } catch {}
// Never promote a .part to a real filename here: a recording that did not
// reach recordingEnd has no finalised container, and leaving the .part is
// more honest than handing the user a video that may not play.
console.log(`[addons] [${r.addonId}] recording #${id} left unfinished (${r.bytes} bytes in ${path.basename(r.partPath)})`);
}
liveRecordings.clear();
}
const tabs = []; // { id, view, title, url, prov }
let activeId = null, tabSeq = 0;
const tabById = (id) => tabs.find((t) => t.id === id);
@ -6829,6 +7053,7 @@ ipcMain.handle("install-update-now", () => {
return true;
});
app.on("will-quit", () => {
closeAllRecordings();
if (!pendingInstallerPath) return;
const setupPath = pendingInstallerPath;
pendingInstallerPath = null;