bitwarden-browser/apps/browser/src/autofill/services/insert-autofill-content.ser...

377 lines
13 KiB
TypeScript

import { EVENTS, TYPE_CHECK } from "../constants";
import AutofillScript, { AutofillInsertActions, FillScript } from "../models/autofill-script";
import { FormFieldElement } from "../types";
import { InsertAutofillContentService as InsertAutofillContentServiceInterface } from "./abstractions/insert-autofill-content.service";
import CollectAutofillContentService from "./collect-autofill-content.service";
import DomElementVisibilityService from "./dom-element-visibility.service";
class InsertAutofillContentService implements InsertAutofillContentServiceInterface {
private readonly domElementVisibilityService: DomElementVisibilityService;
private readonly collectAutofillContentService: CollectAutofillContentService;
private readonly autofillInsertActions: AutofillInsertActions = {
fill_by_opid: ({ opid, value }) => this.handleFillFieldByOpidAction(opid, value),
click_on_opid: ({ opid }) => this.handleClickOnFieldByOpidAction(opid),
focus_by_opid: ({ opid }) => this.handleFocusOnFieldByOpidAction(opid),
};
/**
* InsertAutofillContentService constructor. Instantiates the
* DomElementVisibilityService and CollectAutofillContentService classes.
*/
constructor(
domElementVisibilityService: DomElementVisibilityService,
collectAutofillContentService: CollectAutofillContentService,
) {
this.domElementVisibilityService = domElementVisibilityService;
this.collectAutofillContentService = collectAutofillContentService;
}
/**
* Handles autofill of the forms on the current page based on the
* data within the passed fill script object.
* @param {AutofillScript} fillScript
* @returns {Promise<void>}
* @public
*/
async fillForm(fillScript: AutofillScript) {
if (
!fillScript.script?.length ||
this.fillingWithinSandboxedIframe() ||
this.userCancelledInsecureUrlAutofill(fillScript.savedUrls) ||
this.userCancelledUntrustedIframeAutofill(fillScript)
) {
return;
}
const fillActionPromises = fillScript.script.map(this.runFillScriptAction);
await Promise.all(fillActionPromises);
}
/**
* Identifies if the execution of this script is happening
* within a sandboxed iframe.
* @returns {boolean}
* @private
*/
private fillingWithinSandboxedIframe() {
return (
String(self.origin).toLowerCase() === "null" ||
window.frameElement?.hasAttribute("sandbox") ||
window.location.hostname === ""
);
}
/**
* Checks if the autofill is occurring on a page that can be considered secure. If the page is not secure,
* the user is prompted to confirm that they want to autofill on the page.
* @param {string[] | null} savedUrls
* @returns {boolean}
* @private
*/
private userCancelledInsecureUrlAutofill(savedUrls?: string[] | null): boolean {
if (
!savedUrls?.some((url) => url.startsWith(`https://${window.location.hostname}`)) ||
window.location.protocol !== "http:" ||
!this.isPasswordFieldWithinDocument()
) {
return false;
}
const confirmationWarning = [
chrome.i18n.getMessage("insecurePageWarning"),
chrome.i18n.getMessage("insecurePageWarningFillPrompt", [window.location.hostname]),
].join("\n\n");
return !confirm(confirmationWarning);
}
/**
* Checks if there is a password field within the current document. Includes
* password fields that are present within the shadow DOM.
* @returns {boolean}
* @private
*/
private isPasswordFieldWithinDocument(): boolean {
return Boolean(
this.collectAutofillContentService.queryAllTreeWalkerNodes(
document.documentElement,
(node: Node) => node instanceof HTMLInputElement && node.type === "password",
false,
)?.length,
);
}
/**
* Checking if the autofill is occurring within an untrusted iframe. If the page is within an untrusted iframe,
* the user is prompted to confirm that they want to autofill on the page. If the user cancels the autofill,
* the script will not continue.
*
* Note: confirm() is blocked by sandboxed iframes, but we don't want to fill sandboxed iframes anyway.
* If this occurs, confirm() returns false without displaying the dialog box, and autofill will be aborted.
* The browser may print a message to the console, but this is not a standard error that we can handle.
* @param {AutofillScript} fillScript
* @returns {boolean}
* @private
*/
private userCancelledUntrustedIframeAutofill(fillScript: AutofillScript): boolean {
if (!fillScript.untrustedIframe) {
return false;
}
const confirmationWarning = [
chrome.i18n.getMessage("autofillIframeWarning"),
chrome.i18n.getMessage("autofillIframeWarningTip", [window.location.hostname]),
].join("\n\n");
return !confirm(confirmationWarning);
}
/**
* Runs the autofill action based on the action type and the opid.
* Each action is subsequently delayed by 20 milliseconds.
* @param {"click_on_opid" | "focus_by_opid" | "fill_by_opid"} action
* @param {string} opid
* @param {string} value
* @param {number} actionIndex
* @returns {Promise<void>}
* @private
*/
private runFillScriptAction = (
[action, opid, value]: FillScript,
actionIndex: number,
): Promise<void> => {
if (!opid || !this.autofillInsertActions[action]) {
return;
}
const delayActionsInMilliseconds = 20;
return new Promise((resolve) =>
setTimeout(() => {
this.autofillInsertActions[action]({ opid, value });
resolve();
}, delayActionsInMilliseconds * actionIndex),
);
};
/**
* Queries the DOM for an element by opid and inserts the passed value into the element.
* @param {string} opid
* @param {string} value
* @private
*/
private handleFillFieldByOpidAction(opid: string, value: string) {
const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid);
this.insertValueIntoField(element, value);
}
/**
* Handles finding an element by opid and triggering a click event on the element.
* @param {string} opid
* @private
*/
private handleClickOnFieldByOpidAction(opid: string) {
const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid);
this.triggerClickOnElement(element);
}
/**
* Handles finding an element by opid and triggering click and focus events on the element.
* @param {string} opid
* @private
*/
private handleFocusOnFieldByOpidAction(opid: string) {
const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid);
this.simulateUserMouseClickAndFocusEventInteractions(element, true);
}
/**
* Identifies the type of element passed and inserts the value into the element.
* Will trigger simulated events on the element to ensure that the element is
* properly updated.
* @param {FormFieldElement | null} element
* @param {string} value
* @private
*/
private insertValueIntoField(element: FormFieldElement | null, value: string) {
const elementCanBeReadonly =
element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement;
const elementCanBeFilled = elementCanBeReadonly || element instanceof HTMLSelectElement;
if (
!element ||
!value ||
(elementCanBeReadonly && element.readOnly) ||
(elementCanBeFilled && element.disabled)
) {
return;
}
if (element instanceof HTMLSpanElement) {
this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.innerText = value));
return;
}
const isFillableCheckboxOrRadioElement =
element instanceof HTMLInputElement &&
new Set(["checkbox", "radio"]).has(element.type) &&
new Set(["true", "y", "1", "yes", "✓"]).has(String(value).toLowerCase());
if (isFillableCheckboxOrRadioElement) {
this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.checked = true));
return;
}
this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.value = value));
}
/**
* Simulates pre- and post-insert events on the element meant to mimic user interactions
* while inserting the autofill value into the element.
* @param {FormFieldElement} element
* @param {Function} valueChangeCallback
* @private
*/
private handleInsertValueAndTriggerSimulatedEvents(
element: FormFieldElement,
valueChangeCallback: CallableFunction,
): void {
this.triggerPreInsertEventsOnElement(element);
valueChangeCallback();
this.triggerPostInsertEventsOnElement(element);
this.triggerFillAnimationOnElement(element);
}
/**
* Simulates a mouse click event on the element, including focusing the event, and
* the triggers a simulated keyboard event on the element. Will attempt to ensure
* that the initial element value is not arbitrarily changed by the simulated events.
* @param {FormFieldElement} element
* @private
*/
private triggerPreInsertEventsOnElement(element: FormFieldElement): void {
const initialElementValue = "value" in element ? element.value : "";
this.simulateUserMouseClickAndFocusEventInteractions(element);
this.simulateUserKeyboardEventInteractions(element);
if ("value" in element && initialElementValue !== element.value) {
element.value = initialElementValue;
}
}
/**
* Simulates a keyboard event on the element before assigning the autofilled value to the element, and then
* simulates an input change event on the element to trigger expected events after autofill occurs.
* @param {FormFieldElement} element
* @private
*/
private triggerPostInsertEventsOnElement(element: FormFieldElement): void {
const autofilledValue = "value" in element ? element.value : "";
this.simulateUserKeyboardEventInteractions(element);
if ("value" in element && autofilledValue !== element.value) {
element.value = autofilledValue;
}
this.simulateInputElementChangedEvent(element);
element.blur();
}
/**
* Identifies if a passed element can be animated and sets a class on the element
* to trigger a CSS animation. The animation is removed after a short delay.
* @param {FormFieldElement} element
* @private
*/
private triggerFillAnimationOnElement(element: FormFieldElement): void {
const skipAnimatingElement =
!(element instanceof HTMLSpanElement) &&
!new Set(["email", "text", "password", "number", "tel", "url"]).has(element?.type);
if (this.domElementVisibilityService.isElementHiddenByCss(element) || skipAnimatingElement) {
return;
}
element.classList.add("com-bitwarden-browser-animated-fill");
setTimeout(() => element.classList.remove("com-bitwarden-browser-animated-fill"), 200);
}
/**
* Simulates a click event on the element.
* @param {HTMLElement} element
* @private
*/
private triggerClickOnElement(element?: HTMLElement): void {
if (typeof element?.click !== TYPE_CHECK.FUNCTION) {
return;
}
element.click();
}
/**
* Simulates a focus event on the element. Will optionally reset the value of the element
* if the element has a value property.
* @param {HTMLElement | undefined} element
* @param {boolean} shouldResetValue
* @private
*/
private triggerFocusOnElement(element: HTMLElement | undefined, shouldResetValue = false): void {
if (typeof element?.focus !== TYPE_CHECK.FUNCTION) {
return;
}
let initialValue = "";
if (shouldResetValue && "value" in element) {
initialValue = String(element.value);
}
element.focus();
if (initialValue && "value" in element) {
element.value = initialValue;
}
}
/**
* Simulates a mouse click and focus event on the element.
* @param {FormFieldElement} element
* @param {boolean} shouldResetValue
* @private
*/
private simulateUserMouseClickAndFocusEventInteractions(
element: FormFieldElement,
shouldResetValue = false,
): void {
this.triggerClickOnElement(element);
this.triggerFocusOnElement(element, shouldResetValue);
}
/**
* Simulates several keyboard events on the element, mocking a user interaction with the element.
* @param {FormFieldElement} element
* @private
*/
private simulateUserKeyboardEventInteractions(element: FormFieldElement): void {
const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP];
for (let index = 0; index < simulatedKeyboardEvents.length; index++) {
element.dispatchEvent(new KeyboardEvent(simulatedKeyboardEvents[index], { bubbles: true }));
}
}
/**
* Simulates an input change event on the element, mocking behavior that would occur if a user
* manually changed a value for the element.
* @param {FormFieldElement} element
* @private
*/
private simulateInputElementChangedEvent(element: FormFieldElement): void {
const simulatedInputEvents = [EVENTS.INPUT, EVENTS.CHANGE];
for (let index = 0; index < simulatedInputEvents.length; index++) {
element.dispatchEvent(new Event(simulatedInputEvents[index], { bubbles: true }));
}
}
}
export default InsertAutofillContentService;