From 0dce2a649ef81bd22a3297399041c6e6db2ad71f Mon Sep 17 00:00:00 2001 From: Dax Mobile <44842493+daxmobile@users.noreply.github.com> Date: Fri, 2 Aug 2024 11:42:54 -0400 Subject: [PATCH] Update autofill to 12.1.0 (#4810) Task/Issue URL: https://app.asana.com/0/1207920271697429/1207920271697429 Autofill Release: https://github.com/duckduckgo/duckduckgo-autofill/releases/tag/12.1.0 ## Description Updates Autofill to version [12.1.0](https://github.com/duckduckgo/duckduckgo-autofill/releases/tag/12.1.0). ### Autofill 12.1.0 release notes ## What's Changed * [Form] Skip autofilling select input if it was changed already by @dbajpeyi in https://github.com/duckduckgo/duckduckgo-autofill/pull/580 * scanner: discard Form instances with no classified inputs by @sjbarag in https://github.com/duckduckgo/duckduckgo-autofill/pull/574 * [Credentials] Autofill when elements are in shadow by @dbajpeyi in https://github.com/duckduckgo/duckduckgo-autofill/pull/592 * Update password-related json files (2024-07-19) by @daxmobile in https://github.com/duckduckgo/duckduckgo-autofill/pull/607 * [Scanner] Don't stop scanner fully on max forms by @dbajpeyi in https://github.com/duckduckgo/duckduckgo-autofill/pull/617 * tests: remove spurious logging from unit tests by @sjbarag in https://github.com/duckduckgo/duckduckgo-autofill/pull/575 * Add credit card test form failure by @GioSensation in https://github.com/duckduckgo/duckduckgo-autofill/pull/588 ## New Contributors * @dbajpeyi made their first contribution in https://github.com/duckduckgo/duckduckgo-autofill/pull/580 **Full Changelog**: https://github.com/duckduckgo/duckduckgo-autofill/compare/12.0.1...12.1.0 ## Steps to test This release has been tested during autofill development. For smoke test steps see [this task](https://app.asana.com/0/1198964220583541/1200583647142330/f). Co-authored-by: GioSensation <1828326+GioSensation@users.noreply.github.com> --- .../autofill/dist/autofill-debug.js | 218 +++++++++++++----- .../@duckduckgo/autofill/dist/autofill.js | 218 +++++++++++++----- .../autofill/dist/shared-credentials.json | 64 +++++ package-lock.json | 7 +- package.json | 2 +- 5 files changed, 393 insertions(+), 116 deletions(-) diff --git a/node_modules/@duckduckgo/autofill/dist/autofill-debug.js b/node_modules/@duckduckgo/autofill/dist/autofill-debug.js index b7928dc3c2a0..ffd11d3ce5d0 100644 --- a/node_modules/@duckduckgo/autofill/dist/autofill-debug.js +++ b/node_modules/@duckduckgo/autofill/dist/autofill-debug.js @@ -7215,6 +7215,9 @@ module.exports={ "prestocard.ca": { "password-rules": "minlength: 8; required: lower; required: upper; required: digit,[!\"#$%&'()*+,<>?@];" }, + "pret.com": { + "password-rules": "minlength: 12; required: lower; required: digit; required: [@$!%*#?&]; allowed: upper;" + }, "propelfuels.com": { "password-rules": "minlength: 6; maxlength: 16;" }, @@ -9742,13 +9745,16 @@ class Form { * @param {import("../DeviceInterface/InterfacePrototype").default} deviceInterface * @param {import("../Form/matching").Matching} [matching] * @param {Boolean} [shouldAutoprompt] + * @param {Boolean} [hasShadowTree] */ constructor(form, input, deviceInterface, matching) { let shouldAutoprompt = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false; + let hasShadowTree = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : false; this.form = form; this.matching = matching || (0, _matching.createMatching)(); this.formAnalyzer = new _FormAnalyzer.default(form, input, matching); this.device = deviceInterface; + this.hasShadowTree = hasShadowTree; /** @type Record<'all' | SupportedMainTypes, Set> */ this.inputs = { @@ -10077,7 +10083,10 @@ class Form { // For form elements we use .elements to catch fields outside the form itself using the form attribute. // It also catches all elements when the markup is broken. // We use .filter to avoid fieldset, button, textarea etc. - foundInputs = [...this.form.elements].filter(el => el.matches(selector)); + const formElements = [...this.form.elements].filter(el => el.matches(selector)); + // If there are not form elements, we try to look for all + // enclosed elements within the form. + foundInputs = formElements.length > 0 ? formElements : (0, _autofillUtils.findEnclosedElements)(this.form, selector); } else { foundInputs = this.form.querySelectorAll(selector); } @@ -10085,7 +10094,7 @@ class Form { foundInputs.forEach(input => this.addInput(input)); } else { // This is rather extreme, but better safe than sorry - this.device.scanner.stopScanner(`The form has too many inputs (${foundInputs.length}), bailing.`); + this.device.scanner.setMode('stopped', `The form has too many inputs (${foundInputs.length}), bailing.`); return; } } @@ -10098,7 +10107,7 @@ class Form { } get submitButtons() { const selector = this.matching.cssSelector('submitButtonSelector'); - const allButtons = /** @type {HTMLElement[]} */[...this.form.querySelectorAll(selector)]; + const allButtons = /** @type {HTMLElement[]} */(0, _autofillUtils.findEnclosedElements)(this.form, selector); return allButtons.filter(btn => (0, _autofillUtils.isPotentiallyViewable)(btn) && (0, _autofillUtils.isLikelyASubmitButton)(btn, this.matching) && (0, _autofillUtils.buttonMatchesFormType)(btn, this)); } attemptSubmissionIfNeeded() { @@ -10147,7 +10156,7 @@ class Form { // If the form has too many inputs, destroy everything to avoid performance issues if (this.inputs.all.size > MAX_INPUTS_PER_FORM) { - this.device.scanner.stopScanner('The form has too many inputs, bailing.'); + this.device.scanner.setMode('stopped', 'The form has too many inputs, bailing.'); return this; } @@ -10302,6 +10311,9 @@ class Form { storedClickCoords = new WeakMap(); }, 1000); }; + const handlerSelect = () => { + this.touched.add(input); + }; const handler = e => { // Avoid firing multiple times if (this.isAutofilling || this.device.isTooltipActive()) { @@ -10346,19 +10358,22 @@ class Form { (0, _autofillUtils.addInlineStyles)(input, activeStyles); } }; + const isMobileApp = this.device.globalConfig.isMobileApp; if (!(input instanceof HTMLSelectElement)) { const events = ['pointerdown']; - if (!this.device.globalConfig.isMobileApp) events.push('focus'); + if (!isMobileApp) events.push('focus'); input.labels?.forEach(label => { - if (this.device.globalConfig.isMobileApp) { - // On mobile devices we don't trigger on focus, so we use the click handler here - this.addListener(label, 'pointerdown', handler); - } else { - // Needed to handle label clicks when the form is in an iframe - this.addListener(label, 'pointerdown', handlerLabel); - } + // On mobile devices: handle click events (instead of focus) for labels, + // On desktop devices: handle label clicks which is needed when the form + // is in an iframe. + this.addListener(label, 'pointerdown', isMobileApp ? handler : handlerLabel); }); events.forEach(ev => this.addListener(input, ev, handler)); + } else { + this.addListener(input, 'change', handlerSelect); + input.labels?.forEach(label => { + this.addListener(label, 'pointerdown', isMobileApp ? handlerSelect : handlerLabel); + }); } return this; } @@ -10392,19 +10407,32 @@ class Form { } return !this.touched.has(input) && !input.classList.contains('ddg-autofilled'); } + + /** + * Skip overridding values that the user provided if: + * - we're autofilling non credit card type and, + * - it's a previously filled input or, + * - it's a select input that was already "touched" by the user. + * @param {HTMLInputElement|HTMLSelectElement} input + * @param {'all' | SupportedMainTypes} dataType + * @returns {boolean} + **/ + shouldSkipInput(input, dataType) { + if (dataType === 'creditCards') { + // creditCards always override, even if the input is filled + return false; + } + const isPreviouslyFilledInput = input.value !== '' && this.activeInput !== input; + // if the input select type, then skip if it was previously touched + // otherwise, skip if it was previously filled + return input.nodeName === 'SELECT' ? this.touched.has(input) : isPreviouslyFilledInput; + } autofillInput(input, string, dataType) { // Do not autofill if it's invisible (select elements can be hidden because of custom implementations) if (input instanceof HTMLInputElement && !(0, _autofillUtils.isPotentiallyViewable)(input)) return; // Do not autofill if it's disabled or readonly to avoid potential breakage if (!(0, _inputTypeConfig.canBeInteractedWith)(input)) return; - - // Don't override values the user provided, unless it's the focused input or we're autofilling creditCards - if (dataType !== 'creditCards' && - // creditCards always override, the others only when we're focusing the input - input.nodeName !== 'SELECT' && input.value !== '' && - // if the input is not empty - this.activeInput !== input // and this is not the active input - ) return; // do not overwrite the value + if (this.shouldSkipInput(input, dataType)) return; // If the value is already there, just return if (input.value === string) return; @@ -10716,9 +10744,13 @@ class FormAnalyzer { }); } evaluateUrl() { - const path = window.location.pathname; - const matchesLogin = (0, _autofillUtils.safeRegexTest)(this.matching.getDDGMatcherRegex('loginRegex'), path); - const matchesSignup = (0, _autofillUtils.safeRegexTest)(this.matching.getDDGMatcherRegex('conservativeSignupRegex'), path); + const { + pathname, + hash + } = window.location; + const pathToMatch = pathname + hash; + const matchesLogin = (0, _autofillUtils.safeRegexTest)(this.matching.getDDGMatcherRegex('loginRegex'), pathToMatch); + const matchesSignup = (0, _autofillUtils.safeRegexTest)(this.matching.getDDGMatcherRegex('conservativeSignupRegex'), pathToMatch); // If the url matches both, do nothing: the signal is probably confounding if (matchesLogin && matchesSignup) return; @@ -10845,7 +10877,8 @@ class FormAnalyzer { this.evaluateElAttributes(this.form); // Check form contents (noisy elements are skipped with the safeUniversalSelector) - const formElements = this.form.querySelectorAll(this.matching.cssSelector('safeUniversalSelector')); + const selector = this.matching.cssSelector('safeUniversalSelector'); + const formElements = (0, _autofillUtils.findEnclosedElements)(this.form, selector); for (let i = 0; i < formElements.length; i++) { // Safety cutoff to avoid huge DOMs freezing the browser if (i >= 200) break; @@ -14158,7 +14191,7 @@ const { * findEligibleInputs(context): Scanner; * matching: import("./Form/matching").Matching; * options: ScannerOptions; - * stopScanner: (reason: string, ...rest: any) => void; + * setMode: (mode: Mode, reason: string, ...rest: any) => void; * }} Scanner * * @typedef {{ @@ -14169,6 +14202,8 @@ const { * maxFormsPerPage: number, * maxInputsPerForm: number * }} ScannerOptions + * + * @typedef {'scanning'|'on-click'|'stopped'} Mode */ /** @type {ScannerOptions} */ @@ -14207,8 +14242,8 @@ class DefaultScanner { activeInput = null; /** @type {boolean} A flag to indicate the whole page will be re-scanned */ rescanAll = false; - /** @type {boolean} Indicates whether we called stopScanning */ - stopped = false; + /** @type {Mode} Indicates the mode in which the scanner is operating */ + mode = 'scanning'; /** @type {import("./Form/matching").Matching} matching */ matching; @@ -14265,7 +14300,7 @@ class DefaultScanner { for (var _len = arguments.length, rest = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { rest[_key - 1] = arguments[_key]; } - _this.stopScanner(reason, ...rest); + _this.setMode('stopped', reason, ...rest); }; } @@ -14294,45 +14329,65 @@ class DefaultScanner { if ('matches' in context && context.matches?.(this.matching.cssSelector('formInputsSelectorWithoutSelect'))) { this.addInput(context); } else { - const inputs = context.querySelectorAll(this.matching.cssSelector('formInputsSelectorWithoutSelect')); + const selector = this.matching.cssSelector('formInputsSelectorWithoutSelect'); + const inputs = context.querySelectorAll(selector); if (inputs.length > this.options.maxInputsPerPage) { - this.stopScanner(`Too many input fields in the given context (${inputs.length}), stop scanning`, context); + this.setMode('stopped', `Too many input fields in the given context (${inputs.length}), stop scanning`, context); return this; } inputs.forEach(input => this.addInput(input)); + if (context instanceof HTMLFormElement && this.forms.get(context)?.hasShadowTree) { + const selector = this.matching.cssSelector('formInputsSelectorWithoutSelect'); + (0, _autofillUtils.findEnclosedElements)(context, selector).forEach(input => { + if (input instanceof HTMLInputElement) { + this.addInput(input, context); + } + }); + } } return this; } /** - * Stops scanning, switches off the mutation observer and clears all forms + * Sets the scanner mode, logging the reason and any additional arguments. + * 'stopped', switches off the mutation observer and clears all forms and listeners, + * 'on-click', keeps event listeners so that scanning can continue on clicking, + * 'scanning', default operation triggered in normal conditions + * Keep the listener for pointerdown to scan on click if needed. + * @param {Mode} mode * @param {string} reason * @param {any} rest */ - stopScanner(reason) { - this.stopped = true; + setMode(mode, reason) { + this.mode = mode; if ((0, _autofillUtils.shouldLog)()) { - for (var _len2 = arguments.length, rest = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { - rest[_key2 - 1] = arguments[_key2]; + for (var _len2 = arguments.length, rest = new Array(_len2 > 2 ? _len2 - 2 : 0), _key2 = 2; _key2 < _len2; _key2++) { + rest[_key2 - 2] = arguments[_key2]; } - console.log(reason, ...rest); + console.log(mode, reason, ...rest); + } + if (mode === 'scanning') return; + if (mode === 'stopped') { + window.removeEventListener('pointerdown', this, true); + window.removeEventListener('focus', this, true); } - const activeInput = this.device.activeForm?.activeInput; // remove Dax, listeners, timers, and observers clearTimeout(this.debounceTimer); this.changedElements.clear(); this.mutObs.disconnect(); - window.removeEventListener('pointerdown', this, true); - window.removeEventListener('focus', this, true); this.forms.forEach(form => { form.destroy(); }); this.forms.clear(); // Bring the user back to the input they were interacting with + const activeInput = this.device.activeForm?.activeInput; activeInput?.focus(); } + get isStopped() { + return this.mode === 'stopped'; + } /** * @param {HTMLElement|HTMLInputElement|HTMLSelectElement} input @@ -14358,20 +14413,32 @@ class DefaultScanner { let traversalLayerCount = 0; let element = input; // traverse the DOM to search for related inputs - while (traversalLayerCount <= 5 && element.parentElement && element.parentElement !== document.documentElement) { + while (traversalLayerCount <= 5 && element.parentElement !== document.documentElement) { // Avoid overlapping containers or forms const siblingForm = element.parentElement?.querySelector('form'); if (siblingForm && siblingForm !== element) { return element; } - element = element.parentElement; - const inputs = element.querySelectorAll(this.matching.cssSelector('formInputsSelector')); - const buttons = element.querySelectorAll(this.matching.cssSelector('submitButtonSelector')); - // If we find a button or another input, we assume that's our form - if (inputs.length > 1 || buttons.length) { - // found related input, return common ancestor + if (element instanceof HTMLFormElement) { return element; } + if (element.parentElement) { + element = element.parentElement; + const inputs = element.querySelectorAll(this.matching.cssSelector('formInputsSelector')); + const buttons = element.querySelectorAll(this.matching.cssSelector('submitButtonSelector')); + // If we find a button or another input, we assume that's our form + if (inputs.length > 1 || buttons.length) { + // found related input, return common ancestor + return element; + } + } else { + // possibly a shadow boundary, so traverse through the shadow root and find the form + const root = element.getRootNode(); + if (root instanceof ShadowRoot && root.host) { + // @ts-ignore + element = root.host; + } + } traversalLayerCount++; } return input; @@ -14379,17 +14446,19 @@ class DefaultScanner { /** * @param {HTMLInputElement|HTMLSelectElement} input + * @param {HTMLFormElement|null} form */ addInput(input) { - if (this.stopped) return; - const parentForm = this.getParentForm(input); + let form = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + if (this.isStopped) return; + const parentForm = form || this.getParentForm(input); if (parentForm instanceof HTMLFormElement && this.forms.has(parentForm)) { const foundForm = this.forms.get(parentForm); // We've met the form, add the input provided it's below the max input limit if (foundForm && foundForm.inputs.all.size < MAX_INPUTS_PER_FORM) { foundForm.addInput(input); } else { - this.stopScanner('The form has too many inputs, destroying.'); + this.setMode('stopped', 'The form has too many inputs, destroying.'); } return; } @@ -14433,8 +14502,9 @@ class DefaultScanner { // Only add the form if below the limit of forms per page if (this.forms.size < this.options.maxFormsPerPage) { this.forms.set(parentForm, new _Form.Form(parentForm, input, this.device, this.matching, this.shouldAutoprompt)); + // Also only add the form if it hasn't self-destructed due to having too few fields } else { - this.stopScanner('The page has too many forms, stop adding them.'); + this.setMode('on-click', 'The page has too many forms, stop adding them.'); } } } @@ -14510,7 +14580,7 @@ class DefaultScanner { switch (event.type) { case 'pointerdown': case 'focus': - this.scanShadow(event); + this.scanOnClick(event); break; } } @@ -14519,15 +14589,24 @@ class DefaultScanner { * Scan clicked input fields, even if they're within a shadow tree * @param {FocusEvent | PointerEvent} event */ - scanShadow(event) { - // If the scanner is stopped or there's no shadow root, just return - if (this.stopped || !(event.target instanceof Element) || !event.target?.shadowRoot) return; + scanOnClick(event) { + // If the scanner is stopped, just return + if (this.isStopped || !(event.target instanceof Element)) return; window.performance?.mark?.('scan_shadow:init:start'); + + // If the target is an input, find the real target in case it's in a shadow tree const realTarget = (0, _autofillUtils.pierceShadowTree)(event, HTMLInputElement); - // If it's an input we haven't already scanned, scan the whole shadow tree + // If it's an input we haven't already scanned, + // find the enclosing parent form, and scan it. if (realTarget instanceof HTMLInputElement && !realTarget.hasAttribute(ATTR_INPUT_TYPE)) { - this.findEligibleInputs(realTarget.getRootNode()); + const parentForm = this.getParentForm(realTarget); + if (parentForm && parentForm instanceof HTMLFormElement) { + const hasShadowTree = event.target?.shadowRoot != null; + const form = new _Form.Form(parentForm, realTarget, this.device, this.matching, this.shouldAutoprompt, hasShadowTree); + this.forms.set(parentForm, form); + this.findEligibleInputs(parentForm); + } } window.performance?.mark?.('scan_shadow:init:end'); (0, _autofillUtils.logPerformance)('scan_shadow'); @@ -16596,6 +16675,7 @@ Object.defineProperty(exports, "__esModule", { }); exports.buttonMatchesFormType = exports.autofillEnabled = exports.addInlineStyles = exports.SIGN_IN_MSG = exports.ADDRESS_DOMAIN = void 0; exports.escapeXML = escapeXML; +exports.findEnclosedElements = findEnclosedElements; exports.formatDuckAddress = void 0; exports.getActiveElement = getActiveElement; exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = exports.getDaxBoundingBox = void 0; @@ -17207,6 +17287,32 @@ function getActiveElement() { return innerActiveElement; } +/** + * Takes a root element and tries to find visible elements first, and if it fails, it tries to find shadow elements + * @param {HTMLElement|HTMLFormElement} root + * @param {string} selector + * @returns {Element[]} + */ +function findEnclosedElements(root, selector) { + // Check if there are any normal elements that match the selector + const elements = root.querySelectorAll(selector); + if (elements.length > 0) { + return Array.from(elements); + } + + // Check if there are any shadow elements that match the selector + const shadowElements = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let node = walker.nextNode(); + while (node) { + if (node instanceof HTMLElement && node.shadowRoot) { + shadowElements.push(...node.shadowRoot.querySelectorAll(selector)); + } + node = walker.nextNode(); + } + return shadowElements; +} + },{"./Form/matching.js":43,"./constants.js":65,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],63:[function(require,module,exports){ "use strict"; diff --git a/node_modules/@duckduckgo/autofill/dist/autofill.js b/node_modules/@duckduckgo/autofill/dist/autofill.js index 5582508946fa..248330880e43 100644 --- a/node_modules/@duckduckgo/autofill/dist/autofill.js +++ b/node_modules/@duckduckgo/autofill/dist/autofill.js @@ -3049,6 +3049,9 @@ module.exports={ "prestocard.ca": { "password-rules": "minlength: 8; required: lower; required: upper; required: digit,[!\"#$%&'()*+,<>?@];" }, + "pret.com": { + "password-rules": "minlength: 12; required: lower; required: digit; required: [@$!%*#?&]; allowed: upper;" + }, "propelfuels.com": { "password-rules": "minlength: 6; maxlength: 16;" }, @@ -5576,13 +5579,16 @@ class Form { * @param {import("../DeviceInterface/InterfacePrototype").default} deviceInterface * @param {import("../Form/matching").Matching} [matching] * @param {Boolean} [shouldAutoprompt] + * @param {Boolean} [hasShadowTree] */ constructor(form, input, deviceInterface, matching) { let shouldAutoprompt = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false; + let hasShadowTree = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : false; this.form = form; this.matching = matching || (0, _matching.createMatching)(); this.formAnalyzer = new _FormAnalyzer.default(form, input, matching); this.device = deviceInterface; + this.hasShadowTree = hasShadowTree; /** @type Record<'all' | SupportedMainTypes, Set> */ this.inputs = { @@ -5911,7 +5917,10 @@ class Form { // For form elements we use .elements to catch fields outside the form itself using the form attribute. // It also catches all elements when the markup is broken. // We use .filter to avoid fieldset, button, textarea etc. - foundInputs = [...this.form.elements].filter(el => el.matches(selector)); + const formElements = [...this.form.elements].filter(el => el.matches(selector)); + // If there are not form elements, we try to look for all + // enclosed elements within the form. + foundInputs = formElements.length > 0 ? formElements : (0, _autofillUtils.findEnclosedElements)(this.form, selector); } else { foundInputs = this.form.querySelectorAll(selector); } @@ -5919,7 +5928,7 @@ class Form { foundInputs.forEach(input => this.addInput(input)); } else { // This is rather extreme, but better safe than sorry - this.device.scanner.stopScanner(`The form has too many inputs (${foundInputs.length}), bailing.`); + this.device.scanner.setMode('stopped', `The form has too many inputs (${foundInputs.length}), bailing.`); return; } } @@ -5932,7 +5941,7 @@ class Form { } get submitButtons() { const selector = this.matching.cssSelector('submitButtonSelector'); - const allButtons = /** @type {HTMLElement[]} */[...this.form.querySelectorAll(selector)]; + const allButtons = /** @type {HTMLElement[]} */(0, _autofillUtils.findEnclosedElements)(this.form, selector); return allButtons.filter(btn => (0, _autofillUtils.isPotentiallyViewable)(btn) && (0, _autofillUtils.isLikelyASubmitButton)(btn, this.matching) && (0, _autofillUtils.buttonMatchesFormType)(btn, this)); } attemptSubmissionIfNeeded() { @@ -5981,7 +5990,7 @@ class Form { // If the form has too many inputs, destroy everything to avoid performance issues if (this.inputs.all.size > MAX_INPUTS_PER_FORM) { - this.device.scanner.stopScanner('The form has too many inputs, bailing.'); + this.device.scanner.setMode('stopped', 'The form has too many inputs, bailing.'); return this; } @@ -6136,6 +6145,9 @@ class Form { storedClickCoords = new WeakMap(); }, 1000); }; + const handlerSelect = () => { + this.touched.add(input); + }; const handler = e => { // Avoid firing multiple times if (this.isAutofilling || this.device.isTooltipActive()) { @@ -6180,19 +6192,22 @@ class Form { (0, _autofillUtils.addInlineStyles)(input, activeStyles); } }; + const isMobileApp = this.device.globalConfig.isMobileApp; if (!(input instanceof HTMLSelectElement)) { const events = ['pointerdown']; - if (!this.device.globalConfig.isMobileApp) events.push('focus'); + if (!isMobileApp) events.push('focus'); input.labels?.forEach(label => { - if (this.device.globalConfig.isMobileApp) { - // On mobile devices we don't trigger on focus, so we use the click handler here - this.addListener(label, 'pointerdown', handler); - } else { - // Needed to handle label clicks when the form is in an iframe - this.addListener(label, 'pointerdown', handlerLabel); - } + // On mobile devices: handle click events (instead of focus) for labels, + // On desktop devices: handle label clicks which is needed when the form + // is in an iframe. + this.addListener(label, 'pointerdown', isMobileApp ? handler : handlerLabel); }); events.forEach(ev => this.addListener(input, ev, handler)); + } else { + this.addListener(input, 'change', handlerSelect); + input.labels?.forEach(label => { + this.addListener(label, 'pointerdown', isMobileApp ? handlerSelect : handlerLabel); + }); } return this; } @@ -6226,19 +6241,32 @@ class Form { } return !this.touched.has(input) && !input.classList.contains('ddg-autofilled'); } + + /** + * Skip overridding values that the user provided if: + * - we're autofilling non credit card type and, + * - it's a previously filled input or, + * - it's a select input that was already "touched" by the user. + * @param {HTMLInputElement|HTMLSelectElement} input + * @param {'all' | SupportedMainTypes} dataType + * @returns {boolean} + **/ + shouldSkipInput(input, dataType) { + if (dataType === 'creditCards') { + // creditCards always override, even if the input is filled + return false; + } + const isPreviouslyFilledInput = input.value !== '' && this.activeInput !== input; + // if the input select type, then skip if it was previously touched + // otherwise, skip if it was previously filled + return input.nodeName === 'SELECT' ? this.touched.has(input) : isPreviouslyFilledInput; + } autofillInput(input, string, dataType) { // Do not autofill if it's invisible (select elements can be hidden because of custom implementations) if (input instanceof HTMLInputElement && !(0, _autofillUtils.isPotentiallyViewable)(input)) return; // Do not autofill if it's disabled or readonly to avoid potential breakage if (!(0, _inputTypeConfig.canBeInteractedWith)(input)) return; - - // Don't override values the user provided, unless it's the focused input or we're autofilling creditCards - if (dataType !== 'creditCards' && - // creditCards always override, the others only when we're focusing the input - input.nodeName !== 'SELECT' && input.value !== '' && - // if the input is not empty - this.activeInput !== input // and this is not the active input - ) return; // do not overwrite the value + if (this.shouldSkipInput(input, dataType)) return; // If the value is already there, just return if (input.value === string) return; @@ -6550,9 +6578,13 @@ class FormAnalyzer { }); } evaluateUrl() { - const path = window.location.pathname; - const matchesLogin = (0, _autofillUtils.safeRegexTest)(this.matching.getDDGMatcherRegex('loginRegex'), path); - const matchesSignup = (0, _autofillUtils.safeRegexTest)(this.matching.getDDGMatcherRegex('conservativeSignupRegex'), path); + const { + pathname, + hash + } = window.location; + const pathToMatch = pathname + hash; + const matchesLogin = (0, _autofillUtils.safeRegexTest)(this.matching.getDDGMatcherRegex('loginRegex'), pathToMatch); + const matchesSignup = (0, _autofillUtils.safeRegexTest)(this.matching.getDDGMatcherRegex('conservativeSignupRegex'), pathToMatch); // If the url matches both, do nothing: the signal is probably confounding if (matchesLogin && matchesSignup) return; @@ -6679,7 +6711,8 @@ class FormAnalyzer { this.evaluateElAttributes(this.form); // Check form contents (noisy elements are skipped with the safeUniversalSelector) - const formElements = this.form.querySelectorAll(this.matching.cssSelector('safeUniversalSelector')); + const selector = this.matching.cssSelector('safeUniversalSelector'); + const formElements = (0, _autofillUtils.findEnclosedElements)(this.form, selector); for (let i = 0; i < formElements.length; i++) { // Safety cutoff to avoid huge DOMs freezing the browser if (i >= 200) break; @@ -9992,7 +10025,7 @@ const { * findEligibleInputs(context): Scanner; * matching: import("./Form/matching").Matching; * options: ScannerOptions; - * stopScanner: (reason: string, ...rest: any) => void; + * setMode: (mode: Mode, reason: string, ...rest: any) => void; * }} Scanner * * @typedef {{ @@ -10003,6 +10036,8 @@ const { * maxFormsPerPage: number, * maxInputsPerForm: number * }} ScannerOptions + * + * @typedef {'scanning'|'on-click'|'stopped'} Mode */ /** @type {ScannerOptions} */ @@ -10041,8 +10076,8 @@ class DefaultScanner { activeInput = null; /** @type {boolean} A flag to indicate the whole page will be re-scanned */ rescanAll = false; - /** @type {boolean} Indicates whether we called stopScanning */ - stopped = false; + /** @type {Mode} Indicates the mode in which the scanner is operating */ + mode = 'scanning'; /** @type {import("./Form/matching").Matching} matching */ matching; @@ -10099,7 +10134,7 @@ class DefaultScanner { for (var _len = arguments.length, rest = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { rest[_key - 1] = arguments[_key]; } - _this.stopScanner(reason, ...rest); + _this.setMode('stopped', reason, ...rest); }; } @@ -10128,45 +10163,65 @@ class DefaultScanner { if ('matches' in context && context.matches?.(this.matching.cssSelector('formInputsSelectorWithoutSelect'))) { this.addInput(context); } else { - const inputs = context.querySelectorAll(this.matching.cssSelector('formInputsSelectorWithoutSelect')); + const selector = this.matching.cssSelector('formInputsSelectorWithoutSelect'); + const inputs = context.querySelectorAll(selector); if (inputs.length > this.options.maxInputsPerPage) { - this.stopScanner(`Too many input fields in the given context (${inputs.length}), stop scanning`, context); + this.setMode('stopped', `Too many input fields in the given context (${inputs.length}), stop scanning`, context); return this; } inputs.forEach(input => this.addInput(input)); + if (context instanceof HTMLFormElement && this.forms.get(context)?.hasShadowTree) { + const selector = this.matching.cssSelector('formInputsSelectorWithoutSelect'); + (0, _autofillUtils.findEnclosedElements)(context, selector).forEach(input => { + if (input instanceof HTMLInputElement) { + this.addInput(input, context); + } + }); + } } return this; } /** - * Stops scanning, switches off the mutation observer and clears all forms + * Sets the scanner mode, logging the reason and any additional arguments. + * 'stopped', switches off the mutation observer and clears all forms and listeners, + * 'on-click', keeps event listeners so that scanning can continue on clicking, + * 'scanning', default operation triggered in normal conditions + * Keep the listener for pointerdown to scan on click if needed. + * @param {Mode} mode * @param {string} reason * @param {any} rest */ - stopScanner(reason) { - this.stopped = true; + setMode(mode, reason) { + this.mode = mode; if ((0, _autofillUtils.shouldLog)()) { - for (var _len2 = arguments.length, rest = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { - rest[_key2 - 1] = arguments[_key2]; + for (var _len2 = arguments.length, rest = new Array(_len2 > 2 ? _len2 - 2 : 0), _key2 = 2; _key2 < _len2; _key2++) { + rest[_key2 - 2] = arguments[_key2]; } - console.log(reason, ...rest); + console.log(mode, reason, ...rest); + } + if (mode === 'scanning') return; + if (mode === 'stopped') { + window.removeEventListener('pointerdown', this, true); + window.removeEventListener('focus', this, true); } - const activeInput = this.device.activeForm?.activeInput; // remove Dax, listeners, timers, and observers clearTimeout(this.debounceTimer); this.changedElements.clear(); this.mutObs.disconnect(); - window.removeEventListener('pointerdown', this, true); - window.removeEventListener('focus', this, true); this.forms.forEach(form => { form.destroy(); }); this.forms.clear(); // Bring the user back to the input they were interacting with + const activeInput = this.device.activeForm?.activeInput; activeInput?.focus(); } + get isStopped() { + return this.mode === 'stopped'; + } /** * @param {HTMLElement|HTMLInputElement|HTMLSelectElement} input @@ -10192,20 +10247,32 @@ class DefaultScanner { let traversalLayerCount = 0; let element = input; // traverse the DOM to search for related inputs - while (traversalLayerCount <= 5 && element.parentElement && element.parentElement !== document.documentElement) { + while (traversalLayerCount <= 5 && element.parentElement !== document.documentElement) { // Avoid overlapping containers or forms const siblingForm = element.parentElement?.querySelector('form'); if (siblingForm && siblingForm !== element) { return element; } - element = element.parentElement; - const inputs = element.querySelectorAll(this.matching.cssSelector('formInputsSelector')); - const buttons = element.querySelectorAll(this.matching.cssSelector('submitButtonSelector')); - // If we find a button or another input, we assume that's our form - if (inputs.length > 1 || buttons.length) { - // found related input, return common ancestor + if (element instanceof HTMLFormElement) { return element; } + if (element.parentElement) { + element = element.parentElement; + const inputs = element.querySelectorAll(this.matching.cssSelector('formInputsSelector')); + const buttons = element.querySelectorAll(this.matching.cssSelector('submitButtonSelector')); + // If we find a button or another input, we assume that's our form + if (inputs.length > 1 || buttons.length) { + // found related input, return common ancestor + return element; + } + } else { + // possibly a shadow boundary, so traverse through the shadow root and find the form + const root = element.getRootNode(); + if (root instanceof ShadowRoot && root.host) { + // @ts-ignore + element = root.host; + } + } traversalLayerCount++; } return input; @@ -10213,17 +10280,19 @@ class DefaultScanner { /** * @param {HTMLInputElement|HTMLSelectElement} input + * @param {HTMLFormElement|null} form */ addInput(input) { - if (this.stopped) return; - const parentForm = this.getParentForm(input); + let form = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + if (this.isStopped) return; + const parentForm = form || this.getParentForm(input); if (parentForm instanceof HTMLFormElement && this.forms.has(parentForm)) { const foundForm = this.forms.get(parentForm); // We've met the form, add the input provided it's below the max input limit if (foundForm && foundForm.inputs.all.size < MAX_INPUTS_PER_FORM) { foundForm.addInput(input); } else { - this.stopScanner('The form has too many inputs, destroying.'); + this.setMode('stopped', 'The form has too many inputs, destroying.'); } return; } @@ -10267,8 +10336,9 @@ class DefaultScanner { // Only add the form if below the limit of forms per page if (this.forms.size < this.options.maxFormsPerPage) { this.forms.set(parentForm, new _Form.Form(parentForm, input, this.device, this.matching, this.shouldAutoprompt)); + // Also only add the form if it hasn't self-destructed due to having too few fields } else { - this.stopScanner('The page has too many forms, stop adding them.'); + this.setMode('on-click', 'The page has too many forms, stop adding them.'); } } } @@ -10344,7 +10414,7 @@ class DefaultScanner { switch (event.type) { case 'pointerdown': case 'focus': - this.scanShadow(event); + this.scanOnClick(event); break; } } @@ -10353,15 +10423,24 @@ class DefaultScanner { * Scan clicked input fields, even if they're within a shadow tree * @param {FocusEvent | PointerEvent} event */ - scanShadow(event) { - // If the scanner is stopped or there's no shadow root, just return - if (this.stopped || !(event.target instanceof Element) || !event.target?.shadowRoot) return; + scanOnClick(event) { + // If the scanner is stopped, just return + if (this.isStopped || !(event.target instanceof Element)) return; window.performance?.mark?.('scan_shadow:init:start'); + + // If the target is an input, find the real target in case it's in a shadow tree const realTarget = (0, _autofillUtils.pierceShadowTree)(event, HTMLInputElement); - // If it's an input we haven't already scanned, scan the whole shadow tree + // If it's an input we haven't already scanned, + // find the enclosing parent form, and scan it. if (realTarget instanceof HTMLInputElement && !realTarget.hasAttribute(ATTR_INPUT_TYPE)) { - this.findEligibleInputs(realTarget.getRootNode()); + const parentForm = this.getParentForm(realTarget); + if (parentForm && parentForm instanceof HTMLFormElement) { + const hasShadowTree = event.target?.shadowRoot != null; + const form = new _Form.Form(parentForm, realTarget, this.device, this.matching, this.shouldAutoprompt, hasShadowTree); + this.forms.set(parentForm, form); + this.findEligibleInputs(parentForm); + } } window.performance?.mark?.('scan_shadow:init:end'); (0, _autofillUtils.logPerformance)('scan_shadow'); @@ -12430,6 +12509,7 @@ Object.defineProperty(exports, "__esModule", { }); exports.buttonMatchesFormType = exports.autofillEnabled = exports.addInlineStyles = exports.SIGN_IN_MSG = exports.ADDRESS_DOMAIN = void 0; exports.escapeXML = escapeXML; +exports.findEnclosedElements = findEnclosedElements; exports.formatDuckAddress = void 0; exports.getActiveElement = getActiveElement; exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = exports.getDaxBoundingBox = void 0; @@ -13041,6 +13121,32 @@ function getActiveElement() { return innerActiveElement; } +/** + * Takes a root element and tries to find visible elements first, and if it fails, it tries to find shadow elements + * @param {HTMLElement|HTMLFormElement} root + * @param {string} selector + * @returns {Element[]} + */ +function findEnclosedElements(root, selector) { + // Check if there are any normal elements that match the selector + const elements = root.querySelectorAll(selector); + if (elements.length > 0) { + return Array.from(elements); + } + + // Check if there are any shadow elements that match the selector + const shadowElements = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let node = walker.nextNode(); + while (node) { + if (node instanceof HTMLElement && node.shadowRoot) { + shadowElements.push(...node.shadowRoot.querySelectorAll(selector)); + } + node = walker.nextNode(); + } + return shadowElements; +} + },{"./Form/matching.js":33,"./constants.js":55,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],53:[function(require,module,exports){ "use strict"; diff --git a/node_modules/@duckduckgo/autofill/dist/shared-credentials.json b/node_modules/@duckduckgo/autofill/dist/shared-credentials.json index a66b5842665e..d3086faf6629 100644 --- a/node_modules/@duckduckgo/autofill/dist/shared-credentials.json +++ b/node_modules/@duckduckgo/autofill/dist/shared-credentials.json @@ -150,6 +150,15 @@ "discord.store" ] }, + { + "from": [ + "discovercard.com" + ], + "to": [ + "discover.com" + ], + "fromDomainsAreObsoleted": true + }, { "shared": [ "disney.com", @@ -161,6 +170,12 @@ "shopdisney.com" ] }, + { + "shared": [ + "dnt.abine.com", + "ironvest.com" + ] + }, { "shared": [ "ebay.at", @@ -219,6 +234,15 @@ "eventbrite.sg" ] }, + { + "from": [ + "fancourier.ro" + ], + "to": [ + "selfawb.ro" + ], + "fromDomainsAreObsoleted": true + }, { "from": [ "flyblade.com" @@ -228,6 +252,16 @@ ], "fromDomainsAreObsoleted": true }, + { + "from": [ + "gazduire.com.ro", + "gazduire.net" + ], + "to": [ + "admin.ro" + ], + "fromDomainsAreObsoleted": true + }, { "from": [ "hbo.com", @@ -359,6 +393,15 @@ "simplifimoney.com" ] }, + { + "from": [ + "raywenderlich.com" + ], + "to": [ + "kodeco.com" + ], + "fromDomainsAreObsoleted": true + }, { "shared": [ "redis.com", @@ -380,6 +423,12 @@ ], "fromDomainsAreObsoleted": true }, + { + "shared": [ + "steampowered.com", + "steamcommunity.com" + ] + }, { "shared": [ "taxhawk.com", @@ -387,6 +436,12 @@ "express1040.com" ] }, + { + "shared": [ + "ting.com", + "tingmobile.com" + ] + }, { "from": [ "transferwise.com" @@ -424,6 +479,15 @@ "ussailing.org" ] }, + { + "from": [ + "wacom.eu" + ], + "to": [ + "wacom.com" + ], + "fromDomainsAreObsoleted": true + }, { "shared": [ "wikipedia.org", diff --git a/package-lock.json b/package-lock.json index 13475d592dc8..f202fadc9a63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@duckduckgo/autoconsent": "^10.13.0", - "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#12.0.1", + "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#12.1.0", "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.5.0", "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#3.5.0", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1721137609" @@ -68,8 +68,9 @@ } }, "node_modules/@duckduckgo/autofill": { - "resolved": "git+ssh://git@github.com/duckduckgo/duckduckgo-autofill.git#2b81745565db09eee8c1cd44d38eefa1011a9f0a", - "hasInstallScript": true + "resolved": "git+ssh://git@github.com/duckduckgo/duckduckgo-autofill.git#9fea1c6762db726328b14bb9ebfd6508849eae28", + "hasInstallScript": true, + "license": "Apache-2.0" }, "node_modules/@duckduckgo/content-scope-scripts": { "resolved": "git+ssh://git@github.com/duckduckgo/content-scope-scripts.git#ebeeaea0889d6c61d1424e70d6e30f9befb10d1f", diff --git a/package.json b/package.json index f4c8f8fc841e..69b5a300f36b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@duckduckgo/autoconsent": "^10.13.0", - "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#12.0.1", + "@duckduckgo/autofill": "github:duckduckgo/duckduckgo-autofill#12.1.0", "@duckduckgo/content-scope-scripts": "github:duckduckgo/content-scope-scripts#6.5.0", "@duckduckgo/privacy-dashboard": "github:duckduckgo/privacy-dashboard#3.5.0", "@duckduckgo/privacy-reference-tests": "github:duckduckgo/privacy-reference-tests#1721137609"