diff --git a/apps/browser/src/autofill/models/autofill-field.ts b/apps/browser/src/autofill/models/autofill-field.ts index 942adda8f160..3e5a94577dfa 100644 --- a/apps/browser/src/autofill/models/autofill-field.ts +++ b/apps/browser/src/autofill/models/autofill-field.ts @@ -136,3 +136,6 @@ export default class AutofillField { */ fieldRect?: FieldRect; } + +/** `readonly` / `disabled` from collected field data; a full {@link AutofillField} is assignable. */ +export type AutofillFieldReadonlyDisabledState = Pick; diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index e5ab7ad460a9..2da128add54d 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -38,6 +38,7 @@ import { elementIsFillableFormField, elementIsSelectElement, getAttributeBoolean, + isReadonlyOrDisabledFormFieldElement, nodeIsAnchorElement, nodeIsButtonElement, nodeIsTypeSubmitElement, @@ -216,7 +217,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ return; } - if (this.isReadonlyOrDisabledElement(formFieldElement, autofillFieldData)) { + if (isReadonlyOrDisabledFormFieldElement(formFieldElement, autofillFieldData)) { return; } @@ -764,7 +765,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ */ private async focusInlineMenuList() { if (this.mostRecentlyFocusedField && !(await this.isInlineMenuListVisible())) { - if (this.isReadonlyOrDisabledElement(this.mostRecentlyFocusedField)) { + if (isReadonlyOrDisabledFormFieldElement(this.mostRecentlyFocusedField)) { return; } this.clearFocusInlineMenuListTimeout(); @@ -803,7 +804,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ if (!elementIsFillableFormField(formFieldElement)) { return; } - if (this.isReadonlyOrDisabledElement(formFieldElement)) { + if (isReadonlyOrDisabledFormFieldElement(formFieldElement)) { return; } @@ -928,7 +929,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * @param formFieldElement - The form field element that triggered the click event. */ private async triggerFormFieldClickedAction(formFieldElement: ElementWithOpId) { - if (this.isReadonlyOrDisabledElement(formFieldElement)) { + if (isReadonlyOrDisabledFormFieldElement(formFieldElement)) { return; } @@ -962,7 +963,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ if (await this.isFieldCurrentlyFilling()) { return; } - if (this.isReadonlyOrDisabledElement(formFieldElement)) { + if (isReadonlyOrDisabledFormFieldElement(formFieldElement)) { return; } @@ -1474,25 +1475,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ return documentRoot?.activeElement; } - /** - * Checks if a form field element is currently readonly or disabled. - * - * @param formFieldElement - The form field element to evaluate. - * @param autofillFieldData - Optional cached autofill metadata for readonly or disabled state. - */ - private isReadonlyOrDisabledElement( - formFieldElement: ElementWithOpId, - autofillFieldData?: AutofillField, - ): boolean { - return ( - getAttributeBoolean(formFieldElement, "disabled") || - Boolean((formFieldElement as HTMLInputElement | HTMLTextAreaElement).readOnly) || - getAttributeBoolean(formFieldElement, "aria-readonly", true) || - autofillFieldData?.readonly === true || - autofillFieldData?.disabled === true - ); - } - /** * Queries all iframe elements within the document and returns the * sub frame offsets for each iframe element. diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index c27cd16ae6fa..70f412e7ca65 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -692,6 +692,18 @@ describe("InsertAutofillContentService", () => { ).not.toHaveBeenCalled(); expect(element.value).toBe(value); }); + + it("does not insert when aria-readonly is set", () => { + document.body.innerHTML = ``; + const element = document.getElementById("username") as HTMLInputElement; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](element, "new-value"); + + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"], + ).not.toHaveBeenCalled(); + }); }); describe("handleInsertValueAndTriggerSimulatedEvents", () => { diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts index df8ecd2e5f85..169e7821d27f 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -10,8 +10,7 @@ import { currentlyInSandboxedIframe, elementIsFillableFormField, elementIsInputElement, - elementIsSelectElement, - elementIsTextAreaElement, + isReadonlyOrDisabledFormFieldElement, } from "../utils"; import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service"; @@ -204,18 +203,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf return; } - const elementCanBeReadonly = - elementIsInputElement(element) || elementIsTextAreaElement(element); - const elementCanBeFilled = elementCanBeReadonly || elementIsSelectElement(element); const elementValue = (element as HTMLInputElement)?.value || element?.innerText || ""; - const elementAlreadyHasTheValue = !!(elementValue?.length && elementValue === value); - if ( - elementAlreadyHasTheValue || - (elementCanBeReadonly && element.readOnly) || - (elementCanBeFilled && element.disabled) - ) { + if (elementAlreadyHasTheValue || isReadonlyOrDisabledFormFieldElement(element)) { return; } diff --git a/apps/browser/src/autofill/utils/index.spec.ts b/apps/browser/src/autofill/utils/index.spec.ts index 43984f44bc8a..d7f4d1ea2a9a 100644 --- a/apps/browser/src/autofill/utils/index.spec.ts +++ b/apps/browser/src/autofill/utils/index.spec.ts @@ -7,6 +7,7 @@ import { buildSvgDomElement, debounce, generateRandomCustomElementName, + isReadonlyOrDisabledFormFieldElement, sendExtensionMessage, setElementStyles, setupAutofillInitDisconnectAction, @@ -244,3 +245,35 @@ describe("debounce", () => { expect(debouncedFunction).toHaveBeenCalledTimes(1); }); }); + +describe("isReadonlyOrDisabledFormFieldElement", () => { + it("returns false for an enabled, editable text input", () => { + document.body.innerHTML = ``; + expect( + isReadonlyOrDisabledFormFieldElement(document.getElementById("field") as HTMLInputElement), + ).toBe(false); + }); + + it("returns true when DOM or cached flags indicate the field is not writable", () => { + const expectTrue = (html: string, meta?: { readonly?: boolean; disabled?: boolean }) => { + document.body.innerHTML = html; + expect( + isReadonlyOrDisabledFormFieldElement( + document.getElementById("field") as HTMLInputElement, + meta, + ), + ).toBe(true); + }; + + expectTrue(``); + expectTrue(``); + expectTrue(``); + expectTrue(``, { readonly: true }); + expectTrue(``, { disabled: true }); + + document.body.innerHTML = ``; + const textarea = document.getElementById("field") as HTMLTextAreaElement; + textarea.readOnly = true; + expect(isReadonlyOrDisabledFormFieldElement(textarea)).toBe(true); + }); +}); diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 83859d1a5c6a..4954f95893cb 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -1,5 +1,8 @@ +import { AUTOFILL_ATTRIBUTES } from "@bitwarden/common/autofill/constants"; + import { FieldRect } from "../background/abstractions/overlay.background"; import { AutofillPort } from "../enums/autofill-port.enum"; +import type { AutofillFieldReadonlyDisabledState } from "../models/autofill-field"; import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types"; /** @@ -351,6 +354,29 @@ export function getAttributeBoolean( return Boolean(getPropertyOrAttribute(element, attributeName)); } +/** + * Checks if a form field element is currently readonly or disabled. + * + * @param formFieldElement - The form field element to evaluate. + * @param autofillFieldData - Optional cached autofill metadata for readonly or disabled state. + */ +export function isReadonlyOrDisabledFormFieldElement( + formFieldElement: FormFieldElement, + autofillFieldData?: AutofillFieldReadonlyDisabledState, +): boolean { + const readOnlyByProperty = + (elementIsInputElement(formFieldElement) || elementIsTextAreaElement(formFieldElement)) && + formFieldElement.readOnly; + + return ( + getAttributeBoolean(formFieldElement, AUTOFILL_ATTRIBUTES.DISABLED) || + readOnlyByProperty || + getAttributeBoolean(formFieldElement, "aria-readonly", true) || + autofillFieldData?.readonly === true || + autofillFieldData?.disabled === true + ); +} + /** * Get the value of a property or attribute from a FormFieldElement. *