1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-21 16:18:28 +01:00

[PM-6352] Autofill functionality not working with re-hydrated DOM elements (#8033)

* [PM-6352] Autofill functionality not working with re-hydtrated DOM elements

* [PM-6352] Autofill functionality not working with re-hydtrated DOM elements

* [PM-6352] Fixing issue found with chrome.dom.openOrClosedShadowRoot call

* [PM-6352] Implementing jest tests and adding utils methods where appropriate

* [PM-6352] Implementing jest tests and adding utils methods where appropriate
This commit is contained in:
Cesar Gonzalez 2024-02-23 13:41:26 -06:00 committed by GitHub
parent 3e6ba798ca
commit b2c3ecda0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 203 additions and 43 deletions

View File

@ -10,7 +10,12 @@ import AutofillField from "../models/autofill-field";
import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe"; import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe";
import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe"; import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe";
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
import { generateRandomCustomElementName, sendExtensionMessage, setElementStyles } from "../utils"; import {
elementIsFillableFormField,
generateRandomCustomElementName,
sendExtensionMessage,
setElementStyles,
} from "../utils";
import { import {
AutofillOverlayElement, AutofillOverlayElement,
RedirectFocusDirection, RedirectFocusDirection,
@ -408,7 +413,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
* @param formFieldElement - The form field element that triggered the input event. * @param formFieldElement - The form field element that triggered the input event.
*/ */
private triggerFormFieldInput(formFieldElement: ElementWithOpId<FormFieldElement>) { private triggerFormFieldInput(formFieldElement: ElementWithOpId<FormFieldElement>) {
if (formFieldElement instanceof HTMLSpanElement) { if (!elementIsFillableFormField(formFieldElement)) {
return; return;
} }

View File

@ -7,6 +7,19 @@ import {
FormFieldElement, FormFieldElement,
FormElementWithAttribute, FormElementWithAttribute,
} from "../types"; } from "../types";
import {
elementIsDescriptionDetailsElement,
elementIsDescriptionTermElement,
elementIsFillableFormField,
elementIsFormElement,
elementIsLabelElement,
elementIsSelectElement,
elementIsSpanElement,
nodeIsFormElement,
nodeIsElement,
elementIsInputElement,
elementIsTextAreaElement,
} from "../utils";
import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service"; import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service";
import { import {
@ -347,7 +360,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
tagName: this.getAttributeLowerCase(element, "tagName"), tagName: this.getAttributeLowerCase(element, "tagName"),
}; };
if (element.tagName.toLowerCase() === "span") { if (elementIsSpanElement(element)) {
this.cacheAutofillFieldElement(index, element, autofillFieldBase); this.cacheAutofillFieldElement(index, element, autofillFieldBase);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
@ -383,10 +396,9 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
autoCompleteType: this.getAutoCompleteAttribute(element), autoCompleteType: this.getAutoCompleteAttribute(element),
disabled: this.getAttributeBoolean(element, "disabled"), disabled: this.getAttributeBoolean(element, "disabled"),
readonly: this.getAttributeBoolean(element, "readonly"), readonly: this.getAttributeBoolean(element, "readonly"),
selectInfo: selectInfo: elementIsSelectElement(element)
element.tagName.toLowerCase() === "select" ? this.getSelectElementOptions(element as HTMLSelectElement)
? this.getSelectElementOptions(element as HTMLSelectElement) : null,
: null,
form: fieldFormElement ? this.getPropertyOrAttribute(fieldFormElement, "opid") : null, form: fieldFormElement ? this.getPropertyOrAttribute(fieldFormElement, "opid") : null,
"aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true), "aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true),
"aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true), "aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true),
@ -502,7 +514,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
let currentElement: HTMLElement | null = element; let currentElement: HTMLElement | null = element;
while (currentElement && currentElement !== document.documentElement) { while (currentElement && currentElement !== document.documentElement) {
if (currentElement.tagName.toLowerCase() === "label") { if (elementIsLabelElement(currentElement)) {
labelElementsSet.add(currentElement); labelElementsSet.add(currentElement);
} }
@ -511,10 +523,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
if ( if (
!labelElementsSet.size && !labelElementsSet.size &&
element.parentElement?.tagName.toLowerCase() === "dd" && elementIsDescriptionDetailsElement(element.parentElement) &&
element.parentElement.previousElementSibling?.tagName.toLowerCase() === "dt" elementIsDescriptionTermElement(element.parentElement.previousElementSibling)
) { ) {
labelElementsSet.add(element.parentElement.previousElementSibling as HTMLElement); labelElementsSet.add(element.parentElement.previousElementSibling);
} }
return this.createLabelElementsTag(labelElementsSet); return this.createLabelElementsTag(labelElementsSet);
@ -577,13 +589,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
* @private * @private
*/ */
private getAutofillFieldMaxLength(element: FormFieldElement): number | null { private getAutofillFieldMaxLength(element: FormFieldElement): number | null {
const elementTagName = element.tagName.toLowerCase(); const elementHasMaxLengthProperty =
const elementHasMaxLengthProperty = elementTagName === "input" || elementTagName === "textarea"; elementIsInputElement(element) || elementIsTextAreaElement(element);
const elementMaxLength = const elementMaxLength =
elementHasMaxLengthProperty && elementHasMaxLengthProperty && element.maxLength > -1 ? element.maxLength : 999;
(element as HTMLInputElement | HTMLTextAreaElement).maxLength > -1
? (element as HTMLInputElement | HTMLTextAreaElement).maxLength
: 999;
return elementHasMaxLengthProperty ? Math.min(elementMaxLength, 999) : null; return elementHasMaxLengthProperty ? Math.min(elementMaxLength, 999) : null;
} }
@ -747,10 +756,9 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
// Prioritize capturing text content from elements rather than nodes. // Prioritize capturing text content from elements rather than nodes.
currentElement = currentElement.parentElement || currentElement.parentNode; currentElement = currentElement.parentElement || currentElement.parentNode;
let siblingElement = let siblingElement = nodeIsElement(currentElement)
currentElement instanceof HTMLElement ? currentElement.previousElementSibling
? currentElement.previousElementSibling : currentElement.previousSibling;
: currentElement.previousSibling;
while (siblingElement?.lastChild && !this.isNewSectionElement(siblingElement)) { while (siblingElement?.lastChild && !this.isNewSectionElement(siblingElement)) {
siblingElement = siblingElement.lastChild; siblingElement = siblingElement.lastChild;
} }
@ -793,13 +801,13 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
* @private * @private
*/ */
private getElementValue(element: FormFieldElement): string { private getElementValue(element: FormFieldElement): string {
if (element.tagName.toLowerCase() === "span") { if (!elementIsFillableFormField(element)) {
const spanTextContent = element.textContent || element.innerText; const spanTextContent = element.textContent || element.innerText;
return spanTextContent || ""; return spanTextContent || "";
} }
const elementValue = (element as FillableFormFieldElement).value || ""; const elementValue = element.value || "";
const elementType = String((element as FillableFormFieldElement).type).toLowerCase(); const elementType = String(element.type).toLowerCase();
if ("checked" in element && elementType === "checkbox") { if ("checked" in element && elementType === "checkbox") {
return element.checked ? "✓" : ""; return element.checked ? "✓" : "";
} }
@ -850,7 +858,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
const formElements: Node[] = []; const formElements: Node[] = [];
const formFieldElements: Node[] = []; const formFieldElements: Node[] = [];
this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => { this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => {
if ((node as HTMLFormElement).tagName?.toLowerCase() === "form") { if (nodeIsFormElement(node)) {
formElements.push(node); formElements.push(node);
return true; return true;
} }
@ -873,7 +881,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
* @private * @private
*/ */
private isNodeFormFieldElement(node: Node): boolean { private isNodeFormFieldElement(node: Node): boolean {
if (!(node instanceof HTMLElement)) { if (!nodeIsElement(node)) {
return false; return false;
} }
@ -904,15 +912,20 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
* @param {Node} node * @param {Node} node
*/ */
private getShadowRoot(node: Node): ShadowRoot | null { private getShadowRoot(node: Node): ShadowRoot | null {
if (!(node instanceof HTMLElement) || node.childNodes.length !== 0) { if (!nodeIsElement(node) || node.childNodes.length !== 0) {
return null; return null;
} }
if (node.shadowRoot) { if (node.shadowRoot) {
return node.shadowRoot; return node.shadowRoot;
} }
if ((chrome as any).dom?.openOrClosedShadowRoot) { if ((chrome as any).dom?.openOrClosedShadowRoot) {
return (chrome as any).dom.openOrClosedShadowRoot(node); try {
return (chrome as any).dom.openOrClosedShadowRoot(node);
} catch (error) {
return null;
}
} }
return (node as any).openOrClosedShadowRoot; return (node as any).openOrClosedShadowRoot;
@ -1052,15 +1065,14 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
const mutatedElements: Node[] = []; const mutatedElements: Node[] = [];
for (let index = 0; index < nodes.length; index++) { for (let index = 0; index < nodes.length; index++) {
const node = nodes[index]; const node = nodes[index];
if (!(node instanceof HTMLElement)) { if (!nodeIsElement(node)) {
continue; continue;
} }
const autofillElementNodes = this.queryAllTreeWalkerNodes( const autofillElementNodes = this.queryAllTreeWalkerNodes(
node, node,
(walkerNode: Node) => (walkerNode: Node) =>
(walkerNode as HTMLElement).tagName?.toLowerCase() === "form" || nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode),
this.isNodeFormFieldElement(walkerNode),
) as HTMLElement[]; ) as HTMLElement[];
if (autofillElementNodes.length) { if (autofillElementNodes.length) {
@ -1115,11 +1127,8 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
private deleteCachedAutofillElement( private deleteCachedAutofillElement(
element: ElementWithOpId<HTMLFormElement> | ElementWithOpId<FormFieldElement>, element: ElementWithOpId<HTMLFormElement> | ElementWithOpId<FormFieldElement>,
) { ) {
if ( if (elementIsFormElement(element) && this.autofillFormElements.has(element)) {
element.tagName.toLowerCase() === "form" && this.autofillFormElements.delete(element);
this.autofillFormElements.has(element as ElementWithOpId<HTMLFormElement>)
) {
this.autofillFormElements.delete(element as ElementWithOpId<HTMLFormElement>);
return; return;
} }
@ -1151,7 +1160,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
*/ */
private handleAutofillElementAttributeMutation(mutation: MutationRecord) { private handleAutofillElementAttributeMutation(mutation: MutationRecord) {
const targetElement = mutation.target; const targetElement = mutation.target;
if (!(targetElement instanceof HTMLElement)) { if (!nodeIsElement(targetElement)) {
return; return;
} }

View File

@ -1,6 +1,13 @@
import { EVENTS, TYPE_CHECK } from "../constants"; import { EVENTS, TYPE_CHECK } from "../constants";
import AutofillScript, { AutofillInsertActions, FillScript } from "../models/autofill-script"; import AutofillScript, { AutofillInsertActions, FillScript } from "../models/autofill-script";
import { FormFieldElement } from "../types"; import { FormFieldElement } from "../types";
import {
elementIsFillableFormField,
elementIsInputElement,
elementIsSelectElement,
elementIsTextAreaElement,
nodeIsInputElement,
} from "../utils";
import { InsertAutofillContentService as InsertAutofillContentServiceInterface } from "./abstractions/insert-autofill-content.service"; import { InsertAutofillContentService as InsertAutofillContentServiceInterface } from "./abstractions/insert-autofill-content.service";
import CollectAutofillContentService from "./collect-autofill-content.service"; import CollectAutofillContentService from "./collect-autofill-content.service";
@ -96,7 +103,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
return Boolean( return Boolean(
this.collectAutofillContentService.queryAllTreeWalkerNodes( this.collectAutofillContentService.queryAllTreeWalkerNodes(
document.documentElement, document.documentElement,
(node: Node) => node instanceof HTMLInputElement && node.type === "password", (node: Node) => nodeIsInputElement(node) && node.type === "password",
false, false,
)?.length, )?.length,
); );
@ -195,8 +202,8 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
*/ */
private insertValueIntoField(element: FormFieldElement | null, value: string) { private insertValueIntoField(element: FormFieldElement | null, value: string) {
const elementCanBeReadonly = const elementCanBeReadonly =
element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement; elementIsInputElement(element) || elementIsTextAreaElement(element);
const elementCanBeFilled = elementCanBeReadonly || element instanceof HTMLSelectElement; const elementCanBeFilled = elementCanBeReadonly || elementIsSelectElement(element);
if ( if (
!element || !element ||
@ -207,13 +214,13 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
return; return;
} }
if (element instanceof HTMLSpanElement) { if (!elementIsFillableFormField(element)) {
this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.innerText = value)); this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.innerText = value));
return; return;
} }
const isFillableCheckboxOrRadioElement = const isFillableCheckboxOrRadioElement =
element instanceof HTMLInputElement && elementIsInputElement(element) &&
new Set(["checkbox", "radio"]).has(element.type) && new Set(["checkbox", "radio"]).has(element.type) &&
new Set(["true", "y", "1", "yes", "✓"]).has(String(value).toLowerCase()); new Set(["true", "y", "1", "yes", "✓"]).has(String(value).toLowerCase());
if (isFillableCheckboxOrRadioElement) { if (isFillableCheckboxOrRadioElement) {
@ -285,7 +292,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
*/ */
private triggerFillAnimationOnElement(element: FormFieldElement): void { private triggerFillAnimationOnElement(element: FormFieldElement): void {
const skipAnimatingElement = const skipAnimatingElement =
!(element instanceof HTMLSpanElement) && elementIsFillableFormField(element) &&
!new Set(["email", "text", "password", "number", "tel", "url"]).has(element?.type); !new Set(["email", "text", "password", "number", "tel", "url"]).has(element?.type);
if (this.domElementVisibilityService.isElementHiddenByCss(element) || skipAnimatingElement) { if (this.domElementVisibilityService.isElementHiddenByCss(element) || skipAnimatingElement) {
@ -371,6 +378,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
element.dispatchEvent(new Event(simulatedInputEvents[index], { bubbles: true })); element.dispatchEvent(new Event(simulatedInputEvents[index], { bubbles: true }));
} }
} }
private nodeIsElement(node: Node): node is HTMLElement {
return node.nodeType === Node.ELEMENT_NODE;
}
} }
export default InsertAutofillContentService; export default InsertAutofillContentService;

View File

@ -1,4 +1,5 @@
import { AutofillPort } from "../enums/autofill-port.enums"; import { AutofillPort } from "../enums/autofill-port.enums";
import { FillableFormFieldElement, FormFieldElement } from "../types";
/** /**
* Generates a random string of characters that formatted as a custom element name. * Generates a random string of characters that formatted as a custom element name.
@ -151,6 +152,127 @@ function setupAutofillInitDisconnectAction(windowContext: Window) {
setupExtensionDisconnectAction(onDisconnectCallback); setupExtensionDisconnectAction(onDisconnectCallback);
} }
/**
* Identifies whether an element is a fillable form field.
* This is determined by whether the element is a form field and not a span.
*
* @param formFieldElement - The form field element to check.
*/
function elementIsFillableFormField(
formFieldElement: FormFieldElement,
): formFieldElement is FillableFormFieldElement {
return formFieldElement?.tagName.toLowerCase() !== "span";
}
/**
* Identifies whether an element is an instance of a specific tag name.
*
* @param element - The element to check.
* @param tagName - The tag name to check against.
*/
function elementIsInstanceOf<T extends Element>(element: Element, tagName: string): element is T {
return element?.tagName.toLowerCase() === tagName;
}
/**
* Identifies whether an element is a span element.
*
* @param element - The element to check.
*/
function elementIsSpanElement(element: Element): element is HTMLSpanElement {
return elementIsInstanceOf<HTMLSpanElement>(element, "span");
}
/**
* Identifies whether an element is an input field.
*
* @param element - The element to check.
*/
function elementIsInputElement(element: Element): element is HTMLInputElement {
return elementIsInstanceOf<HTMLInputElement>(element, "input");
}
/**
* Identifies whether an element is a select field.
*
* @param element - The element to check.
*/
function elementIsSelectElement(element: Element): element is HTMLSelectElement {
return elementIsInstanceOf<HTMLSelectElement>(element, "select");
}
/**
* Identifies whether an element is a textarea field.
*
* @param element - The element to check.
*/
function elementIsTextAreaElement(element: Element): element is HTMLTextAreaElement {
return elementIsInstanceOf<HTMLTextAreaElement>(element, "textarea");
}
/**
* Identifies whether an element is a form element.
*
* @param element - The element to check.
*/
function elementIsFormElement(element: Element): element is HTMLFormElement {
return elementIsInstanceOf<HTMLFormElement>(element, "form");
}
/**
* Identifies whether an element is a label element.
*
* @param element - The element to check.
*/
function elementIsLabelElement(element: Element): element is HTMLLabelElement {
return elementIsInstanceOf<HTMLLabelElement>(element, "label");
}
/**
* Identifies whether an element is a description details `dd` element.
*
* @param element - The element to check.
*/
function elementIsDescriptionDetailsElement(element: Element): element is HTMLElement {
return elementIsInstanceOf<HTMLElement>(element, "dd");
}
/**
* Identifies whether an element is a description term `dt` element.
*
* @param element - The element to check.
*/
function elementIsDescriptionTermElement(element: Element): element is HTMLElement {
return elementIsInstanceOf<HTMLElement>(element, "dt");
}
/**
* Identifies whether a node is an HTML element.
*
* @param node - The node to check.
*/
function nodeIsElement(node: Node): node is Element {
return node?.nodeType === Node.ELEMENT_NODE;
}
/**
* Identifies whether a node is an input element.
*
* @param node - The node to check.
*/
function nodeIsInputElement(node: Node): node is HTMLInputElement {
return nodeIsElement(node) && elementIsInputElement(node);
}
/**
* Identifies whether a node is a form element.
*
* @param node - The node to check.
*/
function nodeIsFormElement(node: Node): node is HTMLFormElement {
return nodeIsElement(node) && elementIsFormElement(node);
}
export { export {
generateRandomCustomElementName, generateRandomCustomElementName,
buildSvgDomElement, buildSvgDomElement,
@ -159,4 +281,17 @@ export {
getFromLocalStorage, getFromLocalStorage,
setupExtensionDisconnectAction, setupExtensionDisconnectAction,
setupAutofillInitDisconnectAction, setupAutofillInitDisconnectAction,
elementIsFillableFormField,
elementIsInstanceOf,
elementIsSpanElement,
elementIsInputElement,
elementIsSelectElement,
elementIsTextAreaElement,
elementIsFormElement,
elementIsLabelElement,
elementIsDescriptionDetailsElement,
elementIsDescriptionTermElement,
nodeIsElement,
nodeIsInputElement,
nodeIsFormElement,
}; };