Passwords: save and fill logins inside iframes too
Sign-in widgets and embedded checkouts often put the login form in an iframe, and the password hooks only ran in a tab's top frame, so those logins were never offered for saving or filling. - Web tabs now run preloads in iframes (nodeIntegrationInSubFrames; pages still get no Node). Only the password hooks act there: the home-page bridge, window.bcnr, add-on page scripts and window.theseusId return early outside the top frame, exactly as before. - A login in a frame belongs to the frame's own site, taken from that frame's committed URL. The save prompt says "login.example (in a frame on shop.example)", the fill offer names both, and the fill goes into that exact frame only while it is still that tab's and still on that site. - The "did the login go through" check runs against the frame. Verified with a shop page embedding a cross-site login frame: save offer, fill offer and fill all target the frame; the outer page gets nothing; top-frame logins behave as before.
This commit is contained in:
parent
b4362f2e54
commit
ed441b4e9d
6 changed files with 134 additions and 59 deletions
|
|
@ -15,6 +15,12 @@
|
||||||
// ordinary preload idiom keep working.
|
// ordinary preload idiom keep working.
|
||||||
const { contextBridge, ipcRenderer, webFrame } = require("electron");
|
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 = [];
|
let injections = [];
|
||||||
try { injections = ipcRenderer.sendSync("addon-inject-scripts", location.href) || []; }
|
try { injections = ipcRenderer.sendSync("addon-inject-scripts", location.href) || []; }
|
||||||
catch (e) { console.warn("[theseus] add-on inject query failed:", e?.message || e); }
|
catch (e) { console.warn("[theseus] add-on inject query failed:", e?.message || e); }
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@
|
||||||
// API surface.
|
// API surface.
|
||||||
const { contextBridge, ipcRenderer } = require("electron");
|
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
|
// 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
|
// registered comes back as `null` (not an error) so page code can treat
|
||||||
// "no such name" as data, not an exception. `getBcnrTlds` always returns
|
// "no such name" as data, not an exception. `getBcnrTlds` always returns
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
// origin-mismatched call, so a third-party page can inspect the API's
|
// origin-mismatched call, so a third-party page can inspect the API's
|
||||||
// SHAPE but can't invoke it against local user data.
|
// SHAPE but can't invoke it against local user data.
|
||||||
const { contextBridge, ipcRenderer } = require("electron");
|
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
|
// 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.
|
// pinned. Main ignores this unless a panel is open and the tab is active.
|
||||||
window.addEventListener("pointerdown", () => { ipcRenderer.send("tab-pointerdown"); }, true);
|
window.addEventListener("pointerdown", () => { ipcRenderer.send("tab-pointerdown"); }, true);
|
||||||
|
|
@ -31,11 +35,13 @@ contextBridge.exposeInMainWorld("errorpage", {
|
||||||
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
|
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
|
||||||
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
|
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Password manager hooks -------------------------------------------------
|
// ---- Password manager hooks -------------------------------------------------
|
||||||
// Runs in this preload's isolated world on every web page in a tab; nothing
|
// Runs in this preload's isolated world in every frame of a web tab (a login
|
||||||
// is exposed to the page. Two reports go to main, which takes the site from
|
// form inside an iframe counts too); nothing is exposed to the page. Two
|
||||||
// the tab's committed URL, never from here:
|
// 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-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
|
// pw-capture a form carrying a password was sent -> main may offer to save it
|
||||||
(() => {
|
(() => {
|
||||||
|
|
|
||||||
157
main.js
157
main.js
|
|
@ -4,7 +4,7 @@
|
||||||
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
|
// 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
|
// page, and optional Tor onion routing. No system daemon; the app is the trust
|
||||||
// boundary.
|
// 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 path = require("path");
|
||||||
const url = require("url");
|
const url = require("url");
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
|
|
@ -3634,7 +3634,8 @@ function showPwFill(show, matches, extra = {}) {
|
||||||
win.contentView.removeChildView(pwFillPop);
|
win.contentView.removeChildView(pwFillPop);
|
||||||
win.contentView.addChildView(pwFillPop);
|
win.contentView.addChildView(pwFillPop);
|
||||||
pwFillPop.setVisible(true); pwfVisible = true;
|
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; }
|
} else { cancelOverlayShow(pwFillPop); pwFillPop.setVisible(false); pwfVisible = false; }
|
||||||
}
|
}
|
||||||
// Compute credential matches for a host. Exact hostname match in phase-1;
|
// 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.
|
// password field + tries to fill the adjacent/associated username field.
|
||||||
// Kept intentionally small — the whole autofill affordance is opt-in
|
// Kept intentionally small — the whole autofill affordance is opt-in
|
||||||
// (user clicks the chip; nothing runs on page load).
|
// (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;
|
const t = activeTab(); if (!t) return false;
|
||||||
// Re-check at fill time: the page may have navigated since the picker opened.
|
// Re-check at fill time: the page (or frame) may have navigated since the
|
||||||
if (!entry.domain || liveHost(t) !== entry.domain) return { ok: false, why: "origin-changed" };
|
// picker opened. A frame must still be that tab's, still on that site.
|
||||||
const wc = t.view.webContents;
|
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 script = `(() => {
|
||||||
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; };
|
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);
|
const pwds = [...document.querySelectorAll('input[type=password]:not([disabled])')].filter(visible);
|
||||||
|
|
@ -3712,8 +3718,7 @@ async function pwFillIntoActiveTab(entry) {
|
||||||
return { ok: true, filledUsername: !!user };
|
return { ok: true, filledUsername: !!user };
|
||||||
})()`;
|
})()`;
|
||||||
try {
|
try {
|
||||||
const res = await wc.executeJavaScript(script, true);
|
return await target.executeJavaScript(script, true);
|
||||||
return res;
|
|
||||||
} catch (e) { console.error("pw fill failed:", e?.message); return { ok: false, why: "exec-error" }; }
|
} catch (e) { console.error("pw fill failed:", e?.message); return { ok: false, why: "exec-error" }; }
|
||||||
}
|
}
|
||||||
function showAddressPicker(show, suggestions) {
|
function showAddressPicker(show, suggestions) {
|
||||||
|
|
@ -4201,7 +4206,12 @@ function createTab(initial, opts = {}) {
|
||||||
const preloadPath = opts.settings ? path.join(__dirname, "settings-preload.js")
|
const preloadPath = opts.settings ? path.join(__dirname, "settings-preload.js")
|
||||||
: opts.addonFile ? path.join(__dirname, "addon-tab-preload.js")
|
: opts.addonFile ? path.join(__dirname, "addon-tab-preload.js")
|
||||||
: path.join(__dirname, "home-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
|
// Explicit solid background: transparent (Electron default) makes the tab
|
||||||
// view flash to whatever's underneath (which can be the just-hidden tab or
|
// 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
|
// 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);
|
const matches = pwMatchesForHost(host);
|
||||||
if (!matches.length) return showPwFill(false);
|
if (!matches.length) return showPwFill(false);
|
||||||
if (rect) pwfPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
|
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);
|
showPwFill(true, matches);
|
||||||
});
|
});
|
||||||
ipcMain.handle("close-pw-fill", () => showPwFill(false));
|
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" };
|
if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" };
|
||||||
showPwFill(false);
|
showPwFill(false);
|
||||||
const t = activeTab();
|
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
|
let justUnlocked = false; // a PIN / password typed for this very fill also answers the per-login check
|
||||||
if (id === "__unlock") {
|
if (id === "__unlock") {
|
||||||
// Offered on a locked vault: unlock, then fill at once when there is one
|
// 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;
|
justUnlocked = !u.already;
|
||||||
const matches = pwMatchesForHost(host);
|
const matches = pwMatchesForHost(host);
|
||||||
if (matches.length === 1) id = matches[0].id;
|
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" };
|
if (!vaultState) return { ok: false, err: "locked" };
|
||||||
try {
|
try {
|
||||||
|
|
@ -7944,31 +7958,51 @@ ipcMain.handle("pw-fill-pick", async (e, id) => {
|
||||||
if (!c.ok) return { ok: false, err: "cancelled" };
|
if (!c.ok) return { ok: false, err: "cancelled" };
|
||||||
}
|
}
|
||||||
const password = await v.resolvePassword(vaultState, id);
|
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) }; }
|
} catch (e) { return { ok: false, err: e?.message || String(e) }; }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Password manager: offer saved logins, offer to save new ones ----------
|
// ---- 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
|
// 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
|
// 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
|
// the page. The site is always taken from the committed URL of the frame
|
||||||
// from the message.
|
// that sent the report, never from the message: a login inside an iframe
|
||||||
function webTabForEvent(e) {
|
// 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);
|
const t = tabForSender(e.sender);
|
||||||
if (!t || t.settings || t.addonId) return null;
|
if (!t || t.settings || t.addonId) return null;
|
||||||
if (e.senderFrame !== e.sender.mainFrame) return null;
|
const frame = e.senderFrame;
|
||||||
return t;
|
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();
|
const pwfNavHooked = new WeakSet();
|
||||||
ipcMain.on("pw-form", (e, rect) => {
|
ipcMain.on("pw-form", (e, rect) => {
|
||||||
try {
|
try {
|
||||||
if (!settings.pwOfferFill) return;
|
if (!settings.pwOfferFill) return;
|
||||||
const t = webTabForEvent(e);
|
const src = pwSource(e);
|
||||||
if (!t || t.id !== activeId) return;
|
if (!src || src.t.id !== activeId) return;
|
||||||
const host = liveHost(t);
|
const { t, host } = src;
|
||||||
if (!host || !fs.existsSync(vaultFile())) return;
|
if (!fs.existsSync(vaultFile())) return;
|
||||||
let matches = [], locked = false;
|
let matches = [], locked = false;
|
||||||
if (vaultState) {
|
if (vaultState) {
|
||||||
matches = pwMatchesForHost(host);
|
matches = pwMatchesForHost(host);
|
||||||
|
|
@ -7980,36 +8014,42 @@ ipcMain.on("pw-form", (e, rect) => {
|
||||||
locked = true;
|
locked = true;
|
||||||
}
|
}
|
||||||
const b = t.view.getBounds();
|
const b = t.view.getBounds();
|
||||||
const z = t.view.webContents.getZoomFactor() || 1;
|
if (src.isMain) {
|
||||||
const r = rect && typeof rect === "object" ? rect : {};
|
const z = t.view.webContents.getZoomFactor() || 1;
|
||||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
const r = rect && typeof rect === "object" ? rect : {};
|
||||||
const x = Math.round(b.x + num(r.x) * z);
|
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
||||||
const y = Math.round(b.y + (num(r.y) + num(r.h)) * z + 4);
|
const x = Math.round(b.x + num(r.x) * z);
|
||||||
pwfPos = { x, y: Math.max(b.y, Math.min(y, b.y + b.height - 90)) };
|
const y = Math.round(b.y + (num(r.y) + num(r.h)) * z + 4);
|
||||||
pwfOfferTab = t.id;
|
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;
|
const wc = t.view.webContents;
|
||||||
if (!pwfNavHooked.has(wc)) {
|
if (!pwfNavHooked.has(wc)) {
|
||||||
pwfNavHooked.add(wc);
|
pwfNavHooked.add(wc);
|
||||||
wc.on("did-start-navigation", (_ev, _url, inPage, isMain) => {
|
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); }
|
} catch (err) { console.warn("pw-form:", err?.message); }
|
||||||
});
|
});
|
||||||
ipcMain.on("pw-form-dismiss", (e) => {
|
ipcMain.on("pw-form-dismiss", (e) => {
|
||||||
const t = webTabForEvent(e);
|
const src = pwSource(e);
|
||||||
if (t && pwfVisible && pwfOfferTab === t.id) showPwFill(false);
|
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
|
const pwCaptureSeen = new Map(); // tabId -> { key, at }: one offer per login, however many events report it
|
||||||
ipcMain.on("pw-capture", (e, data) => {
|
ipcMain.on("pw-capture", (e, data) => {
|
||||||
try {
|
try {
|
||||||
if (!settings.pwOfferSave) return;
|
if (!settings.pwOfferSave) return;
|
||||||
const t = webTabForEvent(e);
|
const src = pwSource(e);
|
||||||
if (!t) return;
|
if (!src) return;
|
||||||
const host = liveHost(t);
|
const { t, host } = src;
|
||||||
if (!host || (settings.pwNeverSave || []).includes(host)) return;
|
if ((settings.pwNeverSave || []).includes(host)) return;
|
||||||
const username = String((data && data.username) || "").trim().slice(0, 256);
|
const username = String((data && data.username) || "").trim().slice(0, 256);
|
||||||
const password = String((data && data.password) || "");
|
const password = String((data && data.password) || "");
|
||||||
if (!password || password.length > 1024) return;
|
if (!password || password.length > 1024) return;
|
||||||
|
|
@ -8019,39 +8059,48 @@ ipcMain.on("pw-capture", (e, data) => {
|
||||||
pwCaptureSeen.set(t.id, { key, at: Date.now() });
|
pwCaptureSeen.set(t.id, { key, at: Date.now() });
|
||||||
// A beat later, so a login that navigates away shows the offer on the
|
// A beat later, so a login that navigates away shows the offer on the
|
||||||
// page it lands on rather than flashing over the form.
|
// 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); }
|
} catch (err) { console.warn("pw-capture:", err?.message); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// True once the login looks done: the page moved to another site, or no
|
// True once the login looks done: the page (or the iframe the form was in)
|
||||||
// password field is left on it. A password field still showing after a few
|
// moved to another site or went away, or no password field is left on it. A
|
||||||
// seconds (filled, or emptied by a "wrong password" page) means the login
|
// password field still showing after a few seconds (filled, or emptied by a
|
||||||
// did not go through, and a wrong password is not worth saving.
|
// "wrong password" page) means the login did not go through, and a wrong
|
||||||
async function pwLoginSettled(tabId, host) {
|
// 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++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
const t = tabs.find((x) => x.id === tabId);
|
const t = tabs.find((x) => x.id === tabId);
|
||||||
if (!t) return false;
|
if (!t) return false;
|
||||||
if (liveHost(t) !== host) return true;
|
|
||||||
let pwField = false;
|
let pwField = false;
|
||||||
try {
|
if (ref) {
|
||||||
pwField = await t.view.webContents.executeJavaScriptInIsolatedWorld(1009, [{
|
const f = pwFrameFromRef(ref, t, host);
|
||||||
code: "[...document.querySelectorAll('input[type=password]')].some((e) => e.offsetWidth > 0 && e.offsetHeight > 0)",
|
if (!f) return true; // the frame navigated away or was removed
|
||||||
}]);
|
try { pwField = await f.executeJavaScript(CHECK); } catch { return true; }
|
||||||
} catch { return true; } // navigating: the page is going away
|
} 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;
|
if (!pwField) return true;
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
}
|
}
|
||||||
return false;
|
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 (!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())) {
|
if (!fs.existsSync(vaultFile())) {
|
||||||
const pick = await showApprovalModal({
|
const pick = await showApprovalModal({
|
||||||
from: "Theseus Vault",
|
from: "Theseus Vault",
|
||||||
title: "Save your passwords in Theseus?",
|
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.",
|
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" }],
|
actions: [{ id: "setup", label: "Set up the vault", primary: true }, { id: "never", label: "Never for this site" }, { id: "cancel", label: "Not now" }],
|
||||||
}, null, tabId);
|
}, null, tabId);
|
||||||
if (pick === "setup") openSettingsTab("passwords");
|
if (pick === "setup") openSettingsTab("passwords");
|
||||||
|
|
@ -8071,7 +8120,7 @@ async function offerSavePassword(tabId, host, username, password) {
|
||||||
const pick = await showApprovalModal({
|
const pick = await showApprovalModal({
|
||||||
from: "Theseus Vault",
|
from: "Theseus Vault",
|
||||||
title: update ? "Update the saved password?" : "Save this password?",
|
title: update ? "Update the saved password?" : "Save this password?",
|
||||||
origin: host,
|
origin: where,
|
||||||
rows: [
|
rows: [
|
||||||
{ label: "Username", value: username || "(none)" },
|
{ label: "Username", value: username || "(none)" },
|
||||||
{ label: "Password", value: "•".repeat(Math.min(12, password.length)) },
|
{ label: "Password", value: "•".repeat(Math.min(12, password.length)) },
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,14 @@
|
||||||
window.pwfill.onMatches((data) => {
|
window.pwfill.onMatches((data) => {
|
||||||
const list = $("list");
|
const list = $("list");
|
||||||
const matches = data.matches || [];
|
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) {
|
if (data.locked) {
|
||||||
// The vault is locked, but it has a login for this site.
|
// The vault is locked, but it has a login for this site.
|
||||||
list.innerHTML = `<div class="item" data-id="__unlock"><span class="ic">🔒</span><span class="txt"><div class="u">Sign in with a saved login</div><div class="d">Unlock your vault to fill ${esc(data.host)}</div></span></div>`;
|
list.innerHTML = `<div class="item" data-id="__unlock"><span class="ic">🔒</span><span class="txt"><div class="u">Sign in with a saved login</div><div class="d">Unlock your vault to fill ${esc(data.host)}</div></span></div>`;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@
|
||||||
// origin-list-unavailable, bad-request, busy, not-top-frame, error.
|
// origin-list-unavailable, bad-request, busy, not-top-frame, error.
|
||||||
const { contextBridge, ipcRenderer } = require("electron");
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
if (/^(https?|bns):$/.test(location.protocol)) {
|
// Top frame only (main refuses iframes anyway): web tabs run preloads in
|
||||||
|
// iframes too, for the password hooks.
|
||||||
|
if (window.top === window && /^(https?|bns):$/.test(location.protocol)) {
|
||||||
// Errors lose custom properties crossing the bridge, so the code rides in
|
// Errors lose custom properties crossing the bridge, so the code rides in
|
||||||
// the message as "[code] text" and is copied back onto the Error here, in
|
// the message as "[code] text" and is copied back onto the Error here, in
|
||||||
// the page's world.
|
// the page's world.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue