From 6f030e60723b9e3dba7c7c5431c1d06500e2e91e Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Fri, 22 Nov 2024 16:39:17 +0100 Subject: [PATCH] [Form] Always scan shadow elements when categorizing the form inputs (#703) * fix: scan shadow even if enclosing form is not a form instance when categorizing * fix: create array from result elements list of queryselector * fix: check for custom web elements * style: rename function * feat: optimise traversal and scoring * chore: PR comments * fix: check if parent for is the same as the real target * refactor: call findElementsInShadowTree in foundElements * refactor: use one getFormElements for all queries * refactor: better composition * fix: break traversal if no shadow root or parent element * refactor: move and rename --- dist/autofill-debug.js | 144 ++++++++++++------ dist/autofill.js | 144 ++++++++++++------ src/Form/Form.js | 28 ++-- src/Form/FormAnalyzer.js | 42 +++-- src/Scanner.js | 53 ++++--- src/autofill-utils.js | 56 +++++-- .../Resources/assets/autofill-debug.js | 144 ++++++++++++------ swift-package/Resources/assets/autofill.js | 144 ++++++++++++------ 8 files changed, 501 insertions(+), 254 deletions(-) diff --git a/dist/autofill-debug.js b/dist/autofill-debug.js index af970de66..06004d529 100644 --- a/dist/autofill-debug.js +++ b/dist/autofill-debug.js @@ -10217,20 +10217,10 @@ class Form { if (this.form.matches(selector)) { this.addInput(this.form); } else { - /** @type {Element[] | NodeList} */ - let foundInputs = []; - // Some sites seem to be overriding `form.elements`, so we need to check if it's still iterable. - if (this.form instanceof HTMLFormElement && this.form.elements != null && Symbol.iterator in Object(this.form.elements)) { - // 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. - const formElements = [...this.form.elements].filter(el => el.matches(selector)); - // If there are no 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); - } + // Attempt to get form's control elements first as it can catch elements when markup is broke, or if the fields are outside the form. + // Other wise use queryElementsWithShadow, that can scan for shadow tree. + const formControlElements = (0, _autofillUtils.getFormControlElements)(this.form, selector); + const foundInputs = formControlElements != null ? [...formControlElements, ...(0, _autofillUtils.findElementsInShadowTree)(this.form, selector)] : (0, _autofillUtils.queryElementsWithShadow)(this.form, selector, true); if (foundInputs.length < MAX_INPUTS_PER_FORM) { foundInputs.forEach(input => this.addInput(input)); } else { @@ -10291,7 +10281,7 @@ class Form { } get submitButtons() { const selector = this.matching.cssSelector('submitButtonSelector'); - const allButtons = /** @type {HTMLElement[]} */(0, _autofillUtils.findEnclosedElements)(this.form, selector); + const allButtons = /** @type {HTMLElement[]} */(0, _autofillUtils.queryElementsWithShadow)(this.form, selector); return allButtons.filter(btn => (0, _autofillUtils.isPotentiallyViewable)(btn) && (0, _autofillUtils.isLikelyASubmitButton)(btn, this.matching) && (0, _autofillUtils.buttonMatchesFormType)(btn, this)); } attemptSubmissionIfNeeded() { @@ -10993,6 +10983,22 @@ class FormAnalyzer { } }); } + + /** + * Function that checks if the element is an external link or a custom web element that + * encapsulates a link. + * @param {any} el + * @returns {boolean} + */ + isElementExternalLink(el) { + // Checks if the element is present in the cusotm elements registry and ends with a '-link' suffix. + // If it does, it checks if it contains an anchor element inside. + const tagName = el.nodeName.toLowerCase(); + const isCustomWebElementLink = customElements?.get(tagName) != null && /-link$/.test(tagName) && (0, _autofillUtils.findElementsInShadowTree)(el, 'a').length > 0; + + // if an external link matches one of the regexes, we assume the match is not pertinent to the current form + return el instanceof HTMLAnchorElement && el.href && el.getAttribute('href') !== '#' || (el.getAttribute('role') || '').toUpperCase() === 'LINK' || el.matches('button[class*=secondary]') || isCustomWebElementLink; + } evaluateElement(el) { const string = (0, _autofillUtils.getTextShallow)(el); if (el.matches(this.matching.cssSelector('password'))) { @@ -11032,8 +11038,7 @@ class FormAnalyzer { }); return; } - // if an external link matches one of the regexes, we assume the match is not pertinent to the current form - if (el instanceof HTMLAnchorElement && el.href && el.getAttribute('href') !== '#' || (el.getAttribute('role') || '').toUpperCase() === 'LINK' || el.matches('button[class*=secondary]')) { + if (this.isElementExternalLink(el)) { let shouldFlip = true; let strength = 1; // Don't flip forgotten password links @@ -11052,9 +11057,10 @@ class FormAnalyzer { }); } else { // any other case + const isH1Element = el.tagName === 'H1'; this.updateSignal({ string, - strength: 1, + strength: isH1Element ? 3 : 1, signalType: `generic: ${string}`, shouldCheckUnifiedForm: true }); @@ -11072,7 +11078,7 @@ class FormAnalyzer { // Check form contents (noisy elements are skipped with the safeUniversalSelector) const selector = this.matching.cssSelector('safeUniversalSelector'); - const formElements = (0, _autofillUtils.findEnclosedElements)(this.form, selector); + const formElements = (0, _autofillUtils.queryElementsWithShadow)(this.form, selector); for (let i = 0; i < formElements.length; i++) { // Safety cutoff to avoid huge DOMs freezing the browser if (i >= 200) break; @@ -14522,19 +14528,18 @@ class DefaultScanner { if (this.device.globalConfig.isDDGDomain) { return this; } - if ('matches' in context && context.matches?.(this.matching.cssSelector('formInputsSelectorWithoutSelect'))) { + const formInputsSelectorWithoutSelect = this.matching.cssSelector('formInputsSelectorWithoutSelect'); + if ('matches' in context && context.matches?.(formInputsSelectorWithoutSelect)) { this.addInput(context); } else { - const selector = this.matching.cssSelector('formInputsSelectorWithoutSelect'); - const inputs = context.querySelectorAll(selector); + const inputs = context.querySelectorAll(formInputsSelectorWithoutSelect); if (inputs.length > this.options.maxInputsPerPage) { 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 => { + (0, _autofillUtils.findElementsInShadowTree)(context, formInputsSelectorWithoutSelect).forEach(input => { if (input instanceof HTMLInputElement) { this.addInput(input, context); } @@ -14620,12 +14625,16 @@ class DefaultScanner { } 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; + // If the parent is a redundant component (only contains a single element or is a shadowRoot) do not increase the traversal count. + if (element.childElementCount > 1) { + 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; + } + traversalLayerCount++; } } else { // possibly a shadow boundary, so traverse through the shadow root and find the form @@ -14633,9 +14642,11 @@ class DefaultScanner { if (root instanceof ShadowRoot && root.host) { // @ts-ignore element = root.host; + } else { + // We're in a strange state (no parent or shadow root), just break out of the loop for safety + break; } } - traversalLayerCount++; } return input; } @@ -14797,12 +14808,13 @@ class DefaultScanner { // find the enclosing parent form, and scan it. if (realTarget instanceof HTMLInputElement && !realTarget.hasAttribute(ATTR_INPUT_TYPE)) { 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); - } + + // If the parent form is an input element we bail. + if (parentForm instanceof HTMLInputElement) return; + 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'); @@ -16939,10 +16951,12 @@ 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.findElementsInShadowTree = findElementsInShadowTree; exports.formatDuckAddress = void 0; exports.getActiveElement = getActiveElement; -exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = exports.getDaxBoundingBox = void 0; +exports.getDaxBoundingBox = void 0; +exports.getFormControlElements = getFormControlElements; +exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = void 0; exports.isFormLikelyToBeUsedAsPageWrapper = isFormLikelyToBeUsedAsPageWrapper; exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedConfig = void 0; exports.isLocalNetwork = isLocalNetwork; @@ -16951,6 +16965,7 @@ exports.isValidTLD = isValidTLD; exports.logPerformance = logPerformance; exports.notifyWebApp = void 0; exports.pierceShadowTree = pierceShadowTree; +exports.queryElementsWithShadow = queryElementsWithShadow; exports.safeExecute = exports.removeInlineStyles = void 0; exports.safeRegexTest = safeRegexTest; exports.setValue = exports.sendAndWaitForAnswer = void 0; @@ -17553,22 +17568,16 @@ function getActiveElement() { } /** - * Takes a root element and tries to find visible elements first, and if it fails, it tries to find shadow elements + * Takes a root element and tries to find elements in shadow DOMs that match the selector * @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 +function findElementsInShadowTree(root, selector) { const shadowElements = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - let node = walker.nextNode(); + /** @type {Node|null} */ + let node = walker.currentNode; while (node) { if (node instanceof HTMLElement && node.shadowRoot) { shadowElements.push(...node.shadowRoot.querySelectorAll(selector)); @@ -17578,6 +17587,43 @@ function findEnclosedElements(root, selector) { return shadowElements; } +/** + * The function looks for form's control elements, and returns them if they're iterable. + * @param {HTMLElement} form + * @param {string} selector + * @returns {Element[]|null} + */ +function getFormControlElements(form, selector) { + // Some sites seem to be overriding `form.elements`, so we need to check if it's still iterable. + if (form instanceof HTMLFormElement && form.elements != null && Symbol.iterator in Object(form.elements)) { + // 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 specific types of elements. + const formControls = [...form.elements].filter(el => el.matches(selector)); + return [...formControls]; + } else { + return null; + } +} + +/** + * Default operation: finds elements using querySelectorAll. + * Optionally, can be forced to scan the shadow tree. + * @param {HTMLElement} element + * @param {string} selector + * @param {boolean} forceScanShadowTree + * @returns {Element[]} + */ +function queryElementsWithShadow(element, selector) { + let forceScanShadowTree = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + /** @type {Element[]|NodeListOf} element */ + const elements = element.querySelectorAll(selector); + if (forceScanShadowTree || elements.length === 0) { + return [...elements, ...findElementsInShadowTree(element, selector)]; + } + return [...elements]; +} + },{"./Form/matching.js":44,"./constants.js":67,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],65:[function(require,module,exports){ "use strict"; diff --git a/dist/autofill.js b/dist/autofill.js index 924a7d614..b3e874725 100644 --- a/dist/autofill.js +++ b/dist/autofill.js @@ -6051,20 +6051,10 @@ class Form { if (this.form.matches(selector)) { this.addInput(this.form); } else { - /** @type {Element[] | NodeList} */ - let foundInputs = []; - // Some sites seem to be overriding `form.elements`, so we need to check if it's still iterable. - if (this.form instanceof HTMLFormElement && this.form.elements != null && Symbol.iterator in Object(this.form.elements)) { - // 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. - const formElements = [...this.form.elements].filter(el => el.matches(selector)); - // If there are no 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); - } + // Attempt to get form's control elements first as it can catch elements when markup is broke, or if the fields are outside the form. + // Other wise use queryElementsWithShadow, that can scan for shadow tree. + const formControlElements = (0, _autofillUtils.getFormControlElements)(this.form, selector); + const foundInputs = formControlElements != null ? [...formControlElements, ...(0, _autofillUtils.findElementsInShadowTree)(this.form, selector)] : (0, _autofillUtils.queryElementsWithShadow)(this.form, selector, true); if (foundInputs.length < MAX_INPUTS_PER_FORM) { foundInputs.forEach(input => this.addInput(input)); } else { @@ -6125,7 +6115,7 @@ class Form { } get submitButtons() { const selector = this.matching.cssSelector('submitButtonSelector'); - const allButtons = /** @type {HTMLElement[]} */(0, _autofillUtils.findEnclosedElements)(this.form, selector); + const allButtons = /** @type {HTMLElement[]} */(0, _autofillUtils.queryElementsWithShadow)(this.form, selector); return allButtons.filter(btn => (0, _autofillUtils.isPotentiallyViewable)(btn) && (0, _autofillUtils.isLikelyASubmitButton)(btn, this.matching) && (0, _autofillUtils.buttonMatchesFormType)(btn, this)); } attemptSubmissionIfNeeded() { @@ -6827,6 +6817,22 @@ class FormAnalyzer { } }); } + + /** + * Function that checks if the element is an external link or a custom web element that + * encapsulates a link. + * @param {any} el + * @returns {boolean} + */ + isElementExternalLink(el) { + // Checks if the element is present in the cusotm elements registry and ends with a '-link' suffix. + // If it does, it checks if it contains an anchor element inside. + const tagName = el.nodeName.toLowerCase(); + const isCustomWebElementLink = customElements?.get(tagName) != null && /-link$/.test(tagName) && (0, _autofillUtils.findElementsInShadowTree)(el, 'a').length > 0; + + // if an external link matches one of the regexes, we assume the match is not pertinent to the current form + return el instanceof HTMLAnchorElement && el.href && el.getAttribute('href') !== '#' || (el.getAttribute('role') || '').toUpperCase() === 'LINK' || el.matches('button[class*=secondary]') || isCustomWebElementLink; + } evaluateElement(el) { const string = (0, _autofillUtils.getTextShallow)(el); if (el.matches(this.matching.cssSelector('password'))) { @@ -6866,8 +6872,7 @@ class FormAnalyzer { }); return; } - // if an external link matches one of the regexes, we assume the match is not pertinent to the current form - if (el instanceof HTMLAnchorElement && el.href && el.getAttribute('href') !== '#' || (el.getAttribute('role') || '').toUpperCase() === 'LINK' || el.matches('button[class*=secondary]')) { + if (this.isElementExternalLink(el)) { let shouldFlip = true; let strength = 1; // Don't flip forgotten password links @@ -6886,9 +6891,10 @@ class FormAnalyzer { }); } else { // any other case + const isH1Element = el.tagName === 'H1'; this.updateSignal({ string, - strength: 1, + strength: isH1Element ? 3 : 1, signalType: `generic: ${string}`, shouldCheckUnifiedForm: true }); @@ -6906,7 +6912,7 @@ class FormAnalyzer { // Check form contents (noisy elements are skipped with the safeUniversalSelector) const selector = this.matching.cssSelector('safeUniversalSelector'); - const formElements = (0, _autofillUtils.findEnclosedElements)(this.form, selector); + const formElements = (0, _autofillUtils.queryElementsWithShadow)(this.form, selector); for (let i = 0; i < formElements.length; i++) { // Safety cutoff to avoid huge DOMs freezing the browser if (i >= 200) break; @@ -10356,19 +10362,18 @@ class DefaultScanner { if (this.device.globalConfig.isDDGDomain) { return this; } - if ('matches' in context && context.matches?.(this.matching.cssSelector('formInputsSelectorWithoutSelect'))) { + const formInputsSelectorWithoutSelect = this.matching.cssSelector('formInputsSelectorWithoutSelect'); + if ('matches' in context && context.matches?.(formInputsSelectorWithoutSelect)) { this.addInput(context); } else { - const selector = this.matching.cssSelector('formInputsSelectorWithoutSelect'); - const inputs = context.querySelectorAll(selector); + const inputs = context.querySelectorAll(formInputsSelectorWithoutSelect); if (inputs.length > this.options.maxInputsPerPage) { 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 => { + (0, _autofillUtils.findElementsInShadowTree)(context, formInputsSelectorWithoutSelect).forEach(input => { if (input instanceof HTMLInputElement) { this.addInput(input, context); } @@ -10454,12 +10459,16 @@ class DefaultScanner { } 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; + // If the parent is a redundant component (only contains a single element or is a shadowRoot) do not increase the traversal count. + if (element.childElementCount > 1) { + 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; + } + traversalLayerCount++; } } else { // possibly a shadow boundary, so traverse through the shadow root and find the form @@ -10467,9 +10476,11 @@ class DefaultScanner { if (root instanceof ShadowRoot && root.host) { // @ts-ignore element = root.host; + } else { + // We're in a strange state (no parent or shadow root), just break out of the loop for safety + break; } } - traversalLayerCount++; } return input; } @@ -10631,12 +10642,13 @@ class DefaultScanner { // find the enclosing parent form, and scan it. if (realTarget instanceof HTMLInputElement && !realTarget.hasAttribute(ATTR_INPUT_TYPE)) { 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); - } + + // If the parent form is an input element we bail. + if (parentForm instanceof HTMLInputElement) return; + 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'); @@ -12773,10 +12785,12 @@ 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.findElementsInShadowTree = findElementsInShadowTree; exports.formatDuckAddress = void 0; exports.getActiveElement = getActiveElement; -exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = exports.getDaxBoundingBox = void 0; +exports.getDaxBoundingBox = void 0; +exports.getFormControlElements = getFormControlElements; +exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = void 0; exports.isFormLikelyToBeUsedAsPageWrapper = isFormLikelyToBeUsedAsPageWrapper; exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedConfig = void 0; exports.isLocalNetwork = isLocalNetwork; @@ -12785,6 +12799,7 @@ exports.isValidTLD = isValidTLD; exports.logPerformance = logPerformance; exports.notifyWebApp = void 0; exports.pierceShadowTree = pierceShadowTree; +exports.queryElementsWithShadow = queryElementsWithShadow; exports.safeExecute = exports.removeInlineStyles = void 0; exports.safeRegexTest = safeRegexTest; exports.setValue = exports.sendAndWaitForAnswer = void 0; @@ -13387,22 +13402,16 @@ function getActiveElement() { } /** - * Takes a root element and tries to find visible elements first, and if it fails, it tries to find shadow elements + * Takes a root element and tries to find elements in shadow DOMs that match the selector * @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 +function findElementsInShadowTree(root, selector) { const shadowElements = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - let node = walker.nextNode(); + /** @type {Node|null} */ + let node = walker.currentNode; while (node) { if (node instanceof HTMLElement && node.shadowRoot) { shadowElements.push(...node.shadowRoot.querySelectorAll(selector)); @@ -13412,6 +13421,43 @@ function findEnclosedElements(root, selector) { return shadowElements; } +/** + * The function looks for form's control elements, and returns them if they're iterable. + * @param {HTMLElement} form + * @param {string} selector + * @returns {Element[]|null} + */ +function getFormControlElements(form, selector) { + // Some sites seem to be overriding `form.elements`, so we need to check if it's still iterable. + if (form instanceof HTMLFormElement && form.elements != null && Symbol.iterator in Object(form.elements)) { + // 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 specific types of elements. + const formControls = [...form.elements].filter(el => el.matches(selector)); + return [...formControls]; + } else { + return null; + } +} + +/** + * Default operation: finds elements using querySelectorAll. + * Optionally, can be forced to scan the shadow tree. + * @param {HTMLElement} element + * @param {string} selector + * @param {boolean} forceScanShadowTree + * @returns {Element[]} + */ +function queryElementsWithShadow(element, selector) { + let forceScanShadowTree = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + /** @type {Element[]|NodeListOf} element */ + const elements = element.querySelectorAll(selector); + if (forceScanShadowTree || elements.length === 0) { + return [...elements, ...findElementsInShadowTree(element, selector)]; + } + return [...elements]; +} + },{"./Form/matching.js":34,"./constants.js":57,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],55:[function(require,module,exports){ "use strict"; diff --git a/src/Form/Form.js b/src/Form/Form.js index fb20c6433..e5fcb46ad 100644 --- a/src/Form/Form.js +++ b/src/Form/Form.js @@ -14,7 +14,9 @@ import { shouldLog, safeRegexTest, getActiveElement, - findEnclosedElements, + queryElementsWithShadow, + findElementsInShadowTree, + getFormControlElements, } from '../autofill-utils.js'; import { getInputSubtype, getInputMainType, createMatching, getInputVariant } from './matching.js'; @@ -392,20 +394,13 @@ class Form { if (this.form.matches(selector)) { this.addInput(this.form); } else { - /** @type {Element[] | NodeList} */ - let foundInputs = []; - // Some sites seem to be overriding `form.elements`, so we need to check if it's still iterable. - if (this.form instanceof HTMLFormElement && this.form.elements != null && Symbol.iterator in Object(this.form.elements)) { - // 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. - const formElements = [...this.form.elements].filter((el) => el.matches(selector)); - // If there are no form elements, we try to look for all - // enclosed elements within the form. - foundInputs = formElements.length > 0 ? formElements : findEnclosedElements(this.form, selector); - } else { - foundInputs = this.form.querySelectorAll(selector); - } + // Attempt to get form's control elements first as it can catch elements when markup is broke, or if the fields are outside the form. + // Other wise use queryElementsWithShadow, that can scan for shadow tree. + const formControlElements = getFormControlElements(this.form, selector); + const foundInputs = + formControlElements != null + ? [...formControlElements, ...findElementsInShadowTree(this.form, selector)] + : queryElementsWithShadow(this.form, selector, true); if (foundInputs.length < MAX_INPUTS_PER_FORM) { foundInputs.forEach((input) => this.addInput(input)); @@ -475,8 +470,7 @@ class Form { get submitButtons() { const selector = this.matching.cssSelector('submitButtonSelector'); - const allButtons = /** @type {HTMLElement[]} */ (findEnclosedElements(this.form, selector)); - + const allButtons = /** @type {HTMLElement[]} */ (queryElementsWithShadow(this.form, selector)); return allButtons.filter( (btn) => isPotentiallyViewable(btn) && isLikelyASubmitButton(btn, this.matching) && buttonMatchesFormType(btn, this), ); diff --git a/src/Form/FormAnalyzer.js b/src/Form/FormAnalyzer.js index f3d085b65..7f1acebe6 100644 --- a/src/Form/FormAnalyzer.js +++ b/src/Form/FormAnalyzer.js @@ -1,7 +1,13 @@ import { removeExcessWhitespace, Matching } from './matching.js'; import { constants } from '../constants.js'; import { matchingConfiguration } from './matching-config/__generated__/compiled-matching-config.js'; -import { findEnclosedElements, getTextShallow, isLikelyASubmitButton, safeRegexTest } from '../autofill-utils.js'; +import { + findElementsInShadowTree, + queryElementsWithShadow, + getTextShallow, + isLikelyASubmitButton, + safeRegexTest, +} from '../autofill-utils.js'; class FormAnalyzer { /** @type HTMLElement */ @@ -223,6 +229,28 @@ class FormAnalyzer { }); } + /** + * Function that checks if the element is an external link or a custom web element that + * encapsulates a link. + * @param {any} el + * @returns {boolean} + */ + isElementExternalLink(el) { + // Checks if the element is present in the cusotm elements registry and ends with a '-link' suffix. + // If it does, it checks if it contains an anchor element inside. + const tagName = el.nodeName.toLowerCase(); + const isCustomWebElementLink = + customElements?.get(tagName) != null && /-link$/.test(tagName) && findElementsInShadowTree(el, 'a').length > 0; + + // if an external link matches one of the regexes, we assume the match is not pertinent to the current form + return ( + (el instanceof HTMLAnchorElement && el.href && el.getAttribute('href') !== '#') || + (el.getAttribute('role') || '').toUpperCase() === 'LINK' || + el.matches('button[class*=secondary]') || + isCustomWebElementLink + ); + } + evaluateElement(el) { const string = getTextShallow(el); @@ -258,12 +286,7 @@ class FormAnalyzer { this.updateSignal({ string, strength, signalType: `button: ${string}`, shouldFlip }); return; } - // if an external link matches one of the regexes, we assume the match is not pertinent to the current form - if ( - (el instanceof HTMLAnchorElement && el.href && el.getAttribute('href') !== '#') || - (el.getAttribute('role') || '').toUpperCase() === 'LINK' || - el.matches('button[class*=secondary]') - ) { + if (this.isElementExternalLink(el)) { let shouldFlip = true; let strength = 1; // Don't flip forgotten password links @@ -277,7 +300,8 @@ class FormAnalyzer { this.updateSignal({ string, strength, signalType: `external link: ${string}`, shouldFlip }); } else { // any other case - this.updateSignal({ string, strength: 1, signalType: `generic: ${string}`, shouldCheckUnifiedForm: true }); + const isH1Element = el.tagName === 'H1'; + this.updateSignal({ string, strength: isH1Element ? 3 : 1, signalType: `generic: ${string}`, shouldCheckUnifiedForm: true }); } } @@ -293,7 +317,7 @@ class FormAnalyzer { // Check form contents (noisy elements are skipped with the safeUniversalSelector) const selector = this.matching.cssSelector('safeUniversalSelector'); - const formElements = findEnclosedElements(this.form, selector); + const formElements = queryElementsWithShadow(this.form, selector); for (let i = 0; i < formElements.length; i++) { // Safety cutoff to avoid huge DOMs freezing the browser if (i >= 200) break; diff --git a/src/Scanner.js b/src/Scanner.js index c0ebe3fb9..6d096dce2 100644 --- a/src/Scanner.js +++ b/src/Scanner.js @@ -1,7 +1,13 @@ import { Form } from './Form/Form.js'; import { constants } from './constants.js'; import { createMatching } from './Form/matching.js'; -import { logPerformance, isFormLikelyToBeUsedAsPageWrapper, shouldLog, pierceShadowTree, findEnclosedElements } from './autofill-utils.js'; +import { + logPerformance, + isFormLikelyToBeUsedAsPageWrapper, + shouldLog, + pierceShadowTree, + findElementsInShadowTree, +} from './autofill-utils.js'; import { AddDebugFlagCall } from './deviceApiCalls/__generated__/deviceApiCalls.js'; const { MAX_INPUTS_PER_PAGE, MAX_FORMS_PER_PAGE, MAX_INPUTS_PER_FORM, ATTR_INPUT_TYPE } = constants; @@ -143,19 +149,19 @@ class DefaultScanner { return this; } - if ('matches' in context && context.matches?.(this.matching.cssSelector('formInputsSelectorWithoutSelect'))) { + const formInputsSelectorWithoutSelect = this.matching.cssSelector('formInputsSelectorWithoutSelect'); + + if ('matches' in context && context.matches?.(formInputsSelectorWithoutSelect)) { this.addInput(context); } else { - const selector = this.matching.cssSelector('formInputsSelectorWithoutSelect'); - const inputs = context.querySelectorAll(selector); + const inputs = context.querySelectorAll(formInputsSelectorWithoutSelect); if (inputs.length > this.options.maxInputsPerPage) { 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'); - findEnclosedElements(context, selector).forEach((input) => { + findElementsInShadowTree(context, formInputsSelectorWithoutSelect).forEach((input) => { if (input instanceof HTMLInputElement) { this.addInput(input, context); } @@ -246,12 +252,16 @@ class DefaultScanner { 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; + // If the parent is a redundant component (only contains a single element or is a shadowRoot) do not increase the traversal count. + if (element.childElementCount > 1) { + 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; + } + traversalLayerCount++; } } else { // possibly a shadow boundary, so traverse through the shadow root and find the form @@ -259,10 +269,11 @@ class DefaultScanner { if (root instanceof ShadowRoot && root.host) { // @ts-ignore element = root.host; + } else { + // We're in a strange state (no parent or shadow root), just break out of the loop for safety + break; } } - - traversalLayerCount++; } return input; @@ -430,12 +441,14 @@ class DefaultScanner { // find the enclosing parent form, and scan it. if (realTarget instanceof HTMLInputElement && !realTarget.hasAttribute(ATTR_INPUT_TYPE)) { const parentForm = this.getParentForm(realTarget); - if (parentForm && parentForm instanceof HTMLFormElement) { - const hasShadowTree = event.target?.shadowRoot != null; - const form = new Form(parentForm, realTarget, this.device, this.matching, this.shouldAutoprompt, hasShadowTree); - this.forms.set(parentForm, form); - this.findEligibleInputs(parentForm); - } + + // If the parent form is an input element we bail. + if (parentForm instanceof HTMLInputElement) return; + + const hasShadowTree = event.target?.shadowRoot != null; + const form = new 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'); diff --git a/src/autofill-utils.js b/src/autofill-utils.js index 5a4984052..96eba5b48 100644 --- a/src/autofill-utils.js +++ b/src/autofill-utils.js @@ -567,23 +567,16 @@ function getActiveElement(root = document) { } /** - * Takes a root element and tries to find visible elements first, and if it fails, it tries to find shadow elements + * Takes a root element and tries to find elements in shadow DOMs that match the selector * @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 +function findElementsInShadowTree(root, selector) { const shadowElements = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - - let node = walker.nextNode(); + /** @type {Node|null} */ + let node = walker.currentNode; while (node) { if (node instanceof HTMLElement && node.shadowRoot) { shadowElements.push(...node.shadowRoot.querySelectorAll(selector)); @@ -594,6 +587,43 @@ function findEnclosedElements(root, selector) { return shadowElements; } +/** + * The function looks for form's control elements, and returns them if they're iterable. + * @param {HTMLElement} form + * @param {string} selector + * @returns {Element[]|null} + */ +function getFormControlElements(form, selector) { + // Some sites seem to be overriding `form.elements`, so we need to check if it's still iterable. + if (form instanceof HTMLFormElement && form.elements != null && Symbol.iterator in Object(form.elements)) { + // 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 specific types of elements. + const formControls = [...form.elements].filter((el) => el.matches(selector)); + return [...formControls]; + } else { + return null; + } +} + +/** + * Default operation: finds elements using querySelectorAll. + * Optionally, can be forced to scan the shadow tree. + * @param {HTMLElement} element + * @param {string} selector + * @param {boolean} forceScanShadowTree + * @returns {Element[]} + */ +function queryElementsWithShadow(element, selector, forceScanShadowTree = false) { + /** @type {Element[]|NodeListOf} element */ + const elements = element.querySelectorAll(selector); + + if (forceScanShadowTree || elements.length === 0) { + return [...elements, ...findElementsInShadowTree(element, selector)]; + } + return [...elements]; +} + export { notifyWebApp, sendAndWaitForAnswer, @@ -626,5 +656,7 @@ export { safeRegexTest, pierceShadowTree, getActiveElement, - findEnclosedElements, + findElementsInShadowTree, + queryElementsWithShadow, + getFormControlElements, }; diff --git a/swift-package/Resources/assets/autofill-debug.js b/swift-package/Resources/assets/autofill-debug.js index af970de66..06004d529 100644 --- a/swift-package/Resources/assets/autofill-debug.js +++ b/swift-package/Resources/assets/autofill-debug.js @@ -10217,20 +10217,10 @@ class Form { if (this.form.matches(selector)) { this.addInput(this.form); } else { - /** @type {Element[] | NodeList} */ - let foundInputs = []; - // Some sites seem to be overriding `form.elements`, so we need to check if it's still iterable. - if (this.form instanceof HTMLFormElement && this.form.elements != null && Symbol.iterator in Object(this.form.elements)) { - // 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. - const formElements = [...this.form.elements].filter(el => el.matches(selector)); - // If there are no 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); - } + // Attempt to get form's control elements first as it can catch elements when markup is broke, or if the fields are outside the form. + // Other wise use queryElementsWithShadow, that can scan for shadow tree. + const formControlElements = (0, _autofillUtils.getFormControlElements)(this.form, selector); + const foundInputs = formControlElements != null ? [...formControlElements, ...(0, _autofillUtils.findElementsInShadowTree)(this.form, selector)] : (0, _autofillUtils.queryElementsWithShadow)(this.form, selector, true); if (foundInputs.length < MAX_INPUTS_PER_FORM) { foundInputs.forEach(input => this.addInput(input)); } else { @@ -10291,7 +10281,7 @@ class Form { } get submitButtons() { const selector = this.matching.cssSelector('submitButtonSelector'); - const allButtons = /** @type {HTMLElement[]} */(0, _autofillUtils.findEnclosedElements)(this.form, selector); + const allButtons = /** @type {HTMLElement[]} */(0, _autofillUtils.queryElementsWithShadow)(this.form, selector); return allButtons.filter(btn => (0, _autofillUtils.isPotentiallyViewable)(btn) && (0, _autofillUtils.isLikelyASubmitButton)(btn, this.matching) && (0, _autofillUtils.buttonMatchesFormType)(btn, this)); } attemptSubmissionIfNeeded() { @@ -10993,6 +10983,22 @@ class FormAnalyzer { } }); } + + /** + * Function that checks if the element is an external link or a custom web element that + * encapsulates a link. + * @param {any} el + * @returns {boolean} + */ + isElementExternalLink(el) { + // Checks if the element is present in the cusotm elements registry and ends with a '-link' suffix. + // If it does, it checks if it contains an anchor element inside. + const tagName = el.nodeName.toLowerCase(); + const isCustomWebElementLink = customElements?.get(tagName) != null && /-link$/.test(tagName) && (0, _autofillUtils.findElementsInShadowTree)(el, 'a').length > 0; + + // if an external link matches one of the regexes, we assume the match is not pertinent to the current form + return el instanceof HTMLAnchorElement && el.href && el.getAttribute('href') !== '#' || (el.getAttribute('role') || '').toUpperCase() === 'LINK' || el.matches('button[class*=secondary]') || isCustomWebElementLink; + } evaluateElement(el) { const string = (0, _autofillUtils.getTextShallow)(el); if (el.matches(this.matching.cssSelector('password'))) { @@ -11032,8 +11038,7 @@ class FormAnalyzer { }); return; } - // if an external link matches one of the regexes, we assume the match is not pertinent to the current form - if (el instanceof HTMLAnchorElement && el.href && el.getAttribute('href') !== '#' || (el.getAttribute('role') || '').toUpperCase() === 'LINK' || el.matches('button[class*=secondary]')) { + if (this.isElementExternalLink(el)) { let shouldFlip = true; let strength = 1; // Don't flip forgotten password links @@ -11052,9 +11057,10 @@ class FormAnalyzer { }); } else { // any other case + const isH1Element = el.tagName === 'H1'; this.updateSignal({ string, - strength: 1, + strength: isH1Element ? 3 : 1, signalType: `generic: ${string}`, shouldCheckUnifiedForm: true }); @@ -11072,7 +11078,7 @@ class FormAnalyzer { // Check form contents (noisy elements are skipped with the safeUniversalSelector) const selector = this.matching.cssSelector('safeUniversalSelector'); - const formElements = (0, _autofillUtils.findEnclosedElements)(this.form, selector); + const formElements = (0, _autofillUtils.queryElementsWithShadow)(this.form, selector); for (let i = 0; i < formElements.length; i++) { // Safety cutoff to avoid huge DOMs freezing the browser if (i >= 200) break; @@ -14522,19 +14528,18 @@ class DefaultScanner { if (this.device.globalConfig.isDDGDomain) { return this; } - if ('matches' in context && context.matches?.(this.matching.cssSelector('formInputsSelectorWithoutSelect'))) { + const formInputsSelectorWithoutSelect = this.matching.cssSelector('formInputsSelectorWithoutSelect'); + if ('matches' in context && context.matches?.(formInputsSelectorWithoutSelect)) { this.addInput(context); } else { - const selector = this.matching.cssSelector('formInputsSelectorWithoutSelect'); - const inputs = context.querySelectorAll(selector); + const inputs = context.querySelectorAll(formInputsSelectorWithoutSelect); if (inputs.length > this.options.maxInputsPerPage) { 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 => { + (0, _autofillUtils.findElementsInShadowTree)(context, formInputsSelectorWithoutSelect).forEach(input => { if (input instanceof HTMLInputElement) { this.addInput(input, context); } @@ -14620,12 +14625,16 @@ class DefaultScanner { } 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; + // If the parent is a redundant component (only contains a single element or is a shadowRoot) do not increase the traversal count. + if (element.childElementCount > 1) { + 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; + } + traversalLayerCount++; } } else { // possibly a shadow boundary, so traverse through the shadow root and find the form @@ -14633,9 +14642,11 @@ class DefaultScanner { if (root instanceof ShadowRoot && root.host) { // @ts-ignore element = root.host; + } else { + // We're in a strange state (no parent or shadow root), just break out of the loop for safety + break; } } - traversalLayerCount++; } return input; } @@ -14797,12 +14808,13 @@ class DefaultScanner { // find the enclosing parent form, and scan it. if (realTarget instanceof HTMLInputElement && !realTarget.hasAttribute(ATTR_INPUT_TYPE)) { 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); - } + + // If the parent form is an input element we bail. + if (parentForm instanceof HTMLInputElement) return; + 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'); @@ -16939,10 +16951,12 @@ 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.findElementsInShadowTree = findElementsInShadowTree; exports.formatDuckAddress = void 0; exports.getActiveElement = getActiveElement; -exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = exports.getDaxBoundingBox = void 0; +exports.getDaxBoundingBox = void 0; +exports.getFormControlElements = getFormControlElements; +exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = void 0; exports.isFormLikelyToBeUsedAsPageWrapper = isFormLikelyToBeUsedAsPageWrapper; exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedConfig = void 0; exports.isLocalNetwork = isLocalNetwork; @@ -16951,6 +16965,7 @@ exports.isValidTLD = isValidTLD; exports.logPerformance = logPerformance; exports.notifyWebApp = void 0; exports.pierceShadowTree = pierceShadowTree; +exports.queryElementsWithShadow = queryElementsWithShadow; exports.safeExecute = exports.removeInlineStyles = void 0; exports.safeRegexTest = safeRegexTest; exports.setValue = exports.sendAndWaitForAnswer = void 0; @@ -17553,22 +17568,16 @@ function getActiveElement() { } /** - * Takes a root element and tries to find visible elements first, and if it fails, it tries to find shadow elements + * Takes a root element and tries to find elements in shadow DOMs that match the selector * @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 +function findElementsInShadowTree(root, selector) { const shadowElements = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - let node = walker.nextNode(); + /** @type {Node|null} */ + let node = walker.currentNode; while (node) { if (node instanceof HTMLElement && node.shadowRoot) { shadowElements.push(...node.shadowRoot.querySelectorAll(selector)); @@ -17578,6 +17587,43 @@ function findEnclosedElements(root, selector) { return shadowElements; } +/** + * The function looks for form's control elements, and returns them if they're iterable. + * @param {HTMLElement} form + * @param {string} selector + * @returns {Element[]|null} + */ +function getFormControlElements(form, selector) { + // Some sites seem to be overriding `form.elements`, so we need to check if it's still iterable. + if (form instanceof HTMLFormElement && form.elements != null && Symbol.iterator in Object(form.elements)) { + // 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 specific types of elements. + const formControls = [...form.elements].filter(el => el.matches(selector)); + return [...formControls]; + } else { + return null; + } +} + +/** + * Default operation: finds elements using querySelectorAll. + * Optionally, can be forced to scan the shadow tree. + * @param {HTMLElement} element + * @param {string} selector + * @param {boolean} forceScanShadowTree + * @returns {Element[]} + */ +function queryElementsWithShadow(element, selector) { + let forceScanShadowTree = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + /** @type {Element[]|NodeListOf} element */ + const elements = element.querySelectorAll(selector); + if (forceScanShadowTree || elements.length === 0) { + return [...elements, ...findElementsInShadowTree(element, selector)]; + } + return [...elements]; +} + },{"./Form/matching.js":44,"./constants.js":67,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],65:[function(require,module,exports){ "use strict"; diff --git a/swift-package/Resources/assets/autofill.js b/swift-package/Resources/assets/autofill.js index 924a7d614..b3e874725 100644 --- a/swift-package/Resources/assets/autofill.js +++ b/swift-package/Resources/assets/autofill.js @@ -6051,20 +6051,10 @@ class Form { if (this.form.matches(selector)) { this.addInput(this.form); } else { - /** @type {Element[] | NodeList} */ - let foundInputs = []; - // Some sites seem to be overriding `form.elements`, so we need to check if it's still iterable. - if (this.form instanceof HTMLFormElement && this.form.elements != null && Symbol.iterator in Object(this.form.elements)) { - // 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. - const formElements = [...this.form.elements].filter(el => el.matches(selector)); - // If there are no 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); - } + // Attempt to get form's control elements first as it can catch elements when markup is broke, or if the fields are outside the form. + // Other wise use queryElementsWithShadow, that can scan for shadow tree. + const formControlElements = (0, _autofillUtils.getFormControlElements)(this.form, selector); + const foundInputs = formControlElements != null ? [...formControlElements, ...(0, _autofillUtils.findElementsInShadowTree)(this.form, selector)] : (0, _autofillUtils.queryElementsWithShadow)(this.form, selector, true); if (foundInputs.length < MAX_INPUTS_PER_FORM) { foundInputs.forEach(input => this.addInput(input)); } else { @@ -6125,7 +6115,7 @@ class Form { } get submitButtons() { const selector = this.matching.cssSelector('submitButtonSelector'); - const allButtons = /** @type {HTMLElement[]} */(0, _autofillUtils.findEnclosedElements)(this.form, selector); + const allButtons = /** @type {HTMLElement[]} */(0, _autofillUtils.queryElementsWithShadow)(this.form, selector); return allButtons.filter(btn => (0, _autofillUtils.isPotentiallyViewable)(btn) && (0, _autofillUtils.isLikelyASubmitButton)(btn, this.matching) && (0, _autofillUtils.buttonMatchesFormType)(btn, this)); } attemptSubmissionIfNeeded() { @@ -6827,6 +6817,22 @@ class FormAnalyzer { } }); } + + /** + * Function that checks if the element is an external link or a custom web element that + * encapsulates a link. + * @param {any} el + * @returns {boolean} + */ + isElementExternalLink(el) { + // Checks if the element is present in the cusotm elements registry and ends with a '-link' suffix. + // If it does, it checks if it contains an anchor element inside. + const tagName = el.nodeName.toLowerCase(); + const isCustomWebElementLink = customElements?.get(tagName) != null && /-link$/.test(tagName) && (0, _autofillUtils.findElementsInShadowTree)(el, 'a').length > 0; + + // if an external link matches one of the regexes, we assume the match is not pertinent to the current form + return el instanceof HTMLAnchorElement && el.href && el.getAttribute('href') !== '#' || (el.getAttribute('role') || '').toUpperCase() === 'LINK' || el.matches('button[class*=secondary]') || isCustomWebElementLink; + } evaluateElement(el) { const string = (0, _autofillUtils.getTextShallow)(el); if (el.matches(this.matching.cssSelector('password'))) { @@ -6866,8 +6872,7 @@ class FormAnalyzer { }); return; } - // if an external link matches one of the regexes, we assume the match is not pertinent to the current form - if (el instanceof HTMLAnchorElement && el.href && el.getAttribute('href') !== '#' || (el.getAttribute('role') || '').toUpperCase() === 'LINK' || el.matches('button[class*=secondary]')) { + if (this.isElementExternalLink(el)) { let shouldFlip = true; let strength = 1; // Don't flip forgotten password links @@ -6886,9 +6891,10 @@ class FormAnalyzer { }); } else { // any other case + const isH1Element = el.tagName === 'H1'; this.updateSignal({ string, - strength: 1, + strength: isH1Element ? 3 : 1, signalType: `generic: ${string}`, shouldCheckUnifiedForm: true }); @@ -6906,7 +6912,7 @@ class FormAnalyzer { // Check form contents (noisy elements are skipped with the safeUniversalSelector) const selector = this.matching.cssSelector('safeUniversalSelector'); - const formElements = (0, _autofillUtils.findEnclosedElements)(this.form, selector); + const formElements = (0, _autofillUtils.queryElementsWithShadow)(this.form, selector); for (let i = 0; i < formElements.length; i++) { // Safety cutoff to avoid huge DOMs freezing the browser if (i >= 200) break; @@ -10356,19 +10362,18 @@ class DefaultScanner { if (this.device.globalConfig.isDDGDomain) { return this; } - if ('matches' in context && context.matches?.(this.matching.cssSelector('formInputsSelectorWithoutSelect'))) { + const formInputsSelectorWithoutSelect = this.matching.cssSelector('formInputsSelectorWithoutSelect'); + if ('matches' in context && context.matches?.(formInputsSelectorWithoutSelect)) { this.addInput(context); } else { - const selector = this.matching.cssSelector('formInputsSelectorWithoutSelect'); - const inputs = context.querySelectorAll(selector); + const inputs = context.querySelectorAll(formInputsSelectorWithoutSelect); if (inputs.length > this.options.maxInputsPerPage) { 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 => { + (0, _autofillUtils.findElementsInShadowTree)(context, formInputsSelectorWithoutSelect).forEach(input => { if (input instanceof HTMLInputElement) { this.addInput(input, context); } @@ -10454,12 +10459,16 @@ class DefaultScanner { } 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; + // If the parent is a redundant component (only contains a single element or is a shadowRoot) do not increase the traversal count. + if (element.childElementCount > 1) { + 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; + } + traversalLayerCount++; } } else { // possibly a shadow boundary, so traverse through the shadow root and find the form @@ -10467,9 +10476,11 @@ class DefaultScanner { if (root instanceof ShadowRoot && root.host) { // @ts-ignore element = root.host; + } else { + // We're in a strange state (no parent or shadow root), just break out of the loop for safety + break; } } - traversalLayerCount++; } return input; } @@ -10631,12 +10642,13 @@ class DefaultScanner { // find the enclosing parent form, and scan it. if (realTarget instanceof HTMLInputElement && !realTarget.hasAttribute(ATTR_INPUT_TYPE)) { 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); - } + + // If the parent form is an input element we bail. + if (parentForm instanceof HTMLInputElement) return; + 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'); @@ -12773,10 +12785,12 @@ 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.findElementsInShadowTree = findElementsInShadowTree; exports.formatDuckAddress = void 0; exports.getActiveElement = getActiveElement; -exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = exports.getDaxBoundingBox = void 0; +exports.getDaxBoundingBox = void 0; +exports.getFormControlElements = getFormControlElements; +exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = void 0; exports.isFormLikelyToBeUsedAsPageWrapper = isFormLikelyToBeUsedAsPageWrapper; exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedConfig = void 0; exports.isLocalNetwork = isLocalNetwork; @@ -12785,6 +12799,7 @@ exports.isValidTLD = isValidTLD; exports.logPerformance = logPerformance; exports.notifyWebApp = void 0; exports.pierceShadowTree = pierceShadowTree; +exports.queryElementsWithShadow = queryElementsWithShadow; exports.safeExecute = exports.removeInlineStyles = void 0; exports.safeRegexTest = safeRegexTest; exports.setValue = exports.sendAndWaitForAnswer = void 0; @@ -13387,22 +13402,16 @@ function getActiveElement() { } /** - * Takes a root element and tries to find visible elements first, and if it fails, it tries to find shadow elements + * Takes a root element and tries to find elements in shadow DOMs that match the selector * @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 +function findElementsInShadowTree(root, selector) { const shadowElements = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - let node = walker.nextNode(); + /** @type {Node|null} */ + let node = walker.currentNode; while (node) { if (node instanceof HTMLElement && node.shadowRoot) { shadowElements.push(...node.shadowRoot.querySelectorAll(selector)); @@ -13412,6 +13421,43 @@ function findEnclosedElements(root, selector) { return shadowElements; } +/** + * The function looks for form's control elements, and returns them if they're iterable. + * @param {HTMLElement} form + * @param {string} selector + * @returns {Element[]|null} + */ +function getFormControlElements(form, selector) { + // Some sites seem to be overriding `form.elements`, so we need to check if it's still iterable. + if (form instanceof HTMLFormElement && form.elements != null && Symbol.iterator in Object(form.elements)) { + // 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 specific types of elements. + const formControls = [...form.elements].filter(el => el.matches(selector)); + return [...formControls]; + } else { + return null; + } +} + +/** + * Default operation: finds elements using querySelectorAll. + * Optionally, can be forced to scan the shadow tree. + * @param {HTMLElement} element + * @param {string} selector + * @param {boolean} forceScanShadowTree + * @returns {Element[]} + */ +function queryElementsWithShadow(element, selector) { + let forceScanShadowTree = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + /** @type {Element[]|NodeListOf} element */ + const elements = element.querySelectorAll(selector); + if (forceScanShadowTree || elements.length === 0) { + return [...elements, ...findElementsInShadowTree(element, selector)]; + } + return [...elements]; +} + },{"./Form/matching.js":34,"./constants.js":57,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],55:[function(require,module,exports){ "use strict";