diff --git a/src/firefox-extension/manifest.json b/src/firefox-extension/manifest.json index 184d59e..3db37bc 100644 --- a/src/firefox-extension/manifest.json +++ b/src/firefox-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "SoftGlaze Smart Autofill", - "version": "1.0.1", + "version": "1.0.2", "description": "Fills signup/registration forms inside SoftGlaze profiles from your Identity Data Vault. Talks only to the local SoftGlaze app over loopback.", "browser_specific_settings": { "gecko": { diff --git a/src/firefox-extension/sg-background.js b/src/firefox-extension/sg-background.js index 71d3e3c..5f0d44c 100644 --- a/src/firefox-extension/sg-background.js +++ b/src/firefox-extension/sg-background.js @@ -139,8 +139,13 @@ browser.runtime.onMessage.addListener(function (msg, sender) { } var e = activeIds.get(key); if (!e || Date.now() - e.at > ACTIVE_TTL_MS) { activeIds.delete(key); return Promise.resolve(null); } + // 'finish': a submit that looked final but had no password; the next page decides. + if (msg.op === 'finish') { + if (String(msg.id || '') !== e.id) return Promise.resolve(null); + e.pending = true; + } e.at = Date.now(); - return Promise.resolve({ id: e.id }); + return Promise.resolve({ id: e.id, pending: !!e.pending }); } if (msg.type === 'list') { return call('/sg-autofill/list?url=' + encodeURIComponent(url), { method: 'GET' }) diff --git a/src/firefox-extension/sg-widget.js b/src/firefox-extension/sg-widget.js index a088cd0..5781f22 100644 --- a/src/firefox-extension/sg-widget.js +++ b/src/firefox-extension/sg-widget.js @@ -744,15 +744,52 @@ for (var i = 0; i < btns.length; i++) { if (NEXT_RX.test(controlText(btns[i]))) return false; } return true; } - var finalizing = false; - function finalSubmit() { + // A label alone cannot tell the last step: Buildium's STEP 1 button reads "Start + // My Free Trial". A submit that carries a filled password is an account being + // created, so it is final. Anything else is only "finishing": the identity stays + // active (pending) and the page that follows decides - more fields to fill means + // it was a step, nothing left means it was the end and the identity is used. + function hasFilledPassword(scope) { + var pws = (scope && scope.querySelectorAll ? scope : document).querySelectorAll('input[type="password"]'); + for (var i = 0; i < pws.length; i++) { if (pws[i].value) return true; } + return false; + } + function stepContinues() { + if (newEmptyFieldExists()) return true; + var pws = document.querySelectorAll('input[type="password"]'); + for (var i = 0; i < pws.length; i++) { if (fillable(pws[i]) && !pws[i].value) return true; } + return false; + } + // Watch the current page for up to `ms`: a further step continues the signup + // (pending cleared), no step at all marks the identity used. + function settlePending(ms, onContinue) { + var t0 = Date.now(); + (function check() { + if (!active || leaving) return; + if (stepContinues()) { setActive(active); if (onContinue) onContinue(); return; } + if (Date.now() - t0 >= ms) { markSelectedUsed(true); return; } + setTimeout(check, 500); + })(); + } + var finalizing = false, leaving = false; + window.addEventListener('beforeunload', function () { leaving = true; }, true); + window.addEventListener('pagehide', function () { leaving = true; }, true); + function finalSubmit(scope) { if (!active || finalizing) return; finalizing = true; - markSelectedUsed(true).then(function () { finalizing = false; }, function () { finalizing = false; }); + var done = function () { finalizing = false; }; + if (!canTrackActive() || hasFilledPassword(scope)) { markSelectedUsed(true).then(done, done); return; } + var id = active.id; + sgCall('__sgPersonaActive', 'finish', id).then(function (r) { + // A backend without 'finish' (older Firefox extension) keeps the old rule. + if (!r || r.id !== id || !r.pending) return markSelectedUsed(true); + // The page may stay (SPA wizard, or a validation error): decide here too. + setTimeout(function () { settlePending(8000, function () { armMultiStep(active); }); }, 1500); + }).then(done, done); } document.addEventListener('submit', function (e) { if (!e.isTrusted || !active) return; - if (isFinalSubmit(e.submitter || null, e.target)) finalSubmit(); + if (isFinalSubmit(e.submitter || null, e.target)) finalSubmit(e.target); }, true); // SPA signups often have no
: the last step is a plain button with a click // handler. Buttons only - a "Sign up" LINK in a header is navigation, not a submit. @@ -763,7 +800,7 @@ // A real submit button is handled by the submit event above. if (el.form && (el.type === 'submit' || (el.tagName === 'BUTTON' && !el.getAttribute('type')))) return; var t = controlText(el); - if (t && !NEXT_RX.test(t) && FINAL_RX.test(t)) finalSubmit(); + if (t && !NEXT_RX.test(t) && FINAL_RX.test(t)) finalSubmit(null); }, true); // --- resume an active identity on a new page / step ------------------------ @@ -782,7 +819,13 @@ active = p; selected = p; // The step fill reports the password itself; this covers a step that is // ONLY a password (nothing else to fill). - var go = function () { updateVisibility(); armMultiStep(p); setTimeout(function () { if (!newEmptyFieldExists()) nudgePassword(); }, 1500); }; + var go = function () { + updateVisibility(); + var carryOn = function () { armMultiStep(p); setTimeout(function () { if (!newEmptyFieldExists()) nudgePassword(); }, 1500); }; + // The last submit only looked final: this page decides (forms often render + // late, so give it a few seconds before calling the signup finished). + if (r.pending) settlePending(8000, carryOn); else carryOn(); + }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { setTimeout(go, 700); }); else setTimeout(go, 700); return null; diff --git a/src/main/browserEngine.js b/src/main/browserEngine.js index 05c9eb8..e8c3cde 100644 --- a/src/main/browserEngine.js +++ b/src/main/browserEngine.js @@ -185,6 +185,7 @@ function toPublicPersona(p) { // Active identities, per launched browser (so every tab of one profile shares them and // they die with the session). 30 minutes of no activity releases one. +const { siteKeyOf } = require('./siteKey'); const ACTIVE_IDENTITY_TTL_MS = 30 * 60 * 1000; const activeIdentityStores = new WeakMap(); // Browser -> Map(siteKey -> { id, url, at }) function activeStoreFor(targetPage) { @@ -195,21 +196,7 @@ function activeStoreFor(targetPage) { if (!m) { m = new Map(); activeIdentityStores.set(owner, m); } return m; } -// The site a signup belongs to: the registrable domain, so signup.example.com and -// app.example.com are one site. A cheap public-suffix rule: keep three labels when the -// second-level label is a short generic one under a ccTLD (example.co.uk, example.com.au). -function siteKeyOf(url) { - let h = ''; - try { const u = new URL(String(url || '')); if (!/^https?:$/.test(u.protocol)) return ''; h = u.hostname.toLowerCase(); } catch (e) { return ''; } - if (!h) return ''; - if (/^\d+\.\d+\.\d+\.\d+$/.test(h) || h.includes(':')) return h; // IP literal - const parts = h.split('.'); - if (parts.length <= 2) return h; - const tld = parts[parts.length - 1]; - const sld = parts[parts.length - 2]; - const keep = (tld.length === 2 && /^(co|com|net|org|gov|edu|ac|ne|or|go)$/.test(sld)) ? 3 : 2; - return parts.slice(-keep).join('.'); -} +// The site a signup belongs to (registrable domain): see siteKey.js. async function attachPersonaAutofill(targetPage) { if (!personaBridge || !targetPage) return; @@ -260,9 +247,21 @@ async function attachPersonaAutofill(targetPage) { return { id: pid }; } const e = activeSites.get(key); - if (!e || now - e.at > ACTIVE_IDENTITY_TTL_MS) { activeSites.delete(key); return null; } + if (!e || now - e.at > ACTIVE_IDENTITY_TTL_MS) { + activeSites.delete(key); + // A "finishing" identity nobody continued with was a real final submit. + if (e && e.pending) { try { await personaBridge.markUsed(e.id, e.url); } catch (err) { /* best effort */ } } + return null; + } + // 'finish': a submit that LOOKS final but carried no password ("Start my free + // trial" on a step-1 form). It stays active as pending and the next page + // decides: more fields = it was a step, nothing to fill = mark used. + if (op === 'finish') { + if (String(id || '') !== e.id) return null; + e.pending = true; + } e.at = now; // each step keeps it alive - return { id: e.id }; + return { id: e.id, pending: !!e.pending }; }; const hPersonaMarkUsed = async (id) => { if (!(await vaultOpen())) return { ok: false, error: 'locked' }; diff --git a/src/main/ipcHandlers.js b/src/main/ipcHandlers.js index 976ae19..042dd68 100644 --- a/src/main/ipcHandlers.js +++ b/src/main/ipcHandlers.js @@ -40,6 +40,7 @@ const browserDownloader = require('./browserDownloader'); const firefoxEngine = require('./firefoxEngine'); const localApi = require('./localApi'); const autofillBridge = require('./autofillBridge'); +const { siteKeyOf } = require('./siteKey'); const updater = require('./updater'); const totp = require('./totp'); const permissions = require('./permissions'); @@ -7409,13 +7410,18 @@ function toPublicPersona(p) { return { ...rest, hasPassword: Boolean(password) }; } -// Return only personas whose usedOnUrls does NOT already contain this host. +// Return only personas not yet used on this SITE. usedOnUrls keeps the exact host +// it was used on, but the match is by site (registrable domain): an identity used on +// learn.example.com is used on example.com too, so a signup that moves between +// subdomains keeps the same identity offered on every step and never offers a +// used one again. async function getAvailablePersonasForUrl(payload) { const input = requireObject(payload); const host = normalizeHostname(input.url ?? input.hostname); if (!host) throw new Error('A valid URL or hostname is required.'); + const site = siteKeyOf(host) || host; const rows = await getPrisma().personaData.findMany({ orderBy: { createdAt: 'desc' } }); - const available = rows.filter((p) => !parseUsedOnUrls(p.usedOnUrls).includes(host)); + const available = rows.filter((p) => !parseUsedOnUrls(p.usedOnUrls).some((h) => h === host || siteKeyOf(h) === site)); // audit C2: never ship the plaintext password in a list payload - strip it here // so the Chromium bridge, the Firefox loopback bridge, AND the renderer channel // are all password-free at the source. diff --git a/src/main/personaAutofill.js b/src/main/personaAutofill.js index 7b12622..e80518e 100644 --- a/src/main/personaAutofill.js +++ b/src/main/personaAutofill.js @@ -769,15 +769,52 @@ function personaAutofillMain(sgRpc) { for (var i = 0; i < btns.length; i++) { if (NEXT_RX.test(controlText(btns[i]))) return false; } return true; } - var finalizing = false; - function finalSubmit() { + // A label alone cannot tell the last step: Buildium's STEP 1 button reads "Start + // My Free Trial". A submit that carries a filled password is an account being + // created, so it is final. Anything else is only "finishing": the identity stays + // active (pending) and the page that follows decides - more fields to fill means + // it was a step, nothing left means it was the end and the identity is used. + function hasFilledPassword(scope) { + var pws = (scope && scope.querySelectorAll ? scope : document).querySelectorAll('input[type="password"]'); + for (var i = 0; i < pws.length; i++) { if (pws[i].value) return true; } + return false; + } + function stepContinues() { + if (newEmptyFieldExists()) return true; + var pws = document.querySelectorAll('input[type="password"]'); + for (var i = 0; i < pws.length; i++) { if (fillable(pws[i]) && !pws[i].value) return true; } + return false; + } + // Watch the current page for up to `ms`: a further step continues the signup + // (pending cleared), no step at all marks the identity used. + function settlePending(ms, onContinue) { + var t0 = Date.now(); + (function check() { + if (!active || leaving) return; + if (stepContinues()) { setActive(active); if (onContinue) onContinue(); return; } + if (Date.now() - t0 >= ms) { markSelectedUsed(true); return; } + setTimeout(check, 500); + })(); + } + var finalizing = false, leaving = false; + window.addEventListener('beforeunload', function () { leaving = true; }, true); + window.addEventListener('pagehide', function () { leaving = true; }, true); + function finalSubmit(scope) { if (!active || finalizing) return; finalizing = true; - markSelectedUsed(true).then(function () { finalizing = false; }, function () { finalizing = false; }); + var done = function () { finalizing = false; }; + if (!canTrackActive() || hasFilledPassword(scope)) { markSelectedUsed(true).then(done, done); return; } + var id = active.id; + sgCall('__sgPersonaActive', 'finish', id).then(function (r) { + // A backend without 'finish' (older Firefox extension) keeps the old rule. + if (!r || r.id !== id || !r.pending) return markSelectedUsed(true); + // The page may stay (SPA wizard, or a validation error): decide here too. + setTimeout(function () { settlePending(8000, function () { armMultiStep(active); }); }, 1500); + }).then(done, done); } document.addEventListener('submit', function (e) { if (!e.isTrusted || !active) return; - if (isFinalSubmit(e.submitter || null, e.target)) finalSubmit(); + if (isFinalSubmit(e.submitter || null, e.target)) finalSubmit(e.target); }, true); // SPA signups often have no : the last step is a plain button with a click // handler. Buttons only - a "Sign up" LINK in a header is navigation, not a submit. @@ -788,7 +825,7 @@ function personaAutofillMain(sgRpc) { // A real submit button is handled by the submit event above. if (el.form && (el.type === 'submit' || (el.tagName === 'BUTTON' && !el.getAttribute('type')))) return; var t = controlText(el); - if (t && !NEXT_RX.test(t) && FINAL_RX.test(t)) finalSubmit(); + if (t && !NEXT_RX.test(t) && FINAL_RX.test(t)) finalSubmit(null); }, true); // --- resume an active identity on a new page / step ------------------------ @@ -807,7 +844,13 @@ function personaAutofillMain(sgRpc) { active = p; selected = p; // The step fill reports the password itself; this covers a step that is // ONLY a password (nothing else to fill). - var go = function () { updateVisibility(); armMultiStep(p); setTimeout(function () { if (!newEmptyFieldExists()) nudgePassword(); }, 1500); }; + var go = function () { + updateVisibility(); + var carryOn = function () { armMultiStep(p); setTimeout(function () { if (!newEmptyFieldExists()) nudgePassword(); }, 1500); }; + // The last submit only looked final: this page decides (forms often render + // late, so give it a few seconds before calling the signup finished). + if (r.pending) settlePending(8000, carryOn); else carryOn(); + }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { setTimeout(go, 700); }); else setTimeout(go, 700); return null; diff --git a/src/main/siteKey.js b/src/main/siteKey.js new file mode 100644 index 0000000..1d50b58 --- /dev/null +++ b/src/main/siteKey.js @@ -0,0 +1,42 @@ +'use strict'; + +// The site a URL or hostname belongs to: its registrable domain, so learn.example.com, +// www.example.com and signup.example.com are one site. Used for multi-step signups +// (the active identity follows the user across subdomains) and for the Data Vault +// "already used here" rule (an identity used on one subdomain is used on the site). +// +// A cheap public-suffix rule, no list download: keep three labels when the +// second-level label is a short generic one under a ccTLD (example.co.uk, +// example.com.au), and when the parent is a shared hosting domain where every +// subdomain is a different owner (shop.myshopify.com, user.github.io). +const SHARED_HOSTING = new Set([ + 'myshopify.com', 'github.io', 'gitlab.io', 'herokuapp.com', 'vercel.app', 'netlify.app', + 'pages.dev', 'workers.dev', 'web.app', 'firebaseapp.com', 'appspot.com', 'azurewebsites.net', + 'cloudfront.net', 'wordpress.com', 'blogspot.com', 'wixsite.com', 'webflow.io', + 'squarespace.com', 'tumblr.com', 'substack.com', 'onrender.com', 'fly.dev', 'glitch.me', + 'repl.co', 'ngrok.io', 'ngrok-free.app', 'framer.website', 'carrd.co' +]); + +function hostOf(value) { + let s = String(value == null ? '' : value).trim().toLowerCase(); + if (!s) return ''; + const hasScheme = /^[a-z][a-z0-9+.-]*:/.test(s); + if (hasScheme && !/^https?:\/\//.test(s)) return ''; // file:, about:, chrome: ... have no site + if (!hasScheme) s = 'http://' + s; + try { return new URL(s).hostname; } catch (e) { return ''; } +} + +function siteKeyOf(value) { + const h = hostOf(value); + if (!h) return ''; + if (/^\d+\.\d+\.\d+\.\d+$/.test(h) || h.includes(':') || h.startsWith('[')) return h; // IP literal + const parts = h.split('.'); + if (parts.length <= 2) return h; + const tld = parts[parts.length - 1]; + const sld = parts[parts.length - 2]; + let keep = (tld.length === 2 && /^(co|com|net|org|gov|edu|ac|ne|or|go)$/.test(sld)) ? 3 : 2; + if (SHARED_HOSTING.has(parts.slice(-keep).join('.'))) keep += 1; + return parts.slice(-Math.min(keep, parts.length)).join('.'); +} + +module.exports = { siteKeyOf }; diff --git a/test/autofillFillEngine.test.js b/test/autofillFillEngine.test.js index 87e2618..f7ae0a9 100644 --- a/test/autofillFillEngine.test.js +++ b/test/autofillFillEngine.test.js @@ -176,6 +176,21 @@ test('only a FINAL submit marks the identity used; Next / Continue keep it', () assert.match(widgetSrc, /document\.addEventListener\('submit', function \(e\) \{\s*if \(!e\.isTrusted \|\| !active\) return;/); }); +test('a final-looking submit without a password only FINISHES: the next page decides', () => { + // Buildium's step-1 button reads "Start My Free Trial": it must not burn the identity. + const fin = extractFunction(widgetSrc, 'finalSubmit'); + assert.match(fin, /if \(!canTrackActive\(\) \|\| hasFilledPassword\(scope\)\) \{ markSelectedUsed\(true\)/, 'a filled password = account created = used now'); + assert.match(fin, /sgCall\('__sgPersonaActive', 'finish', id\)/); + assert.match(fin, /if \(!r \|\| r\.id !== id \|\| !r\.pending\) return markSelectedUsed\(true\);/, 'old backend keeps the old rule'); + const settle = extractFunction(widgetSrc, 'settlePending'); + assert.match(settle, /if \(stepContinues\(\)\) \{ setActive\(active\);/, 'another step clears pending'); + assert.match(settle, /markSelectedUsed\(true\); return;/, 'no further step = used'); + assert.match(widgetSrc, /if \(r\.pending\) settlePending\(8000, carryOn\); else carryOn\(\);/, 'the next page settles a pending finish'); + assert.match(attach, /if \(op === 'finish'\) \{[\s\S]*?e\.pending = true;/); + assert.match(attach, /if \(e && e\.pending\) \{ try \{ await personaBridge\.markUsed\(e\.id, e\.url\);/, 'an abandoned pending finish is marked used on expiry'); + assert.match(SRC('firefox-extension/sg-background.js'), /if \(msg\.op === 'finish'\) \{/); +}); + test('a new page resumes the active identity and fills the step, never the password', () => { assert.match(widgetSrc, /sgCall\('__sgPersonaActive', 'get'\)/); const arm = extractFunction(widgetSrc, 'armMultiStep'); diff --git a/test/siteKey.test.js b/test/siteKey.test.js new file mode 100644 index 0000000..87da2a9 --- /dev/null +++ b/test/siteKey.test.js @@ -0,0 +1,37 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { siteKeyOf } = require('../src/main/siteKey'); + +test('subdomains of one site share a site key', () => { + for (const u of ['https://learn.buildium.com/affiliates/', 'https://www.buildium.com/free-trial/customize/', 'buildium.com', 'learn.buildium.com']) { + assert.equal(siteKeyOf(u), 'buildium.com', u); + } + assert.equal(siteKeyOf('https://www.shop.example.co.uk/x'), 'example.co.uk'); + assert.equal(siteKeyOf('https://my.site.com.au/'), 'site.com.au'); + assert.equal(siteKeyOf('http://127.0.0.1:8799/page2.html'), '127.0.0.1'); +}); + +test('shared hosting keeps each owner separate', () => { + assert.equal(siteKeyOf('https://alpha.myshopify.com/account'), 'alpha.myshopify.com'); + assert.notEqual(siteKeyOf('https://alpha.myshopify.com/'), siteKeyOf('https://beta.myshopify.com/')); + assert.equal(siteKeyOf('https://user.github.io/app'), 'user.github.io'); + assert.equal(siteKeyOf('https://myshopify.com/'), 'myshopify.com'); +}); + +test('non-web URLs have no site', () => { + assert.equal(siteKeyOf('file:///C:/x.html'), ''); + assert.equal(siteKeyOf('about:blank'), ''); + assert.equal(siteKeyOf(''), ''); + assert.equal(siteKeyOf(null), ''); +}); + +test('the Data Vault "already used" filter matches by site, not exact host', () => { + const src = fs.readFileSync(path.join(__dirname, '../src/main/ipcHandlers.js'), 'utf8'); + assert.match(src, /const site = siteKeyOf\(host\) \|\| host;/); + assert.match(src, /parseUsedOnUrls\(p\.usedOnUrls\)\.some\(\(h\) => h === host \|\| siteKeyOf\(h\) === site\)/); + const engine = fs.readFileSync(path.join(__dirname, '../src/main/browserEngine.js'), 'utf8'); + assert.match(engine, /const \{ siteKeyOf \} = require\('\.\/siteKey'\);/, 'the active identity uses the same rule'); +});