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
This commit is contained in:
parent
166e220343
commit
08beadcf8f
7 changed files with 346 additions and 98 deletions
19
GOTCHAS.md
19
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`
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@
|
|||
// Batch specifics: `timeout` refuses to run without a console, so sleeps are
|
||||
// `ping -n <n+1> 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 };
|
||||
|
|
|
|||
317
main.js
317
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(/<base\s+href="\/bns\/[^"]*">/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 "<path>" 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(); });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1917,7 +1917,7 @@
|
|||
return;
|
||||
}
|
||||
pwListEl.innerHTML = entries.map((e) => `<div class="eng" data-id="${esc(e.id)}">` +
|
||||
`<span class="eic"><img class="ei" src="https://icons.duckduckgo.com/ip3/${esc(e.domain)}.ico" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'🔑'}))"></span>` +
|
||||
`<span class="eic"><span class="es">🔑</span></span>` +
|
||||
`<span class="enm"><b>${esc(e.domain)}</b> <span class="pmuted">· ${esc(e.username || "—")}</span> <span class="pmuted" style="font-size:11px">· ${e.kind === "generated" ? "generated" : "pasted"}</span></span>` +
|
||||
`<button class="cx pwShow" title="Show + copy">👁</button>` +
|
||||
`<button class="cx pwDel" title="Remove">✕</button>` +
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue