From d162b745f7d57878de7716a639d979b61d08b746 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 6 Oct 2026 22:34:25 +0200 Subject: [PATCH] fix(theseus,gateway): don't leak relay chrome into BNS-aware clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosis: Theseus does not actually redirect to navigate.st — but for `s3`-record names, it fetches content THROUGH /bns// 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://. Two-layer defense: Gateway: Each injected block is now wrapped in `` / `` 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 `` 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. --- main.js | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 239 insertions(+), 14 deletions(-) diff --git a/main.js b/main.js index 6cf7f704..7cc6b140 100644 --- a/main.js +++ b/main.js @@ -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=" 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 so assets // resolve against the bns:// origin, not back through the relay. - body = Buffer.from(body.toString("utf8").replace(//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 comments + // for exactly this kind of surgical removal. + body = Buffer.from( + body.toString("utf8") + .replace(//i, "") + .replace(/[\s\S]*?/gi, "") + .replace(/[\s\S]*?/gi, "") + .replace(/[\s\S]*?/gi, ""), + "utf8" + ); } return upstreamResponse({ ...up, buffer: body }, ct); } @@ -2482,7 +2609,7 @@ function firstPartyAddonIds() { // leave the bundle: their extensions-data/.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;