From 08beadcf8fe4beb6b2e98f0e72c67d0e41d093ad Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 09:50:10 +0200 Subject: [PATCH] Theseus: close the Settings-tab vault leak and the add-on update signer bypass A preload belongs to the WebContents, not the page: a website loaded into the Settings tab kept window.cfg and could read every vault password, flip settings and install extensions without consent. Settings and add-on tabs now never load web content, and the channels behind settings-preload check their sender. `navigate` no longer accepts calls from web pages. Add-on updates trusted any publisherSig, whatever name it carried, even for bundled add-ons. The trust root is now the installed addon.json (publisher, or the operator key when there is none); versions must be plain dotted numbers; a community install can't take over a bundled or foreign id. Also: - autofill matches and fills against the live URL, not a stale prov.host - bns:// forwards the raw request path (..%2F escaped the name's bucket) - clipboard-read denied, openExternal asks; forged collision choices ignored - web pages can't window.open file:/chrome:/theseus:; data:/blob: no longer go to the search engine; the quick-links panel loses home-preload - clear-history-on-quit is awaited and removes history.json too - update helper takes its paths from the environment (non-ASCII profiles) - electrum poll has a deadline; misses wait at most 2.5 s - p records go through Tor; add-on proxy credentials are actually used - whole-folder require-cache bust on add-on version change; failed activate() no longer leaks its request filter - approvals released when the window closes; web-app ids stay on-origin --- GOTCHAS.md | 19 +++ addon-updater.js | 55 ++++++-- addons-host.js | 28 +++- lib/update-helper.cjs | 17 ++- main.js | 317 ++++++++++++++++++++++++++++++++---------- settings.html | 2 +- webapps.js | 6 + 7 files changed, 346 insertions(+), 98 deletions(-) diff --git a/GOTCHAS.md b/GOTCHAS.md index 220e03d4..aa2724d6 100644 --- a/GOTCHAS.md +++ b/GOTCHAS.md @@ -62,6 +62,25 @@ Fix, currently in [settings.html](settings.html): Chromium also respects `select option { background; color }` in the popup on Windows — a belt-and-braces override alongside `color-scheme`. +## A preload belongs to the WebContents, not the page + +A view's preload runs for every document that view ever loads. Navigating +a Settings tab to a website used to hand that site `window.cfg`, which +covers the vault (`pwGet`), settings and extension installs. Two rules +follow: + +- Settings and add-on-file tabs never load anything else. `navigateTab` + and `will-navigate` open the target in a fresh tab instead. +- A new IPC channel exposed through `settings-preload.js` **must** be added + to `SETTINGS_ONLY` (or `SETTINGS_SHARED` if the toolbar's `preload.js` + calls it too) in [main.js](main.js). The wrapper around `ipcMain.handle` + only checks the sender for the channels in those sets. Any other channel + is callable by whatever page ends up in the view. + +The same applies to `home-preload.js`, which is in *every* tab. Handlers +behind it check `isHomePageSender` / `isErrorPageSender`, which compare +the sender against the exact shipped file:// URL. + ## The resolver in Theseus is `resolver-web.mjs`, not `.js` Packaged builds ship `Argus/src/lib/resolver-web.js` as `resolver-web.mjs` diff --git a/addon-updater.js b/addon-updater.js index 8b0c0856..9f168db6 100644 --- a/addon-updater.js +++ b/addon-updater.js @@ -32,6 +32,14 @@ const SIG_DOMAIN = "silentmode.addon-update-v1"; const MAX_MANIFEST_BYTES = 128 * 1024; // updates.json shouldn't exceed 128 KB const MAX_TARBALL_BYTES = 16 * 1024 * 1024; // an add-on payload above 16 MB is suspicious const MAX_REDIRECTS = 3; +// A channel's version string ends up in staging paths and temp-file names, so +// only plain dotted numbers are accepted ("1.2.3", not "9/../../x"). +const VERSION_RE = /^\d{1,6}(?:\.\d{1,6}){0,3}$/; +// Windows resolves a bare "tar" against the current directory before PATH; +// use the system copy (Win10 1803+) explicitly. +const TAR = process.platform === "win32" + ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe") + : "tar"; function cmpVer(a, b) { const A = String(a || "").split(".").map((n) => parseInt(n, 10) || 0); @@ -149,11 +157,15 @@ function verifySignature(id, version, tarballSha256, sigB64, pubkeysHex) { } // ---------- single-add-on staging ---------------------------------------- -// Read a channel manifest and pick its best entry. An entry is trusted when -// EITHER the operator signed it (Ed25519 `sig`, bundled add-ons) OR the -// publisher signed it (`publisherSig`, community extensions — verified by the -// caller-supplied verifyPublisher against the publisher name's NFT owner). -async function pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, log, timeoutMs }) { +// Read a channel manifest and pick its best entry. The trust root comes from +// the caller (the installed addon.json, or the catalog card), never from the +// channel itself: with `publisher` set, the entry must name that publisher and +// carry its `publisherSig` (verified by verifyPublisher against the name's NFT +// owner); without it, only the operator's Ed25519 `sig` counts. Otherwise +// whoever can write a channel could sign a bundled add-on's update with any +// name they own. +async function pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, publisher, log, timeoutMs }) { + const pub = publisher ? String(publisher).toLowerCase() : null; let manifestBuf; try { manifestBuf = await httpGet(updateURL, { timeoutMs, maxBytes: MAX_MANIFEST_BYTES }); } catch (e) { log(`updates: fetch ${id} failed:`, e.message); return { status: "fetch-failed", detail: e.message }; } @@ -163,14 +175,16 @@ async function pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyP const addons = Array.isArray(manifest?.addons) ? manifest.addons : []; let best = null; for (const e of addons) { - if (!e?.version || !e?.url || !e?.sha256 || !(e?.sig || e?.publisherSig)) continue; + if (!e?.version || !e?.url || !e?.sha256 || !(pub ? e?.publisherSig : e?.sig)) continue; + if (!VERSION_RE.test(String(e.version))) continue; + if (pub && String(e.publisher || "").toLowerCase() !== pub) continue; if (currentVer && cmpVer(e.version, currentVer) <= 0) continue; if (!best || cmpVer(e.version, best.version) > 0) best = e; } if (!best) return { status: "up-to-date" }; let trusted = false; - if (best.sig && Array.isArray(pubkeysHex) && pubkeysHex.length) trusted = verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex); - if (!trusted && best.publisherSig && typeof verifyPublisher === "function") { + if (!pub && Array.isArray(pubkeysHex) && pubkeysHex.length) trusted = verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex); + if (pub && typeof verifyPublisher === "function") { try { trusted = !!(await verifyPublisher({ ...best, id })); } catch (e) { log(`updates: publisher verify ${id}@${best.version} threw:`, e.message); } } if (!trusted) { @@ -219,10 +233,10 @@ async function fetchVerifiedPackage({ id, version, url, sha256, log }) { const posix = (p) => p.replace(/\\/g, "/"); const baseArgs = ["-x", "-z", "-f", posix(tmpFile), "-C", posix(tmpDir)]; try { - execFileSync("tar", baseArgs, { stdio: "ignore" }); + execFileSync(TAR, baseArgs, { stdio: "ignore" }); } catch (e1) { try { - execFileSync("tar", ["--force-local", ...baseArgs], { stdio: "ignore" }); + execFileSync(TAR, ["--force-local", ...baseArgs], { stdio: "ignore" }); } catch (e2) { // Surface the first error; --force-local retry is opportunistic. throw e1; @@ -258,8 +272,8 @@ function placeDir(from, to) { catch { fs.cpSync(from, to, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); } } -async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs }) { - const picked = await pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, log, timeoutMs }); +async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, verifyPublisher, publisher, log, timeoutMs }) { + const picked = await pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, publisher, log, timeoutMs }); if (picked.status !== "ok") return picked; const best = picked.best; @@ -285,11 +299,19 @@ async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, veri // checks it against the name's NFT owner); a prior copy is kept in backupsDir // like every other add-on swap. The installed addon.json gets `updateURL` // and `publisher` so the regular update check covers it from then on. -async function installCommunity({ id, updatesUrl, addonsDir, backupsDir, verifyPublisher, log = () => {}, timeoutMs = 15000 }) { +async function installCommunity({ id, updatesUrl, publisher, addonsDir, backupsDir, verifyPublisher, log = () => {}, timeoutMs = 15000 }) { if (typeof verifyPublisher !== "function") return { ok: false, error: "no publisher verifier" }; + if (!publisher) return { ok: false, error: "catalog entry has no publisher" }; const dest = path.join(addonsDir, id); - const currentVer = readAddonJson(dest)?.version || null; - const picked = await pickChannelEntry({ id, currentVer, updateURL: updatesUrl, pubkeysHex: [], verifyPublisher, log, timeoutMs }); + const current = readAddonJson(dest); + // An id that is already installed belongs to whoever signed it — an + // operator-signed add-on (no publisher) or another publisher's extension + // can't be replaced by a catalog entry that reuses its id. + if (current && String(current.publisher || "").toLowerCase() !== String(publisher).toLowerCase()) { + return { ok: false, error: `"${id}" is already installed from another publisher` }; + } + const currentVer = current?.version || null; + const picked = await pickChannelEntry({ id, currentVer, updateURL: updatesUrl, pubkeysHex: [], verifyPublisher, publisher, log, timeoutMs }); if (picked.status === "up-to-date") return { ok: false, error: currentVer ? `already installed (v${currentVer})` : "channel has no installable version" }; if (picked.status !== "ok") return { ok: false, error: picked.status + (picked.detail ? ": " + picked.detail : ""), status: picked.status }; const best = picked.best; @@ -300,6 +322,8 @@ async function installCommunity({ id, updatesUrl, addonsDir, backupsDir, verifyP const mf = { ...pkg.manifest }; if (!mf.updateURL) mf.updateURL = updatesUrl; mf.publisher = best.publisher; + // First-party plug-in placement is not something a package can claim. + delete mf.category; delete mf.absorbs; fs.writeFileSync(path.join(pkg.tmpDir, "addon.json"), JSON.stringify(mf, null, 2)); fs.mkdirSync(addonsDir, { recursive: true }); if (fs.existsSync(dest)) { @@ -351,6 +375,7 @@ async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, verifyPu id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, + publisher: manifest.publisher || null, stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs, }) .then((r) => report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, ...r })) diff --git a/addons-host.js b/addons-host.js index 8366d29a..cc4c5c2b 100644 --- a/addons-host.js +++ b/addons-host.js @@ -371,14 +371,24 @@ class AddonHost { _activateOne(manifest, folder) { const mainPath = path.join(folder, manifest.main); + if (this._active.has(manifest.id)) { + throw new Error(`duplicate add-on id "${manifest.id}" (already loaded from ${this._active.get(manifest.id).folder})`); + } // require() from a folder outside asar is fine — Electron just uses Node's // resolver. This is where the trust decision lives: we're loading arbitrary // JS into the main process with full API access. let mod; try { - // Bust the require cache so a manual reload (future feature) picks up - // edits — cheap since add-ons are small. - delete require.cache[require.resolve(mainPath)]; + // Bust the require cache so a manual reload picks up edits — cheap since + // add-ons are small. When the version changed (a hot-applied update) the + // whole folder goes, or the new index.js would run against the previous + // version's lib/*.js; a plain re-activation keeps its submodules. + this._loadedVersions = this._loadedVersions || new Map(); + if (this._loadedVersions.get(folder) !== manifest.version) { + const root = path.resolve(folder).toLowerCase() + path.sep; + for (const k of Object.keys(require.cache)) if (k.toLowerCase().startsWith(root)) delete require.cache[k]; + } else delete require.cache[require.resolve(mainPath)]; + this._loadedVersions.set(folder, manifest.version); mod = require(mainPath); } catch (e) { throw new Error(`require() failed: ${e?.message || e}`); @@ -398,8 +408,16 @@ class AddonHost { active.inject = { source, matchers: manifest.pageInject.matchers, origins: manifest.pageInject.origins }; } const api = this._makeApi(active); - try { mod.activate(api); } - catch (e) { throw new Error(`activate() threw: ${e?.message || e}`); } + // Request filters and tab listeners register as activate() runs; if it + // fails the add-on never reaches _active, so _deactivateAll can't undo them. + const cleanup = () => { + try { if (this._requestFilter) this._requestFilter.clear(manifest.id); } catch {} + for (const off of active.tabListeners) { try { off(); } catch {} } + }; + try { + const r = mod.activate(api); + if (r && typeof r.then === "function") r.catch((e) => this.log(`[${manifest.id}] activate() rejected: ${e?.message || e}`)); + } catch (e) { cleanup(); throw new Error(`activate() threw: ${e?.message || e}`); } this._active.set(manifest.id, active); this.log(`activated ${manifest.id} v${manifest.version}`); } diff --git a/lib/update-helper.cjs b/lib/update-helper.cjs index dd9f44ee..3398af4f 100644 --- a/lib/update-helper.cjs +++ b/lib/update-helper.cjs @@ -23,6 +23,8 @@ // Batch specifics: `timeout` refuses to run without a console, so sleeps are // `ping -n 127.0.0.1`; the setup is launched with `start "" /wait` // so the script blocks until the installer exits. The script deletes itself. +const ENV_SETUP = "THESEUS_UPDATE_SETUP"; +const ENV_DIR = "THESEUS_UPDATE_DIR"; function buildUpdateHelperCmd({ pid, setupPath, installDir, args = ["/S", "--force-run"], graceSec = 2, maxWaitSec = 120 }) { if (!Number.isInteger(pid) || pid <= 0) throw new Error("pid required"); if (typeof setupPath !== "string" || !setupPath || /["\r\n%]/.test(setupPath)) throw new Error("setupPath required (no quotes, percent signs or newlines)"); @@ -46,8 +48,12 @@ function buildUpdateHelperCmd({ pid, setupPath, installDir, args = ["/S", "--for return [ "@echo off", "setlocal", - `set "SETUP=${setupPath}"`, - `set "ASAR=${installDir}\\resources\\app.asar"`, + // The paths arrive through the environment (updateHelperEnv), not as text + // in this file: cmd.exe reads a batch file in the OEM code page, so a + // UTF-8 "C:\Users\Иван\…" written here would point nowhere. The + // environment block is UTF-16 and survives intact. + `set "SETUP=%${ENV_SETUP}%"`, + `set "ASAR=%${ENV_DIR}%\\resources\\app.asar"`, `"${PS}" -NoProfile -NonInteractive -Command "Wait-Process -Id ${pid} -Timeout ${maxIter} -ErrorAction SilentlyContinue"`, `"${S32}\\ping.exe" -n ${grace} 127.0.0.1 >nul`, `"%SETUP%" ${argStr}`, @@ -64,4 +70,9 @@ function buildUpdateHelperCmd({ pid, setupPath, installDir, args = ["/S", "--for ].join("\r\n"); } -module.exports = { buildUpdateHelperCmd }; +// Environment for the cmd.exe that runs the script above. +function updateHelperEnv({ setupPath, installDir }) { + return { [ENV_SETUP]: setupPath, [ENV_DIR]: installDir }; +} + +module.exports = { buildUpdateHelperCmd, updateHelperEnv }; diff --git a/main.js b/main.js index 63edaebc..84416057 100644 --- a/main.js +++ b/main.js @@ -742,20 +742,50 @@ async function refreshRemoteHomeCards() { console.log(`[home-cards] refreshed from ${HOME_CARDS_URL}: ${clean.length} cards`); } catch (e) { /* silent */ } } -// Sender validation — only accept IPC from our own home.html file:// URL. -// Rejects third-party pages that see the API shape via the preload. -function isHomePageSender(sender) { - try { - const u = sender.getURL() || ""; - return u.startsWith("file://") && /home\.html(?:$|\?|#)/i.test(u); - } catch { return false; } +// Sender validation — only accept IPC from our own app pages. Compared against +// the exact file:// URL of the shipped page, so a downloaded +// file:///…/Downloads/home.html (or …/x.html#home.html) doesn't pass. +const appPageUrl = (name) => url.pathToFileURL(path.join(__dirname, name)).href.toLowerCase(); +function isAppPage(sender, name) { + try { return String(sender.getURL() || "").split(/[?#]/)[0].toLowerCase() === appPageUrl(name); } + catch { return false; } } +// Rejects third-party pages that see the API shape via the preload. +function isHomePageSender(sender) { return isAppPage(sender, "home.html"); } // Same origin-gating pattern for the branded error page. -function isErrorPageSender(sender) { - try { - const u = sender.getURL() || ""; - return u.startsWith("file://") && /error\.html(?:$|\?|#)/i.test(u); - } catch { return false; } +function isErrorPageSender(sender) { return isAppPage(sender, "error.html"); } +// settings-preload is the only bridge to these channels, but a preload belongs +// to the WebContents, not the page — so the caller is checked as well: it must +// be a Settings tab currently showing our settings.html. SETTINGS_SHARED are +// also called by the toolbar (preload.js). +const SETTINGS_ONLY = new Set([ + "addon-invoke", "addons-check-updates", "addons-community-catalog", "addons-install-community", + "addons-list", "addons-open-dir", "addons-reload", "addons-remove", "addons-reveal", "addons-set-enabled", + "ariadne-get-policy", "ariadne-get-status", "ariadne-install", "ariadne-set-policy", "ariadne-set-source", + "ariadne-state", "ariadne-toggle", "ariadne-uninstall", "ariadne-update", + "clear-browsing-data", "collision-reset", "collision-set-policy", + "password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove", + "password-setup", "password-status", "password-unlock", "password-update", + "recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order", + "settings-open-panel", "settings-section", "tor-state", +]); +const SETTINGS_SHARED = new Set([ + "add-engine", "addons-apply-staged", "addons-list-staged", "app-restart", "collision-state", + "remove-engine", "settings-get", "settings-set", "toggle-tor", +]); +function isSettingsPage(sender) { + return tabs.some((t) => t.settings && t.view?.webContents === sender) && isAppPage(sender, "settings.html"); +} +{ + const handle = ipcMain.handle.bind(ipcMain); + ipcMain.handle = (channel, fn) => handle(channel, + SETTINGS_ONLY.has(channel) || SETTINGS_SHARED.has(channel) + ? (e, ...args) => { + const ok = isSettingsPage(e.sender) || (SETTINGS_SHARED.has(channel) && chrome && e.sender === chrome.webContents); + if (!ok) throw new Error(`${channel}: settings only`); + return fn(e, ...args); + } + : fn); } // ---- address-bar history (userData/history.json) -------------------------- @@ -1161,7 +1191,9 @@ const sessionFile = () => path.join(app.getPath("userData"), "session.json"); // without any tab having to load first — background tabs stay DORMANT (no // loadURL, no renderer activity) and come to life only when activated. let sessionSavedAtClose = false; +let sessionDroppedForQuit = false; // clear-history-on-quit already deleted it; the window's close must not rewrite it function saveSession() { + if (sessionDroppedForQuit) return; if (sessionSavedAtClose && !winAlive()) return; // already captured when the window closed try { const live = tabs.filter((t) => !t.settings && (t.url || t.pending?.url)); @@ -1290,6 +1322,23 @@ function applyThrottle() { // media-device labels/ids from enumerateDevices. Handlers read settings live. // Device permissions with no legitimate need here — always denied. const SENSITIVE_DEVICE = new Set(["hid", "serial", "usb", "bluetooth", "midi", "midiSysex"]); +// Silent clipboard reads (seeds, WIFs, copied vault passwords live there), idle +// detection and multi-screen layout have no place being auto-granted either. +const DENIED_PERMISSIONS = new Set(["clipboard-read", "idle-detection", "window-management"]); +// A page navigating to an unknown scheme (search-ms:, ms-msdt:, …) asks for +// "openExternal"; hand it to the OS only after the user says so. +async function confirmOpenExternal(wc, externalURL) { + let scheme = ""; + try { scheme = new URL(externalURL).protocol; } catch { return false; } + if (scheme === "mailto:" || scheme === "tel:") return true; + const parent = BrowserWindow.fromWebContents(wc) || win; + const r = await dialog.showMessageBox(parent, { + type: "question", buttons: ["Open", "Cancel"], defaultId: 1, cancelId: 1, noLink: true, + message: "Open an external application?", + detail: `This page wants to open:\n${String(externalURL).slice(0, 300)}`, + }).catch(() => ({ response: 1 })); + return r.response === 0; +} // A "media" request may ask for audio, video, or both — allow only if none blocked. function mediaAllowed(kinds) { if (kinds.includes("video") && settings.blockCamera) return false; @@ -1301,7 +1350,8 @@ function applyPermissions() { ses.setPermissionRequestHandler((_wc, permission, callback, details) => { if (permission === "media") return callback(mediaAllowed(details?.mediaTypes || [])); if (permission === "geolocation") return callback(effLocation() !== "deny"); // allow unless "hide" - if (SENSITIVE_DEVICE.has(permission)) return callback(false); + if (SENSITIVE_DEVICE.has(permission) || DENIED_PERMISSIONS.has(permission)) return callback(false); + if (permission === "openExternal") { confirmOpenExternal(_wc, details?.externalURL).then(callback, () => callback(false)); return; } callback(true); // benign UX permissions (fullscreen, pointerLock, …) }); ses.setPermissionCheckHandler((_wc, permission, _origin, details) => { @@ -1311,7 +1361,7 @@ function applyPermissions() { return !(settings.blockCamera && settings.blockMicrophone); } if (permission === "geolocation") return effLocation() !== "deny"; - if (SENSITIVE_DEVICE.has(permission)) return false; + if (SENSITIVE_DEVICE.has(permission) || DENIED_PERMISSIONS.has(permission)) return false; return true; }); } @@ -1365,6 +1415,9 @@ async function startTor() { torProc.stdout.on("data", (d) => { if (/Bootstrapped 100%/.test(d.toString())) torReady(); }); torProc.stderr.on("data", () => {}); torProc.on("exit", () => { torProc = null; if (torState !== "off") torOff(); }); + // A missing/quarantined tor.exe emits "error" and never "exit" — without this + // torProc stays set and startTor() is a no-op until restart. + torProc.on("error", (e) => { console.warn("tor spawn failed:", e?.message); torProc = null; torOff(); }); } function torReady() { torState = "on"; @@ -1397,15 +1450,18 @@ function nodeRequest(urlStr, { method = "GET", headers = {}, agent } = {}) { const req = lib.request(u, { method, headers, agent }, (res) => { const chunks = []; res.on("data", (c) => chunks.push(c)); - res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) })); + res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null, buffer: Buffer.concat(chunks) })); + res.on("error", reject); }); + // An upstream that accepts and never answers would leave the tab spinning. + req.setTimeout(60000, () => req.destroy(new Error("upstream timeout"))); req.on("error", reject); req.end(); }); } async function contentFetch(url, init = {}) { if (torState === "on") { await loadSocks(); return nodeRequest(url, { ...init, agent: new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`) }); } const r = await fetch(url, init); - return { status: r.status, contentType: r.headers.get("content-type"), buffer: Buffer.from(await r.arrayBuffer()) }; + return { status: r.status, contentType: r.headers.get("content-type"), location: r.headers.get("location"), buffer: Buffer.from(await r.arrayBuffer()) }; } // BNS `ip`-record fetch. The default `fetch` fails here for two reasons: @@ -1458,8 +1514,10 @@ async function httpGetByIp(ip, reqPath, hostHeader) { const req = http.request(opts, (res) => { const chunks = []; res.on("data", (c) => chunks.push(c)); - res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) })); + res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null, buffer: Buffer.concat(chunks) })); + res.on("error", reject); }); + req.setTimeout(60000, () => req.destroy(new Error("upstream timeout"))); req.on("error", reject); req.end(); }); } @@ -1635,9 +1693,14 @@ let pollInFlight = null; let pollTimer = null; let pollAttempts = 0, pollLastError = null; +// Neither the electrum connect nor its WebSocket handshake has a timeout of +// its own; a server that accepts TCP and then stalls kept pollInFlight set +// forever, wedging the poll loop until restart. +const POLL_DEADLINE_MS = 45_000; async function pollAndMerge() { if (pollInFlight) return pollInFlight; - pollInFlight = (async () => { + let conn = null, deadline; + const work = (async () => { pollAttempts++; try { const R = await getResolver(); @@ -1656,7 +1719,7 @@ async function pollAndMerge() { || readSnapshotFrom(SNAPSHOT_BUNDLED) || { beacon: R.BEACON_SCRIPTHASH, history: [], txs: {} }; - const el = await R.connectElectrum({ + const el = conn = await R.connectElectrum({ electrum: electrumPool, WebSocket: currentWS(), directIP: true, }); try { @@ -1697,9 +1760,21 @@ async function pollAndMerge() { pollLastError = e && e.message || String(e); // Silent — the browser stays usable via sharedIndex (last-known-good) or // the ensureIndex fallback on the next navigation. - } finally { pollInFlight = null; } + } })(); - return pollInFlight; + const timeout = new Promise((resolve) => { + deadline = setTimeout(() => { + pollLastError = `poll timed out after ${POLL_DEADLINE_MS / 1000}s`; + try { conn?.close(); } catch {} + resolve(null); + }, POLL_DEADLINE_MS); + }); + const p = Promise.race([work, timeout]).finally(() => { + clearTimeout(deadline); + if (pollInFlight === p) pollInFlight = null; + }); + pollInFlight = p; + return p; } function startBnsPolling() { @@ -1737,6 +1812,9 @@ async function ensureIndex(force = false) { indexBuilding = buildIndex({ WebSocket: currentWS(), directIP: true, electrum: electrumPool }) .then((idx) => { sharedIndex = idx; indexBuiltAt = Date.now(); refreshBcnrTlds(idx); return idx; }) .finally(() => { indexBuilding = null; }); + // When a stale index is served below nobody awaits the rebuild; a failed one + // (routine offline) would surface as an unhandled rejection. + indexBuilding.catch(() => {}); // If we have a stale index, don't block on the rebuild — serve stale, refresh async. return (sharedIndex && !force) ? sharedIndex : indexBuilding; } @@ -1756,7 +1834,11 @@ async function resolveHost(host) { if (!entry && Date.now() - indexBuiltAt > 8_000) { const R = await getResolver(); if (R.connectElectrum && R.buildIndexFromSnapshot) { - await pollAndMerge(); + // Every dotted host the web uses lands here on a miss, so the wait is + // bounded: with electrum unreachable or stalling, an ordinary website + // must not sit behind a TCP timeout per server. The poll carries on in + // the background and the next lookup sees its result. + await Promise.race([pollAndMerge(), new Promise((r) => setTimeout(r, 2500))]); entry = sharedIndex?.get(key) ?? null; } else { idx = await ensureIndex(true); @@ -1774,11 +1856,25 @@ const MIME = { html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8" json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", ico: "image/x-icon", webp: "image/webp", woff2: "font/woff2", woff: "font/woff", txt: "text/plain", wasm: "application/wasm" }; const guessType = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application/octet-stream"; +// Response() throws on a body with a null-body status, which turned an +// upstream 204/304 into the 502 page. +const NULL_BODY_STATUS = new Set([101, 204, 205, 304]); +function upstreamResponse(up, contentType) { + const headers = { "content-type": contentType }; + if (up.location && up.status >= 300 && up.status < 400) headers.location = up.location; + return new Response(NULL_BODY_STATUS.has(up.status) ? null : up.buffer, { status: up.status, headers }); +} async function serveBns(request) { const url = new URL(request.url); const host = url.hostname.toLowerCase(); - const reqPath = decodeURIComponent(url.pathname) || "/"; + // Upstream requests carry the path exactly as the URL has it (percent- + // encoded). Decoding first broke file names with spaces/non-Latin-1 on `ip` + // records, turned %23/%3F into #/?, and let `..%2F` climb out of the + // name's own bucket on the gateway (bns://a.bch/..%2Fb.bch%2Fx). The + // decoded form is only used for MIME guessing. + const rawPath = url.pathname || "/"; + let reqPath; try { reqPath = decodeURIComponent(rawPath); } catch { reqPath = rawPath; } // (bns://collision-choose/ is handled by the will-navigate listener attached // to each tab — it fires BEFORE the request reaches this protocol handler.) @@ -1799,8 +1895,8 @@ async function serveBns(request) { // record when available, HTTP fallback when not. Fixes serving BNS names // whose server redirects :80→:443 (the plain-fetch path chokes on the // redirect target because it isn't in ICANN DNS). - const up = await ipRequest(r.ip, reqPath + url.search, host, r.tls); - return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } }); + const up = await ipRequest(r.ip, rawPath + url.search, host, r.tls); + return upstreamResponse(up, up.contentType || guessType(reqPath)); }; // `p` — reverse-proxy the request to a full upstream URL. Address bar stays // on the BNS host; unlike `ip`, uses the upstream's own DNS + public CA and @@ -1811,11 +1907,10 @@ async function serveBns(request) { const serveP = async () => { const base = new URL(r.p); const prefix = base.pathname === "/" ? "" : base.pathname.replace(/\/$/, ""); - const target = base.origin + prefix + reqPath + url.search; - const up = await fetch(target, { redirect: "manual" }); - const body = Buffer.from(await up.arrayBuffer()); - const ct = up.headers.get("content-type") || guessType(reqPath); - return new Response(body, { status: up.status, headers: { "content-type": ct } }); + const target = base.origin + prefix + rawPath + url.search; + // contentFetch so Tor covers this too (a plain fetch leaked the real IP). + const up = await contentFetch(target, { redirect: "manual" }); + return upstreamResponse(up, up.contentType || guessType(reqPath)); }; try { // A subdomain the owner has ruled on: blocked, or sent elsewhere. The @@ -1832,7 +1927,7 @@ async function serveBns(request) { // Secret-free: fetch Sia content from the public gateway (it holds the // keys and owns the subfolder mapping) instead of signing S3 requests // with credentials that must never ship in a public build. - const up = await contentFetch(`${GATEWAY}/bns/${host}${reqPath}${url.search}`, {}); + const up = await contentFetch(`${GATEWAY}/bns/${host}${rawPath}${url.search}`, {}); const ct = up.contentType && up.contentType !== "application/octet-stream" ? up.contentType : guessType(reqPath === "/" ? "index.html" : reqPath); let body = up.buffer; @@ -1841,7 +1936,7 @@ async function serveBns(request) { // resolve against the bns:// origin, not back through the relay. body = Buffer.from(body.toString("utf8").replace(//i, ""), "utf8"); } - return new Response(body, { status: up.status, headers: { "content-type": ct } }); + return upstreamResponse({ ...up, buffer: body }, ct); } if (r.ip) return await serveIp(); if (!isSubdomain && r.p) return await serveP(); @@ -1851,8 +1946,8 @@ async function serveBns(request) { // semantics as an `ip` record (and the on-chain `tls` pin still applies). const dnsIp = await dnsAddressFor(rec.entry); if (dnsIp) { - const up = await ipRequest(dnsIp, reqPath + url.search, host, r.tls); - return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } }); + const up = await ipRequest(dnsIp, rawPath + url.search, host, r.tls); + return upstreamResponse(up, up.contentType || guessType(reqPath)); } return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } }); } catch (e) { @@ -1947,11 +2042,10 @@ function sidebarMaxWidth() { // The add-on host is the single point of truth for what's installed and // active. Populated by initAddons() at app-ready time. let addonHost = null; -// One-shot proxy-login handler installed by setSessionProxy when the -// extension provided credentials. Removed and re-installed on every -// setSessionProxy call so the current credentials always match the -// current proxy. -let proxyLoginHandler = null; +// Proxy credentials supplied by an add-on via setSessionProxy, answered from +// app#login (Session has no "login" event). Replaced on every setSessionProxy +// call so the current credentials always match the current proxy. +let proxyAuth = null; const { AddonHost } = require("./addons-host.js"); const addonUpdater = require("./addon-updater.js"); const { PUBKEYS_HEX: ADDON_UPDATE_PUBKEYS } = require("./addon-update-pubkeys.js"); @@ -2142,7 +2236,7 @@ function initAddons() { setSessionProxy: async (rules, addonId) => { const ses = session.defaultSession; // Always clear any prior proxy-login handler before swapping. - if (proxyLoginHandler) { ses.off("login", proxyLoginHandler); proxyLoginHandler = null; } + proxyAuth = null; if (rules == null || rules === "") { console.log(`[addons] [${addonId}] clearing session proxy`); try { await ses.setProxy({ proxyRules: "" }); } catch (e) { console.warn("proxy clear failed:", e?.message); } @@ -2161,16 +2255,8 @@ function initAddons() { } const publicRules = opts.proxyRules; // never log the password console.log(`[addons] [${addonId}] setting session proxy:`, publicRules, auth ? "(auth pending)" : ""); - if (auth) { - // Chromium fires session#login with `authenticationResponseDetails.isProxy === true` - // when the proxy asks for creds. Answer once per session. - proxyLoginHandler = (event, _details, authInfo, callback) => { - if (!authInfo || !authInfo.isProxy) return; - event.preventDefault(); - callback(auth.username, auth.password); - }; - ses.on("login", proxyLoginHandler); - } + // Chromium fires app#login with authInfo.isProxy when the proxy asks for creds. + if (auth) proxyAuth = auth; try { await ses.setProxy(opts); } catch (e) { console.warn("proxy set failed:", e?.message); } }, // vault-derive capability. Resolves once the vault is unlocked (the @@ -2331,6 +2417,7 @@ function initAddons() { addonsDir: addonsUserDir(), stagedDir, pubkeysHex: ADDON_UPDATE_PUBKEYS, + verifyPublisher: verifyPublisherEntry, logger: (...a) => console.log("[addons]", ...a), }); const staged = listStagedAddons(stagedDir); @@ -2952,11 +3039,21 @@ function pwMatchesForHost(host) { .filter((e) => e.domain === h) .map((e) => ({ id: e.id, domain: e.domain, username: e.username || "" })); } +// The host of what the tab is showing right now. t.prov.host is set by our own +// navigations only, so it goes stale on Back/Forward and server redirects — +// matching credentials against it offered (and filled) bank.com's login on +// whatever page was actually loaded. +function liveHost(t) { + try { + const u = new URL(t.view.webContents.getURL()); + return u.protocol === "http:" || u.protocol === "https:" || u.protocol === "bns:" ? u.hostname.toLowerCase() : ""; + } catch { return ""; } +} // Emit the current tab's match count to chrome so the toolbar chip can // show/hide + display the count. Cheap; called on nav + vault unlock/lock. function emitPwAvailability() { const t = activeTab(); - const host = t?.prov?.host || ""; + const host = t ? liveHost(t) : ""; const count = pwMatchesForHost(host).length; try { chrome?.webContents.send("pw-availability", { host, count }); } catch {} } @@ -2966,6 +3063,8 @@ function emitPwAvailability() { // (user clicks the chip; nothing runs on page load). async function pwFillIntoActiveTab(entry) { const t = activeTab(); if (!t) return false; + // Re-check at fill time: the page may have navigated since the picker opened. + if (!entry.domain || liveHost(t) !== entry.domain) return { ok: false, why: "origin-changed" }; const wc = t.view.webContents; const script = `(() => { const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; }; @@ -3054,6 +3153,11 @@ function installDownloadTracker() { try { item.setSavePath(dst); } catch {} updateDownloadTotal = item.getTotalBytes() || 0; updateDownloadReceived = 0; + // The hash this download must match, taken now: a manifest refresh while + // it runs would otherwise compare it against the next release's hash. + // Only the installer is armable — the portable build can't go through + // the NSIS helper, so it has no expected hash here. + const expectedHash = url === updateAvailable.setupUrl ? String(updateAvailable.setupHash || "").toLowerCase() : ""; item.on("updated", () => { updateDownloadReceived = item.getReceivedBytes(); updateDownloadTotal = item.getTotalBytes() || updateDownloadTotal; @@ -3073,7 +3177,7 @@ function installDownloadTracker() { // and 0.3.31's in-app updater then spawned a half-file as setup — // NSIS integrity check failed silently and the browser was gone. const savedPath = item.getSavePath() || dst; - const expected = String(updateAvailable && updateAvailable.setupHash || "").toLowerCase(); + const expected = expectedHash; if (!expected) { updateDownloadState = "failed"; console.warn(`[update] no manifest hash for ${savedPath} — refusing to arm install`); @@ -3261,9 +3365,9 @@ ipcMain.handle("auth-answer", (e, id, creds) => { settle(creds && typeof creds.username === "string" ? { username: creds.username, password: String(creds.password || "") } : null); }); app.on("login", (event, _wc, details, authInfo, callback) => { - // Proxy auth configured by an add-on is answered by its own handler. - if (authInfo?.isProxy && proxyLoginHandler) return; event.preventDefault(); + // Proxy auth configured by an add-on is answered with its credentials. + if (authInfo?.isProxy && proxyAuth) return callback(proxyAuth.username, proxyAuth.password); const host = authInfo?.host || ""; const port = authInfo?.port; const origin = (authInfo?.isProxy ? "" : (String(details?.url || "").startsWith("http:") ? "http://" : "https://")) @@ -3453,7 +3557,7 @@ function createTab(initial, opts = {}) { }); wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); }); wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); }); - wc.on("did-navigate", () => { if (tab.id === activeId) notifyTabChange(); }); + wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } }); wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); }); // Ctrl+wheel / pinch: Chromium only reports the intent on Windows and // Linux, the zoom itself is up to us. @@ -3491,12 +3595,21 @@ function createTab(initial, opts = {}) { installExtensionWithConsent(installId, requester); return; } + // Same rule as navigateTab: privileged tabs hand any non-file target to a new tab. + if ((tab.settings || tab.addonId) && !/^file:/i.test(u)) { + e.preventDefault(); + navigateTab(id, u); + return; + } const parsed = new URL(u); // Intercept the collision-choose posted by the in-tab "Open with…" page, // apply the remember flag, set a one-shot transient override so loadBns // doesn't re-prompt, and route via navigateTab so chrome/prov stay in sync. if (parsed.protocol === "bns:" && parsed.hostname === "collision-choose") { e.preventDefault(); + // Only our interstitial may post a choice — any page could otherwise + // persist "always ICANN"/"always BCNR" for a name or a whole TLD. + if (!isAppPage(wc, "collision.html")) return; const p = parsed.searchParams; const target = String(p.get("host") || "").toLowerCase(); const cTld = String(p.get("tld") || "").toLowerCase(); @@ -3568,7 +3681,16 @@ function createTab(initial, opts = {}) { if (installId) { let requester = null; try { requester = new URL(wc.getURL()).host || null; } catch {} installExtensionWithConsent(installId, requester); - } else if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab", after: tab.id }); + } else if (url && url !== "about:blank") { + // Chromium won't let a web page navigate to file://, chrome:// or + // theseus://, but createTab → loadURL runs from main and would. Web + // pages get web schemes only; our own file:// pages keep the rest. + let opener = ""; try { opener = new URL(wc.getURL()).protocol; } catch {} + let target = ""; try { target = new URL(url).protocol; } catch {} + if (opener === "file:" || ["http:", "https:", "bns:"].includes(target)) { + createTab(url, { background: disposition === "background-tab", after: tab.id }); + } + } return { action: "deny" }; }); // Right-click context menu. @@ -3814,7 +3936,14 @@ function createWindow() { // hidden via activeQuickLinkId. Separate browsing context from any tab, // so a Facebook sidebar visit doesn't share cookies with a Facebook tab // the user opened — matching how Opera's sidebar panels feel. - quickPanel = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "home-preload.js") } }); + // Third-party sites only — no preload (home-preload's navigate/cards API + // has no business here), and its popups become ordinary tabs instead of + // bare Electron windows outside every tab protection. + quickPanel = new WebContentsView(); + quickPanel.webContents.setWindowOpenHandler(({ url }) => { + if (url && /^(?:https?|bns):/i.test(url)) createTab(url); + return { action: "deny" }; + }); try { quickPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {} win.contentView.addChildView(quickPanel); styleScrollbars(quickPanel.webContents); @@ -3865,6 +3994,10 @@ function createWindow() { win.on("closed", () => { win = null; chrome = null; popover = null; enginePicker = null; downloadsPop = null; dismissJsDialogFor(null); + // Same for wallet approvals: a request left current would block + // pumpApproval (and every later dapp request) until a full restart. + if (approvalCurrent) { const c = approvalCurrent; approvalCurrent = null; try { c.resolve("cancel"); } catch {} } + for (const q of approvalQueue.splice(0)) { try { q.resolve("cancel"); } catch {} } addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; approvalPop = null; jsDialogPop = null; linkStatusVisible = false; }); @@ -3882,9 +4015,24 @@ async function navigateTab(id, input) { // every Settings page. const settingsLink = /^theseus:\/\/settings(?:\/([a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32})?))?\/?$/i.exec(q); if (settingsLink) { openSettingsTab(settingsLink[1] || ""); return; } + // A Settings or add-on tab keeps its privileged preload for the life of its + // WebContents, so it never loads anything else: the target opens in a fresh + // tab in its place. + if (t.settings || t.addonId) { createTab(q, { after: id }); closeTab(id); return; } // Local paths open as files — never BCNR, never a search. const fileUrl = localFileUrl(q); if (fileUrl) return loadLocalFile(t, id, fileUrl); + // data:/blob: (e.g. "Open image in new tab" on an inline image) aren't + // scheme:// URLs, so they used to fall through to the search below — which + // sent the whole image to the search engine. Load them as they are. + if (/^(?:data|blob):/i.test(q)) { + t.url = q; t.prov = { host: "", kind: "web" }; + await t.view.webContents.loadURL(q).catch(() => {}); + if (id === activeId) pushNav(t.prov); + emitTabs(); + return; + } + if (/^javascript:/i.test(q)) return; // Address bar doubles as a search box: anything that isn't a URL/hostname // (a bare word, or a phrase with spaces) becomes a web search. if (!looksLikeUrl(q)) q = SEARCH(q); @@ -4018,7 +4166,14 @@ async function loadBns(t, id, host, rest, tld) { emitTabs(); } -ipcMain.handle("navigate", (_e, input) => navigateTab(activeId, input)); +// The toolbar and our own home page only — home-preload is in every tab, and a +// web page must not steer the active tab (or a Settings tab) from the background. +ipcMain.handle("navigate", (e, input) => { + if (chrome && e.sender === chrome.webContents) return navigateTab(activeId, input); + if (!isHomePageSender(e.sender)) return; + const t = tabs.find((x) => x.view.webContents === e.sender); + return navigateTab(t ? t.id : activeId, input); +}); ipcMain.handle("search", (_e, q) => navigateTab(activeId, SEARCH(q))); ipcMain.handle("new-tab", () => createTab()); ipcMain.handle("close-tab", (_e, id) => closeTab(id)); @@ -4607,8 +4762,9 @@ async function installCommunityById(id) { try { const card = (await fetchCommunityCatalog()).find((e) => e.id === id); if (!card) return { ok: false, error: "not in the catalog" }; + if (fs.existsSync(path.join(bundledAddonsDir(), id, "addon.json"))) return { ok: false, error: "id belongs to a built-in add-on" }; const r = await addonUpdater.installCommunity({ - id, updatesUrl: card.updatesUrl, + id, updatesUrl: card.updatesUrl, publisher: card.publisher, addonsDir: addonsUserDir(), backupsDir: addonsBackupDir(), verifyPublisher: verifyPublisherEntry, log: (...a) => console.log("[addons]", ...a), @@ -4662,7 +4818,7 @@ async function installExtensionWithConsent(id, requester) { : `Install ${card.name || id} ${card.latest}?`, detail: `${from}Published by ${card.publisher}. Theseus verifies the package signature against ${card.publisher}'s current owner in its own chain index before anything is written.\n\n` + `A community extension runs with the same access as any add-on — treat it like a program from that publisher.`, - buttons: ["Install", "Cancel"], defaultId: 0, cancelId: 1, noLink: true, + buttons: ["Install", "Cancel"], defaultId: 1, cancelId: 1, noLink: true, }); if (response !== 0) return { ok: false, error: "cancelled" }; const r = await installCommunityById(id); @@ -5287,13 +5443,17 @@ app.on("will-quit", () => { const setupPath = pendingInstallerPath; pendingInstallerPath = null; try { - const { buildUpdateHelperCmd } = require("./lib/update-helper.cjs"); + const { buildUpdateHelperCmd, updateHelperEnv } = require("./lib/update-helper.cjs"); const cmdPath = path.join(app.getPath("userData"), "update-helper.cmd"); - fs.writeFileSync(cmdPath, buildUpdateHelperCmd({ pid: process.pid, setupPath, installDir: path.dirname(process.execPath) })); + const installDir = path.dirname(process.execPath); + fs.writeFileSync(cmdPath, buildUpdateHelperCmd({ pid: process.pid, setupPath, installDir })); // A detached cmd.exe outlives this process (verified) and is in no job // of ours; the batch file itself waits for our PID to disappear. - const helper = spawn("cmd.exe", [`/d /c "${cmdPath}"`], - { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true }); + // /s + doubled quotes: a plain /c "" loses its quotes when the path + // holds & ( ) and the like. + const helper = spawn("cmd.exe", [`/d /s /c ""${cmdPath}""`], + { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true, + env: { ...process.env, ...updateHelperEnv({ setupPath, installDir }) } }); helper.unref(); console.log(`[update] helper armed for ${setupPath}`); } catch (e) { console.warn("[update] helper spawn failed:", e?.message); } @@ -6172,7 +6332,7 @@ ipcMain.handle("address-pick", (_e, url) => { // active tab. Whole flow is user-initiated; no page-load DOM watchers yet. ipcMain.handle("toggle-pw-fill", async (_e, rect) => { if (pwfVisible) return showPwFill(false); - const t = activeTab(); const host = t?.prov?.host || ""; + const t = activeTab(); const host = t ? liveHost(t) : ""; const matches = pwMatchesForHost(host); if (!matches.length) return showPwFill(false); if (rect) pwfPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; @@ -6188,7 +6348,8 @@ ipcMain.handle("pw-fill-resize", (_e, h) => { pwfH = Math.max(60, Math.min(300, Math.round(h) || 80)); if (pwfVisible) positionPwFill(); }); -ipcMain.handle("pw-fill-pick", async (_e, id) => { +ipcMain.handle("pw-fill-pick", async (e, id) => { + if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" }; showPwFill(false); if (!vaultState) return { ok: false, err: "locked" }; try { @@ -6196,7 +6357,7 @@ ipcMain.handle("pw-fill-pick", async (_e, id) => { const entry = vaultState.entries.find((x) => x.id === id); if (!entry) return { ok: false, err: "no such entry" }; const password = await v.resolvePassword(vaultState, id); - return await pwFillIntoActiveTab({ username: entry.username, password }); + return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password }); } catch (e) { return { ok: false, err: e?.message || String(e) }; } }); // The chrome sends arrow-up/down/enter through so the picker can move its @@ -6679,7 +6840,7 @@ async function openLinkWindow(input) { // Popups from a page here go to the main window's tabs when it exists — // one place for tabs — otherwise to another plain window. wc.setWindowOpenHandler(({ url }) => { - if (url && url !== "about:blank") { + if (url && /^(?:https?|bns):/i.test(url)) { // web schemes only — see the tab handler if (win && !win.isDestroyed()) { createTab(url); try { win.focus(); } catch {} } else openLinkWindow(url); } @@ -7079,7 +7240,14 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) { setInterval(() => refreshRemoteHomeCards().catch(() => {}), HOME_CARDS_REFRESH_MS); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); - app.on("before-quit", async (e) => { + // Electron doesn't wait for an async before-quit listener, so the quit is + // held until the clear finishes (bounded) and then re-issued. + let quitCleared = false, quitClearing = false; + app.on("before-quit", (e) => { + if (quitCleared) return; + e.preventDefault(); + if (quitClearing) return; + quitClearing = true; // Auto-clear per user settings. saveSession() runs first so restoreSession // still works UNLESS the user asked to drop history — in which case we // wipe the session file too so the next launch is genuinely blank. @@ -7087,16 +7255,17 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) { stopTor(); stopBnsPolling(); // silence the background delta refresh before exit vaultState = null; // drop the in-memory vault key + purposeRoot - try { + const clear = (async () => { await clearBrowsingData({ cookies: settings.clearCookiesOnQuit, cache: settings.clearCacheOnQuit, storage: settings.clearStorageOnQuit, }); - if (settings.clearHistoryOnQuit) { - try { fs.unlinkSync(sessionFile()); } catch {} - } - } catch (err) { console.error("before-quit clear failed:", err?.message); } + // Session file AND the address-bar history (history.json). + if (settings.clearHistoryOnQuit) { await clearHistoryNow(); sessionDroppedForQuit = true; } + })().catch((err) => console.error("before-quit clear failed:", err?.message)); + Promise.race([clear, new Promise((r) => setTimeout(r, 5000))]) + .finally(() => { quitCleared = true; app.quit(); }); }); app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); }); } diff --git a/settings.html b/settings.html index fc811df7..b232d88f 100644 --- a/settings.html +++ b/settings.html @@ -1917,7 +1917,7 @@ return; } pwListEl.innerHTML = entries.map((e) => `
` + - `` + + `🔑` + `${esc(e.domain)} · ${esc(e.username || "—")} · ${e.kind === "generated" ? "generated" : "pasted"}` + `` + `` + diff --git a/webapps.js b/webapps.js index b51cbb61..cb349c59 100644 --- a/webapps.js +++ b/webapps.js @@ -102,6 +102,8 @@ function pickIcon(icons, base) { const any = purpose.length === 0 || purpose.includes("any"); const size = parseSizes(ic.sizes); let href; try { href = new URL(ic.src, base).href; } catch { continue; } + // Fetched from main — never let a manifest point that at file: or other schemes. + if (!/^(?:https?|bns):/i.test(href)) continue; // Rank: "any"-purpose over maskable-only, then the largest size up to // 512 (bigger is only downscaled), then anything larger. const rank = (any ? 1e6 : 0) + (size === Infinity ? 512 : size <= 512 ? size : 512 - (size - 512) / 1e4); @@ -128,6 +130,9 @@ function describe(pageUrl, manifestHref, text) { if (!scope || originOf(scope) !== origin || !startUrl.startsWith(scope)) scope = startUrl.replace(/[?#].*$/, "").replace(/[^/]*$/, ""); let id; try { id = new URL(str(m.id) || startUrl, startUrl).href; } catch { id = startUrl; } id = norm(id); + // Per spec an id on another origin is ignored — otherwise evil.com could + // claim bank.com's app slot (same key) before bank.com is ever installed. + if (originOf(id) !== origin) id = startUrl; const icon = pickIcon(m.icons, manifestHref); return { key: keyFor(id), id, name: name.slice(0, 80), shortName: (str(m.short_name) || name).slice(0, 40), @@ -174,6 +179,7 @@ function pngToIco(png) { return Buffer.concat([hdr, ent, png]); } async function fetchBytes(u) { + if (!/^(?:https?|bns|data):/i.test(String(u))) throw new Error("unsupported icon URL"); const r = await session.defaultSession.fetch(u, { cache: "force-cache" }); if (!r.ok) throw new Error("HTTP " + r.status); return Buffer.from(await r.arrayBuffer());