Files
tubestation/toolkit/components/formautofill/FormAutofillChild.sys.mjs
Andrew McCreight a378e69577 Bug 1913520 - Make filled fields data for FormAutofill use Map instead of Object. r=credential-management-reviewers,dimi
My main motivation is that it is easier to type Maps than Objects-as-maps, but
using Objects as maps can have some odd behavior if you aren't careful, so I
think it is probably a better idea to use anyways for this data that might
be coming from a compromised child process.

This slightly alters the behavior of autofillFields because it modifies
the existing map in place rather than creating a new one from scratch,
but I think that is preferable because nothing seems to have an external
reference so there's no need to preserve the old map.

Differential Revision: https://phabricator.services.mozilla.com/D219361
2024-08-19 13:18:48 +00:00

584 lines
18 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AddressResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
AutofillTelemetry: "resource://gre/modules/shared/AutofillTelemetry.sys.mjs",
CreditCardResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
GenericAutocompleteItem: "resource://gre/modules/FillHelpers.sys.mjs",
InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs",
FormAutofill: "resource://autofill/FormAutofill.sys.mjs",
FormAutofillContent: "resource://autofill/FormAutofillContent.sys.mjs",
FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs",
FormStateManager: "resource://gre/modules/shared/FormStateManager.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
FORM_SUBMISSION_REASON: "resource://gre/actors/FormHandlerChild.sys.mjs",
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"DELEGATE_AUTOCOMPLETE",
"toolkit.autocomplete.delegate",
false
);
/**
* Handles content's interactions for the frame.
*/
export class FormAutofillChild extends JSWindowActorChild {
constructor() {
super();
this.log = lazy.FormAutofill.defineLogGetter(this, "FormAutofillChild");
this.debug("init");
this._nextHandleElements = [];
this._hasDOMContentLoadedHandler = false;
this._hasRegisteredPageHide = new Set();
/**
* @type {FormAutofillFieldDetailsManager} handling state management of current forms and handlers.
*/
this._fieldDetailsManager = new lazy.FormStateManager(
this.onFilledModified.bind(this)
);
/**
* Tracks whether the last form submission was triggered by a form submit event,
* if so we'll ignore the page navigation that follows
*/
this.isFollowingSubmitEvent = false;
}
/**
* Identifies and marks each autofill field
*/
identifyAutofillFields() {
if (
lazy.DELEGATE_AUTOCOMPLETE ||
!lazy.FormAutofillContent.savedFieldNames
) {
this.debug("identifyAutofillFields: savedFieldNames are not known yet");
// Init can be asynchronous because we don't need anything from the parent
// at this point.
this.sendAsyncMessage("FormAutofill:InitStorage");
}
for (const element of this._nextHandleElements) {
this.debug(
`identifyAutofillFields: ${element.ownerDocument.location?.hostname}`
);
const { handler, newFieldsIdentified } =
this._fieldDetailsManager.identifyAutofillFields(element);
// Bail out if there is nothing changed since last time we identified this element
// or there is no interested fields.
if (!newFieldsIdentified || !handler.fieldDetails.length) {
continue;
}
const fieldDetails = handler.fieldDetails;
// Inform the autocomplete controller these fields are autofillable.
// Bug 1905040. This is only a temporarily workaround for now to skip marking address fields
// autocompletable whenever we detect an address field. We only mark address field when
// it is a valid address section (This is done in the parent)
const fields = new Set(
fieldDetails
.map(f => f.fieldName)
.filter(fieldName => lazy.FormAutofillUtils.isAddressField(fieldName))
);
const validAddressSection =
fields.size >= lazy.FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD;
for (const fieldDetail of fieldDetails) {
if (
!validAddressSection &&
lazy.FormAutofillUtils.isAddressField(fieldDetail.fieldName)
) {
continue;
}
this.#markAsAutofillField(fieldDetail.element);
}
// Notify the parent when we detect autofillable fields.
this.sendAsyncMessage(
"FormAutofill:FieldsDetected",
fieldDetails.map(detail => detail.toVanillaObject())
);
this.manager
.getActor("FormHandler")
.registerFormSubmissionInterest(this, {
includesFormRemoval: lazy.FormAutofill.captureOnFormRemoval,
includesPageNavigation: lazy.FormAutofill.captureOnPageNavigation,
});
// TODO (Bug 1901486): Integrate pagehide to FormHandler.
if (!this._hasRegisteredPageHide.has(handler)) {
this.registerPageHide(handler);
this._hasRegisteredPageHide.add(true);
}
}
if (
this._nextHandleElements.includes(lazy.FormAutofillContent.focusedInput)
) {
this.#showCreditCardPopupIfEmpty(lazy.FormAutofillContent.focusedInput);
}
this._nextHandleElements = [];
// This is for testing purpose only which sends a notification to indicate that the
// form has been identified, and ready to open popup.
this.sendAsyncMessage("FormAutofill:FieldsIdentified");
}
#showCreditCardPopupIfEmpty(element) {
if (element.value?.length !== 0) {
this.debug(`Not opening popup because field is not empty.`);
return;
}
const handler = this._fieldDetailsManager.getFormHandler(element);
const fieldName =
handler?.getFieldDetailByElement(element)?.fieldName ?? "";
if (fieldName.startsWith("cc-") || AppConstants.platform === "android") {
lazy.FormAutofillContent.showPopup();
}
}
/**
* We received a form-submission-detected event because
* the page was navigated.
*/
onPageNavigation() {
if (!lazy.FormAutofill.captureOnPageNavigation) {
return;
}
if (this.isFollowingSubmitEvent) {
// The next page navigation should be handled as form submission again
this.isFollowingSubmitEvent = false;
return;
}
let weakIdentifiedForms = ChromeUtils.nondeterministicGetWeakMapKeys(
this._fieldDetailsManager._formsDetails
);
const formSubmissionReason = lazy.FORM_SUBMISSION_REASON.PAGE_NAVIGATION;
for (const form of weakIdentifiedForms) {
// Disconnected forms are captured by the form removal heuristic
if (!form.isConnected) {
continue;
}
this.formSubmitted(form, formSubmissionReason);
}
}
/**
* We received a form-submission-detected event because
* a form was removed from the DOM after a successful
* xhr/fetch request
*
* @param {Event} form form to be submitted
*/
onFormRemoval(form) {
if (!lazy.FormAutofill.captureOnFormRemoval) {
return;
}
const formSubmissionReason =
lazy.FORM_SUBMISSION_REASON.FORM_REMOVAL_AFTER_FETCH;
this.formSubmitted(form, formSubmissionReason);
this.manager.getActor("FormHandler").unregisterFormRemovalInterest(this);
}
registerPageHide(handler) {
// Check whether the section is in an <iframe>; and, if so,
// watch for the <iframe> to pagehide.
if (this.browsingContext != this.browsingContext.top) {
this.debug(
"Address/Credit card form is in an iframe -- watching for pagehide",
handler.fieldDetails
);
handler.window.addEventListener(
"pagehide",
() => {
this.debug("Credit card subframe is pagehiding", handler.form);
const reason = lazy.FORM_SUBMISSION_REASON.IFRAME_PAGEHIDE;
this.formSubmitted(handler.form, reason, handler);
this._hasRegisteredPageHide.delete(handler);
},
{ once: true }
);
}
}
shouldIgnoreFormAutofillEvent(event) {
let nodePrincipal = event.target.nodePrincipal;
return nodePrincipal.isSystemPrincipal || nodePrincipal.schemeIs("about");
}
handleEvent(evt) {
if (!evt.isTrusted) {
return;
}
if (this.shouldIgnoreFormAutofillEvent(evt)) {
return;
}
if (!this.windowContext) {
// !this.windowContext must not be null, because we need the
// windowContext and/or docShell to (un)register form submission listeners
return;
}
switch (evt.type) {
case "focusin": {
if (lazy.FormAutofill.isAutofillEnabled) {
this.onFocusIn(evt);
}
break;
}
case "form-submission-detected": {
if (lazy.FormAutofill.isAutofillEnabled) {
const formElement = evt.detail.form;
const formSubmissionReason = evt.detail.reason;
this.onFormSubmission(formElement, formSubmissionReason);
}
break;
}
default: {
throw new Error("Unexpected event type");
}
}
}
onFocusIn(evt) {
const element = evt.target;
if (!lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element)) {
return;
}
this.#showCreditCardPopupIfEmpty(element);
this._nextHandleElements.push(element);
const doc = element.ownerDocument;
if (doc.readyState === "loading") {
// For auto-focused input, we might receive focus event before document becomes ready.
// When this happens, run field identification after receiving `DOMContentLoaded` event
if (!this._hasDOMContentLoadedHandler) {
this._hasDOMContentLoadedHandler = true;
doc.addEventListener(
"DOMContentLoaded",
() => this.identifyAutofillFields(),
{ once: true }
);
}
return;
}
this.identifyAutofillFields();
}
/**
* Handle form-submission-detected event (dispatched by FormHandlerChild)
*
* Depending on the heuristic that detected the form submission,
* the form that is submitted is retrieved differently
*
* @param {HTMLFormElement} form that is being submitted
* @param {string} reason heuristic that detected the form submission
* (see FormHandlerChild.FORM_SUBMISSION_REASON)
*/
onFormSubmission(form, reason) {
switch (reason) {
case lazy.FORM_SUBMISSION_REASON.PAGE_NAVIGATION:
this.onPageNavigation();
break;
case lazy.FORM_SUBMISSION_REASON.FORM_SUBMIT_EVENT:
this.formSubmitted(form, reason);
break;
case lazy.FORM_SUBMISSION_REASON.FORM_REMOVAL_AFTER_FETCH:
this.onFormRemoval(form);
break;
}
}
async receiveMessage(message) {
if (!lazy.FormAutofill.isAutofillEnabled) {
return false;
}
switch (message.name) {
case "FormAutofill:FillFields": {
const { focusedElementId, ids, profile } = message.data;
const result = await this.fillFields(focusedElementId, ids, profile);
// Return the autofilled result to the parent. The result
// is used by both tests and telemetry.
return result;
}
case "FormAutofill:ClearFilledFields": {
const ids = message.data;
const handler = this.#getHandlerByElementId(ids[0]);
handler?.clearFilledFields(ids);
break;
}
case "FormAutofill:PreviewFields": {
const { ids, profile } = message.data;
const handler = this.#getHandlerByElementId(ids[0]);
handler?.previewFields(ids, profile);
break;
}
case "FormAutofill:ClearPreviewedFields": {
const { ids } = message.data;
const handler = this.#getHandlerByElementId(ids[0]);
handler?.clearPreviewedFields(ids);
break;
}
}
return true;
}
/**
* Handle a form submission and early return when:
* 1. In private browsing mode.
* 2. Could not map any autofill handler by form element.
* 3. Number of filled fields is less than autofill threshold
*
* @param {HTMLElement} formElement Root element which receives submit event.
* @param {string} formSubmissionReason Reason for invoking the form submission
* (see options for FORM_SUBMISSION_REASON in FormAutofillUtils))
* @param {object} handler FormAutofillHander, if known by caller
*/
formSubmitted(formElement, formSubmissionReason, handler = undefined) {
this.debug(`Handling form submission - infered by ${formSubmissionReason}`);
lazy.AutofillTelemetry.recordFormSubmissionHeuristicCount(
formSubmissionReason
);
if (!lazy.FormAutofill.isAutofillEnabled) {
this.debug("Form Autofill is disabled");
return;
}
// The `domWin` truthiness test is used by unit tests to bypass this check.
const domWin = formElement.ownerGlobal;
if (domWin && lazy.PrivateBrowsingUtils.isContentWindowPrivate(domWin)) {
this.debug("Ignoring submission in a private window");
return;
}
handler = handler || this._fieldDetailsManager.getFormHandler(formElement);
if (!handler) {
this.debug("Form element could not map to an existing handler");
return;
}
const formFilledData = handler.collectFormFilledData();
if (!formFilledData) {
this.debug("Form handler could not obtain filled data");
return;
}
// After a form submit event follows (most likely) a page navigation, so we set this flag
// to not handle the following one as form submission in order to avoid re-submitting the same form.
// Ideally, we should keep a record of the last submitted form details and based on that we
// should decide if we want to submit a form (bug 1895437)
this.isFollowingSubmitEvent = true;
this.sendAsyncMessage("FormAutofill:OnFormSubmit", {
rootElementId: handler.rootElementId,
formFilledData,
});
}
/**
* This is called by FormAutofillHandler
*/
onFilledModified(fieldDetail, previousState, newState) {
const element = fieldDetail.element;
if (HTMLInputElement.isInstance(element)) {
// If the user manually blanks a credit card field, then
// we want the popup to be activated.
if (
lazy.FormAutofillUtils.isCreditCardField(fieldDetail.fieldName) &&
element.value === ""
) {
lazy.FormAutofillContent.showPopup();
}
}
if (
previousState == lazy.FormAutofillUtils.FIELD_STATES.AUTO_FILLED &&
newState == lazy.FormAutofillUtils.FIELD_STATES.NORMAL
) {
this.sendAsyncMessage(
"FormAutofill:FieldFilledModified",
fieldDetail.elementId
);
}
}
async fillFields(focusedElementId, elementIds, profile) {
let result = new Map();
try {
Services.obs.notifyObservers(null, "autofill-fill-starting");
const handler = this.#getHandlerByElementId(elementIds[0]);
await handler.fillFields(focusedElementId, elementIds, profile);
// Return the autofilled result to the parent. The result
// is used by both tests and telemetry.
result = handler.collectFormFilledData();
Services.obs.notifyObservers(null, "autofill-fill-complete");
} catch {}
return result;
}
#markAsAutofillField(element) {
// Since Form Autofill popup is only for input element, any non-Input
// element should be excluded here.
if (!HTMLInputElement.isInstance(element)) {
return;
}
this.manager
.getActor("AutoComplete")
?.markAsAutoCompletableField(element, this);
}
get actorName() {
return "FormAutofill";
}
/**
* Get the search options when searching for autocomplete entries in the parent
*
* @param {HTMLInputElement} input - The input element to search for autocomplete entries
* @returns {object} the search options for the input
*/
getAutoCompleteSearchOption(input) {
const fieldDetail = this._fieldDetailsManager
.getFormHandler(input)
?.getFieldDetailByElement(input);
const scenarioName = lazy.FormScenarios.detect({ input }).signUpForm
? "SignUpFormScenario"
: "";
return {
fieldName: fieldDetail?.fieldName,
elementId: fieldDetail?.elementId,
scenarioName,
};
}
/**
* Ask the provider whether it might have autocomplete entry to show
* for the given input.
*
* @param {HTMLInputElement} input - The input element to search for autocomplete entries
* @returns {boolean} true if we shold search for autocomplete entries
*/
shouldSearchForAutoComplete(input) {
const fieldDetail = this._fieldDetailsManager
.getFormHandler(input)
?.getFieldDetailByElement(input);
if (!fieldDetail) {
return false;
}
const fieldName = fieldDetail.fieldName;
const isAddressField = lazy.FormAutofillUtils.isAddressField(fieldName);
const searchPermitted = isAddressField
? lazy.FormAutofill.isAutofillAddressesEnabled
: lazy.FormAutofill.isAutofillCreditCardsEnabled;
// If the specified autofill feature is pref off, do not search
if (!searchPermitted) {
return false;
}
// No profile can fill the currently-focused input.
if (!lazy.FormAutofillContent.savedFieldNames.has(fieldName)) {
return false;
}
return true;
}
/**
* Convert the search result to autocomplete results
*
* @param {string} searchString - The string to search for
* @param {HTMLInputElement} input - The input element to search for autocomplete entries
* @param {Array<object>} records - autocomplete records
* @returns {AutocompleteResult}
*/
searchResultToAutoCompleteResult(searchString, input, records) {
if (!records) {
return null;
}
const handler = this._fieldDetailsManager.getFormHandler(input);
const fieldDetail = handler?.getFieldDetailByElement(input);
if (!fieldDetail) {
return null;
}
const adaptedRecords = handler.getAdaptedProfiles(records.records);
const isSecure = lazy.InsecurePasswordUtils.isFormSecure(handler.form);
const isInputAutofilled =
input.autofillState == lazy.FormAutofillUtils.FIELD_STATES.AUTO_FILLED;
const AutocompleteResult = lazy.FormAutofillUtils.isAddressField(
fieldDetail.fieldName
)
? lazy.AddressResult
: lazy.CreditCardResult;
const acResult = new AutocompleteResult(
searchString,
fieldDetail,
records.allFieldNames,
adaptedRecords,
{ isSecure, isInputAutofilled }
);
const externalEntries = records.externalEntries;
acResult.externalEntries.push(
...externalEntries.map(
entry =>
new lazy.GenericAutocompleteItem(
entry.image,
entry.title,
entry.subtitle,
entry.fillMessageName,
entry.fillMessageData
)
)
);
return acResult;
}
#getHandlerByElementId(elementId) {
const element = lazy.FormAutofillUtils.getElementByIdentifier(elementId);
return this._fieldDetailsManager.getFormHandler(element);
}
}