diff --git a/addon-inject-preload.js b/addon-inject-preload.js index 1cf54997..c049eb22 100644 --- a/addon-inject-preload.js +++ b/addon-inject-preload.js @@ -15,6 +15,12 @@ // ordinary preload idiom keep working. const { contextBridge, ipcRenderer, webFrame } = require("electron"); +// Web tabs run preloads in iframes too (nodeIntegrationInSubFrames, for the +// password hooks). Add-on page scripts (wallet providers, consent rules) +// stay top-frame only, as they always were: main decides them from the +// tab's URL, not the frame's, and must never put them into third-party frames. +if (window.top !== window) return; + let injections = []; try { injections = ipcRenderer.sendSync("addon-inject-scripts", location.href) || []; } catch (e) { console.warn("[theseus] add-on inject query failed:", e?.message || e); } diff --git a/bcnr-preload.js b/bcnr-preload.js index 4b433356..fec304c7 100644 --- a/bcnr-preload.js +++ b/bcnr-preload.js @@ -11,6 +11,11 @@ // API surface. const { contextBridge, ipcRenderer } = require("electron"); +// Web tabs run preloads in iframes too (nodeIntegrationInSubFrames, for the +// password hooks in home-preload.js). This one stays top-frame only, as +// it always was. +if (window.top !== window) return; + // Every method returns a Promise; a name that fails to resolve or isn't // registered comes back as `null` (not an error) so page code can treat // "no such name" as data, not an exception. `getBcnrTlds` always returns diff --git a/home-preload.js b/home-preload.js index 5e94f633..133ec531 100644 --- a/home-preload.js +++ b/home-preload.js @@ -5,6 +5,10 @@ // origin-mismatched call, so a third-party page can inspect the API's // SHAPE but can't invoke it against local user data. const { contextBridge, ipcRenderer } = require("electron"); +// Web tabs run this preload in iframes too (nodeIntegrationInSubFrames), so +// the password hooks below see logins inside frames. Everything else here +// stays top-frame only, as it always was. +if (window.top === window) { // A click in the page closes the left panel (web app / add-on) unless it is // pinned. Main ignores this unless a panel is open and the tab is active. window.addEventListener("pointerdown", () => { ipcRenderer.send("tab-pointerdown"); }, true); @@ -31,11 +35,13 @@ contextBridge.exposeInMainWorld("errorpage", { registerOnSirius: (host) => ipcRenderer.invoke("error-register", host), openExternal: (url) => ipcRenderer.invoke("error-open-external", url), }); +} // ---- Password manager hooks ------------------------------------------------- -// Runs in this preload's isolated world on every web page in a tab; nothing -// is exposed to the page. Two reports go to main, which takes the site from -// the tab's committed URL, never from here: +// Runs in this preload's isolated world in every frame of a web tab (a login +// form inside an iframe counts too); nothing is exposed to the page. Two +// reports go to main, which takes the site from the committed URL of the +// frame that sent them, never from here: // pw-form a login field got focus -> main may offer saved logins under it // pw-capture a form carrying a password was sent -> main may offer to save it (() => { diff --git a/main.js b/main.js index 8d374888..ac5ef01a 100644 --- a/main.js +++ b/main.js @@ -4,7 +4,7 @@ // h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home // page, and optional Tor onion routing. No system daemon; the app is the trust // boundary. -const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess, safeStorage } = require("electron"); +const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess, safeStorage, webFrameMain } = require("electron"); const path = require("path"); const url = require("url"); const http = require("http"); @@ -3634,7 +3634,8 @@ function showPwFill(show, matches, extra = {}) { win.contentView.removeChildView(pwFillPop); win.contentView.addChildView(pwFillPop); pwFillPop.setVisible(true); pwfVisible = true; - pwFillPop.webContents.send("pw-matches", { matches: matches || [], locked: !!extra.locked, host: extra.host || "" }); + pwFillPop.webContents.send("pw-matches", { matches: matches || [], locked: !!extra.locked, host: extra.host || "", + framed: !!extra.framed, topHost: extra.topHost || "" }); } else { cancelOverlayShow(pwFillPop); pwFillPop.setVisible(false); pwfVisible = false; } } // Compute credential matches for a host. Exact hostname match in phase-1; @@ -3683,11 +3684,16 @@ function emitPwAvailability() { // password field + tries to fill the adjacent/associated username field. // Kept intentionally small โ the whole autofill affordance is opt-in // (user clicks the chip; nothing runs on page load). -async function pwFillIntoActiveTab(entry) { +// frameRef: the iframe the login was offered in (null = the tab's own page). +async function pwFillIntoActiveTab(entry, frameRef = null) { 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; + // Re-check at fill time: the page (or frame) may have navigated since the + // picker opened. A frame must still be that tab's, still on that site. + let target = t.view.webContents; + if (frameRef) { + target = pwFrameFromRef(frameRef, t, entry.domain); + if (!target) return { ok: false, why: "origin-changed" }; + } else if (!entry.domain || liveHost(t) !== entry.domain) return { ok: false, why: "origin-changed" }; const script = `(() => { const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; }; const pwds = [...document.querySelectorAll('input[type=password]:not([disabled])')].filter(visible); @@ -3712,8 +3718,7 @@ async function pwFillIntoActiveTab(entry) { return { ok: true, filledUsername: !!user }; })()`; try { - const res = await wc.executeJavaScript(script, true); - return res; + return await target.executeJavaScript(script, true); } catch (e) { console.error("pw fill failed:", e?.message); return { ok: false, why: "exec-error" }; } } function showAddressPicker(show, suggestions) { @@ -4201,7 +4206,12 @@ function createTab(initial, opts = {}) { const preloadPath = opts.settings ? path.join(__dirname, "settings-preload.js") : opts.addonFile ? path.join(__dirname, "addon-tab-preload.js") : path.join(__dirname, "home-preload.js"); - const view = new WebContentsView({ webPreferences: { preload: preloadPath } }); + // Web tabs also run preloads inside iframes, so the password manager sees + // logins in embedded sign-in forms. Pages still get no Node access + // (nodeIntegration stays off); every other preload returns early outside + // the top frame, as before. + const webTab = !opts.settings && !opts.addonFile; + const view = new WebContentsView({ webPreferences: { preload: preloadPath, ...(webTab ? { nodeIntegrationInSubFrames: true } : {}) } }); // Explicit solid background: transparent (Electron default) makes the tab // view flash to whatever's underneath (which can be the just-hidden tab or // black) between setVisible(true) and the first paint on tab switch. A @@ -7905,6 +7915,7 @@ ipcMain.handle("toggle-pw-fill", async (_e, rect) => { const matches = pwMatchesForHost(host); if (!matches.length) return showPwFill(false); if (rect) pwfPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; + pwfOffer = null; // the chip fills the tab's own page showPwFill(true, matches); }); ipcMain.handle("close-pw-fill", () => showPwFill(false)); @@ -7921,7 +7932,10 @@ ipcMain.handle("pw-fill-pick", async (e, id) => { if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" }; showPwFill(false); const t = activeTab(); - const host = t ? liveHost(t) : ""; + // The offer came from a login field (possibly in an iframe), or from the + // toolbar chip (the tab's own page). + const offer = pwfOffer && t && pwfOffer.tabId === t.id ? pwfOffer : null; + const host = offer ? offer.host : t ? liveHost(t) : ""; let justUnlocked = false; // a PIN / password typed for this very fill also answers the per-login check if (id === "__unlock") { // Offered on a locked vault: unlock, then fill at once when there is one @@ -7931,7 +7945,7 @@ ipcMain.handle("pw-fill-pick", async (e, id) => { justUnlocked = !u.already; const matches = pwMatchesForHost(host); if (matches.length === 1) id = matches[0].id; - else { if (matches.length) showPwFill(true, matches, { host }); return { ok: true, shown: matches.length }; } + else { if (matches.length) showPwFill(true, matches, { host, framed: !!(offer && offer.ref), topHost: offer && offer.topHost }); return { ok: true, shown: matches.length }; } } if (!vaultState) return { ok: false, err: "locked" }; try { @@ -7944,31 +7958,51 @@ ipcMain.handle("pw-fill-pick", async (e, id) => { if (!c.ok) return { ok: false, err: "cancelled" }; } const password = await v.resolvePassword(vaultState, id); - return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password }); + return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password }, offer && offer.ref); } catch (e) { return { ok: false, err: e?.message || String(e) }; } }); // ---- Password manager: offer saved logins, offer to save new ones ---------- -// home-preload.js runs in the top frame of every web tab, in its isolated +// home-preload.js runs in every frame of every web tab (iframes included: +// sign-in widgets and embedded checkouts often live in one), in its isolated // world, and reports two things: a login field got focus ("pw-form"), and a // form carrying a password was sent ("pw-capture"). It exposes nothing to -// the page. The host is always taken from the tab's committed URL, never -// from the message. -function webTabForEvent(e) { +// the page. The site is always taken from the committed URL of the frame +// that sent the report, never from the message: a login inside an iframe +// from login.example belongs to login.example, whatever page embeds it. +function frameHost(frame) { + try { + const u = new URL(frame.url); + return /^(https?|bns):$/.test(u.protocol) ? u.hostname.toLowerCase() : ""; + } catch { return ""; } +} +function pwSource(e) { const t = tabForSender(e.sender); if (!t || t.settings || t.addonId) return null; - if (e.senderFrame !== e.sender.mainFrame) return null; - return t; + const frame = e.senderFrame; + if (!frame || frame.detached) return null; + const host = frameHost(frame); + if (!host) return null; + const isMain = frame === e.sender.mainFrame; + return { t, frame, host, isMain, topHost: liveHost(t), ref: isMain ? null : { processId: frame.processId, routingId: frame.routingId } }; } -let pwfOfferTab = null; +// An iframe the user was offered a login in, if it still exists, still +// belongs to that tab, and still shows that site. +function pwFrameFromRef(ref, t, host) { + let f = null; + try { f = webFrameMain.fromId(ref.processId, ref.routingId); } catch { return null; } + if (!f || f.detached || !t || f.top !== t.view.webContents.mainFrame) return null; + return host && frameHost(f) !== host ? null : f; +} +let pwfOffer = null; // { tabId, host, ref (null = the tab's own page), topHost } const pwfNavHooked = new WeakSet(); ipcMain.on("pw-form", (e, rect) => { try { if (!settings.pwOfferFill) return; - const t = webTabForEvent(e); - if (!t || t.id !== activeId) return; - const host = liveHost(t); - if (!host || !fs.existsSync(vaultFile())) return; + const src = pwSource(e); + if (!src || src.t.id !== activeId) return; + const { t, host } = src; + if (!fs.existsSync(vaultFile())) return; let matches = [], locked = false; if (vaultState) { matches = pwMatchesForHost(host); @@ -7980,36 +8014,42 @@ ipcMain.on("pw-form", (e, rect) => { locked = true; } const b = t.view.getBounds(); - const z = t.view.webContents.getZoomFactor() || 1; - const r = rect && typeof rect === "object" ? rect : {}; - const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); - const x = Math.round(b.x + num(r.x) * z); - const y = Math.round(b.y + (num(r.y) + num(r.h)) * z + 4); - pwfPos = { x, y: Math.max(b.y, Math.min(y, b.y + b.height - 90)) }; - pwfOfferTab = t.id; + if (src.isMain) { + const z = t.view.webContents.getZoomFactor() || 1; + const r = rect && typeof rect === "object" ? rect : {}; + const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); + const x = Math.round(b.x + num(r.x) * z); + const y = Math.round(b.y + (num(r.y) + num(r.h)) * z + 4); + pwfPos = { x, y: Math.max(b.y, Math.min(y, b.y + b.height - 90)) }; + } else { + // A field inside an iframe: its position is only known inside that + // frame, so the offer sits at the top of the page, naming the site. + pwfPos = { x: Math.round(b.x + (b.width - PWF_W) / 2), y: b.y + 10 }; + } + pwfOffer = { tabId: t.id, host, ref: src.ref, topHost: src.topHost }; const wc = t.view.webContents; if (!pwfNavHooked.has(wc)) { pwfNavHooked.add(wc); wc.on("did-start-navigation", (_ev, _url, inPage, isMain) => { - if (isMain && !inPage && pwfVisible && pwfOfferTab === t.id) showPwFill(false); + if (isMain && !inPage && pwfVisible && pwfOffer && pwfOffer.tabId === t.id) showPwFill(false); }); } - showPwFill(true, matches, { locked, host }); + showPwFill(true, matches, { locked, host, framed: !src.isMain, topHost: src.topHost }); } catch (err) { console.warn("pw-form:", err?.message); } }); ipcMain.on("pw-form-dismiss", (e) => { - const t = webTabForEvent(e); - if (t && pwfVisible && pwfOfferTab === t.id) showPwFill(false); + const src = pwSource(e); + if (src && pwfVisible && pwfOffer && pwfOffer.tabId === src.t.id) showPwFill(false); }); const pwCaptureSeen = new Map(); // tabId -> { key, at }: one offer per login, however many events report it ipcMain.on("pw-capture", (e, data) => { try { if (!settings.pwOfferSave) return; - const t = webTabForEvent(e); - if (!t) return; - const host = liveHost(t); - if (!host || (settings.pwNeverSave || []).includes(host)) return; + const src = pwSource(e); + if (!src) return; + const { t, host } = src; + if ((settings.pwNeverSave || []).includes(host)) return; const username = String((data && data.username) || "").trim().slice(0, 256); const password = String((data && data.password) || ""); if (!password || password.length > 1024) return; @@ -8019,39 +8059,48 @@ ipcMain.on("pw-capture", (e, data) => { pwCaptureSeen.set(t.id, { key, at: Date.now() }); // A beat later, so a login that navigates away shows the offer on the // page it lands on rather than flashing over the form. - setTimeout(() => offerSavePassword(t.id, host, username, password).catch((err) => console.warn("pw save offer:", err?.message)), 900); + setTimeout(() => offerSavePassword(t.id, host, username, password, { ref: src.ref, topHost: src.topHost }) + .catch((err) => console.warn("pw save offer:", err?.message)), 900); } catch (err) { console.warn("pw-capture:", err?.message); } }); -// True once the login looks done: the page moved to another site, or no -// password field is left on it. A password field still showing after a few -// seconds (filled, or emptied by a "wrong password" page) means the login -// did not go through, and a wrong password is not worth saving. -async function pwLoginSettled(tabId, host) { +// True once the login looks done: the page (or the iframe the form was in) +// moved to another site or went away, or no password field is left on it. A +// password field still showing after a few seconds (filled, or emptied by a +// "wrong password" page) means the login did not go through, and a wrong +// password is not worth saving. +async function pwLoginSettled(tabId, host, ref) { + const CHECK = "[...document.querySelectorAll('input[type=password]')].some((e) => e.offsetWidth > 0 && e.offsetHeight > 0)"; for (let i = 0; i < 8; i++) { const t = tabs.find((x) => x.id === tabId); if (!t) return false; - if (liveHost(t) !== host) return true; let pwField = false; - try { - pwField = await t.view.webContents.executeJavaScriptInIsolatedWorld(1009, [{ - code: "[...document.querySelectorAll('input[type=password]')].some((e) => e.offsetWidth > 0 && e.offsetHeight > 0)", - }]); - } catch { return true; } // navigating: the page is going away + if (ref) { + const f = pwFrameFromRef(ref, t, host); + if (!f) return true; // the frame navigated away or was removed + try { pwField = await f.executeJavaScript(CHECK); } catch { return true; } + } else { + if (liveHost(t) !== host) return true; + try { + pwField = await t.view.webContents.executeJavaScriptInIsolatedWorld(1009, [{ code: CHECK }]); + } catch { return true; } // navigating: the page is going away + } if (!pwField) return true; await new Promise((r) => setTimeout(r, 500)); } return false; } -async function offerSavePassword(tabId, host, username, password) { +async function offerSavePassword(tabId, host, username, password, { ref = null, topHost = "" } = {}) { if (!tabs.some((x) => x.id === tabId)) return; - if (!(await pwLoginSettled(tabId, host))) return; + if (!(await pwLoginSettled(tabId, host, ref))) return; + // A login in an iframe is saved for the iframe's site; say so. + const where = ref && topHost && topHost !== host ? `${host} (in a frame on ${topHost})` : host; if (!fs.existsSync(vaultFile())) { const pick = await showApprovalModal({ from: "Theseus Vault", title: "Save your passwords in Theseus?", body: "Theseus can remember this login and fill it next time. Set up the Theseus Vault first: it is encrypted with a master password only you know, and nothing leaves this computer.", - origin: host, + origin: where, actions: [{ id: "setup", label: "Set up the vault", primary: true }, { id: "never", label: "Never for this site" }, { id: "cancel", label: "Not now" }], }, null, tabId); if (pick === "setup") openSettingsTab("passwords"); @@ -8071,7 +8120,7 @@ async function offerSavePassword(tabId, host, username, password) { const pick = await showApprovalModal({ from: "Theseus Vault", title: update ? "Update the saved password?" : "Save this password?", - origin: host, + origin: where, rows: [ { label: "Username", value: username || "(none)" }, { label: "Password", value: "โข".repeat(Math.min(12, password.length)) }, diff --git a/pw-fill.html b/pw-fill.html index 9f5e1313..de67103b 100644 --- a/pw-fill.html +++ b/pw-fill.html @@ -30,7 +30,14 @@ window.pwfill.onMatches((data) => { const list = $("list"); const matches = data.matches || []; - document.querySelector(".hdr").textContent = data.locked ? "Theseus Vault" : "Fill password for this site"; + // A login form inside an iframe belongs to the iframe's site: name it, + // and the page it sits in, so the user knows where the password goes. + const framed = data.framed && data.topHost && data.topHost !== data.host; + const hdr = document.querySelector(".hdr"); + hdr.textContent = framed + ? `Login for ${data.host} ยท in a frame on ${data.topHost}` + : data.locked ? "Theseus Vault" : "Fill password for this site"; + hdr.style.textTransform = framed ? "none" : ""; // host names stay as written if (data.locked) { // The vault is locked, but it has a login for this site. list.innerHTML = `