Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/firefox-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
7 changes: 6 additions & 1 deletion src/firefox-extension/sg-background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
55 changes: 49 additions & 6 deletions src/firefox-extension/sg-widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <form>: 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.
Expand All @@ -763,7 +800,7 @@
// A real <form> 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 ------------------------
Expand All @@ -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;
Expand Down
33 changes: 16 additions & 17 deletions src/main/browserEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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' };
Expand Down
10 changes: 8 additions & 2 deletions src/main/ipcHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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.
Expand Down
55 changes: 49 additions & 6 deletions src/main/personaAutofill.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <form>: 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.
Expand All @@ -788,7 +825,7 @@ function personaAutofillMain(sgRpc) {
// A real <form> 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 ------------------------
Expand All @@ -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;
Expand Down
42 changes: 42 additions & 0 deletions src/main/siteKey.js
Original file line number Diff line number Diff line change
@@ -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 };
15 changes: 15 additions & 0 deletions test/autofillFillEngine.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading