Source code

Revision control

Copy as Markdown

Other Tools

/* 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/. */
/**
* The FormHandler actor pair implements the logic of detecting
* form submissions and notifies of a form submission by
* dispatching the event "form-submission-detected"
*/
export const FORM_SUBMISSION_REASON = {
FORM_SUBMIT_EVENT: "form-submit-event",
FORM_REMOVAL_AFTER_FETCH: "form-removal-after-fetch",
IFRAME_PAGEHIDE: "iframe-pagehide",
PAGE_NAVIGATION: "page-navigation",
PASSWORD_REMOVAL_AFTER_FETCH: "password-removal-after-fetch",
};
export class FormHandlerChild extends JSWindowActorChild {
actorCreated() {
// Whenever a FormHandlerChild is created it's because somebody has registered
// their interest in form submissions. This step might create FormHandler actors
// across multiple window contexts. Whenever a FormHandlerChild is created in a
// process root, we want to make sure that it registers the progress listener
// in order to listen for form submissions in that process.
if (this.manager.isProcessRoot) {
this.registerProgressListener();
}
}
/**
* Tracks whether an interest in form submissions was registered in this window
*/
#hasRegisteredFormSubmissionInterest = false;
/**
* Tracks the actors that are interested in form or password field removals from DOM
* If this set is empty, FormHandlerChild can unregister the form removal event listeners
*/
#actorsListeningForFormRemoval = new Set();
handleEvent(event) {
if (!event.isTrusted) {
return;
}
if (!this.#hasRegisteredFormSubmissionInterest) {
return;
}
switch (event.type) {
case "DOMDocFetchSuccess":
this.processDOMDocFetchSuccessEvent();
break;
case "DOMFormBeforeSubmit":
this.processDOMFormBeforeSubmitEvent(event);
break;
case "DOMFormRemoved":
this.processDOMFormRemovedEvent(event);
break;
case "DOMInputPasswordRemoved": {
this.processDOMInputPasswordRemovedEvent(event);
break;
}
default:
throw new Error("Unexpected event type");
}
}
receiveMessage(message) {
switch (message.name) {
case "FormHandler:FormSubmissionByNavigation": {
this.processPageNavigation();
break;
}
case "FormHandler:EnsureChildExists": {
// This is just a dummy message to make sure that the
// FormHandlerChild is created because then the actor
// starts listening to page navigations
break;
}
}
}
/**
* Process the DOMFormBeforeSubmit event that is dispatched
* after a form submit event.
*
* @param {Event} event DOMFormBeforeSubmit
*/
processDOMFormBeforeSubmitEvent(event) {
const form = event.target;
const formSubmissionReason = FORM_SUBMISSION_REASON.FORM_SUBMIT_EVENT;
this.#dispatchFormSubmissionEvent(form, formSubmissionReason);
}
/**
* Process the DOMDocFetchSuccess event that is dispatched
* after a successfull xhr/fetch request and start listening for
* the events DOMFormRemoved and DOMInputPasswordRemoved
*/
processDOMDocFetchSuccessEvent() {
this.document.setNotifyFormOrPasswordRemoved(true);
this.docShell.chromeEventHandler.addEventListener(
"DOMFormRemoved",
this,
true
);
this.docShell.chromeEventHandler.addEventListener(
"DOMInputPasswordRemoved",
this,
true
);
this.document.setNotifyFetchSuccess(false);
this.docShell.chromeEventHandler.removeEventListener(
"DOMDocFetchSuccess",
this
);
this.#dispatchPrepareFormSubmissionEvent();
}
/**
* Process the DOMFormRemoved event that is dispatched
* after a form was removed from the DOM.
*
* @param {Event} event DOMFormRemoved
*/
processDOMFormRemovedEvent(event) {
const form = event.target;
const formSubmissionReason =
FORM_SUBMISSION_REASON.FORM_REMOVAL_AFTER_FETCH;
this.#dispatchFormSubmissionEvent(form, formSubmissionReason);
}
/**
* Process the DOMInputPasswordRemoved event that is dispatched
* after a password input was removed from the DOM.
*
* @param {Event} event DOMInputPasswordRemoved
*/
processDOMInputPasswordRemovedEvent(event) {
const form = event.target;
const formSubmissionReason =
FORM_SUBMISSION_REASON.PASSWORD_REMOVAL_AFTER_FETCH;
this.#dispatchFormSubmissionEvent(form, formSubmissionReason);
}
/**
* This or the page of a parent browsing context was navigated,
* so process the page navigation, only when somebody in the current has
* registered interest for it
*/
processPageNavigation() {
if (!this.#hasRegisteredFormSubmissionInterest) {
// Nobody is interested in the current window
// so don't bother notifying anyone
return;
}
const formSubmissionReason = FORM_SUBMISSION_REASON.PAGE_NAVIGATION;
this.#dispatchFormSubmissionEvent(null, formSubmissionReason);
}
/**
* Dispatch the CustomEvent form-submission-detected and transfer
* the following information:
* detail.form - the form that is being submitted
* detail.reason - the heuristic that detected the form submission
* (see FORM_SUBMISSION_REASON)
*
* @param {HTMLFormElement} form
* @param {string} reason
*/
#dispatchFormSubmissionEvent(form, reason) {
const formSubmissionEvent = new CustomEvent("form-submission-detected", {
detail: { form, reason },
bubbles: true,
});
this.document.dispatchEvent(formSubmissionEvent);
}
/**
* Dispatch the before-form-submission event after receiving
* a DOMDocFetchSuccess event. This gives the listening actors a chance to
* save observed fields before they are removed from the DOM.
*/
#dispatchPrepareFormSubmissionEvent() {
const parepareFormSubmissionEvent = new CustomEvent(
"before-form-submission",
{
bubbles: true,
}
);
this.document.dispatchEvent(parepareFormSubmissionEvent);
}
/**
* A page navigation was observed in this window or in the subtree.
* If somebody in this window is interested in form submissions, process it here.
* Additionally, inform the parent of the navigation so that all FormHandler
* children in the subtree of the navigated browsing context are notified as well.
*
* @param {BrowsingContext} navigatedBrowingContext
*/
onNavigationObserved(navigatedBrowingContext) {
if (
this.#hasRegisteredFormSubmissionInterest &&
this.browsingContext == navigatedBrowingContext
) {
// This is the most probable case, that an interest in form submissions was registered
// in the navigated browing context, so we call processPageNavigation directly
// instead of letting the parent notify this actor again to process it.
this.processPageNavigation();
}
this.sendAsyncMessage(
"FormHandler:NotifyNavigatedSubtree",
navigatedBrowingContext
);
}
/**
* Set up needed listeners in order to detect form submissions after an actor indicated their interest
*
* 1. Register listeners relevant to form / password input removal heuristic
* - Set up 'DOMDocFetchSuccess' event listener (by calling setNotifyFetchSuccess)
*
* 2. Set up listeners relevant to page navigation heuristic
* - Create the corresponding parent of the current child, because the existence
* of the FormHandlerParent is the condition for being notified of a page navigation.
* If the current process is not the process root, we create the FormHandlerChild in
* the process root. The progress listener is registered after creating the child.
* If the current process is in a cross-origin frame, we notify the parent
* to register the progress listener also with the top level's process root.
*
* @param {JSWindowActorChild} interestedActor
* @param {boolean} includesFormRemoval
*/
registerFormSubmissionInterest(
interestedActor,
{ includesFormRemoval = true, includesPageNavigation = true } = {}
) {
if (includesFormRemoval) {
if (!this.#actorsListeningForFormRemoval.size) {
// The list of actors interest in form removals is empty when this is the
// first time an actor registered to be notified of form removals or when all actors
// processed their forms previously and unregistered their interest again. In both
// cases we need to set up the listener for the event 'DOMDocFetchSuccess' here.
this.document.setNotifyFetchSuccess(true);
this.docShell.chromeEventHandler.addEventListener(
"DOMDocFetchSuccess",
this,
true
);
}
this.#actorsListeningForFormRemoval.add(interestedActor);
}
if (this.#hasRegisteredFormSubmissionInterest) {
// If an actor in this window has already registered their interest
// in form submissions, then the page navigation listeners are already set up
return;
}
if (includesPageNavigation) {
// We use the existence of the FormHandlerParent on the parent side
// to determine whether to notify the corresponding FormHandleChild
// when a page is navigated. So we explicitly create the parent actor
// by sending a dummy message here
this.sendAsyncMessage("FormHandler:EnsureParentExists");
if (!this.manager.isProcessRoot) {
// The progress listener is registered after the
// FormHandlerChild is created in the process root
this.document.ownerGlobal.windowRoot.ownerGlobal.windowGlobalChild.getActor(
"FormHandler"
);
}
if (!this.manager.sameOriginWithTop) {
// If the top level is navigated, that also effects the current cross-origin frame.
// So we notify the parent to set up the progress listeners at the top as well.
this.sendAsyncMessage("FormHandler:RegisterProgressListenerAtTopLevel");
}
this.#hasRegisteredFormSubmissionInterest = true;
}
}
/**
* The actors that are interested in form submissions explicitly unregister their interest
* in form removals here. This way we can keep track if there is any interested actor left
* so that we don't remove the form removal event listeners too early, but we also don't
* listen to the form removal events for too long unnecessarily.
*
* @param {JSWindowActorChild} interestedActor
*/
unregisterFormRemovalInterest(interestedActor) {
this.#actorsListeningForFormRemoval.delete(interestedActor);
if (this.#actorsListeningForFormRemoval.size) {
// Other actors are still interested in form removals
return;
}
this.document.setNotifyFormOrPasswordRemoved(false);
this.docShell.chromeEventHandler.removeEventListener(
"DOMFormRemoved",
this
);
this.docShell.chromeEventHandler.removeEventListener(
"DOMInputPasswordRemoved",
this
);
}
/**
* Set up a nsIWebProgressListener that notifies of certain request state
* changes such as changes of the location and the history stack for this docShell
* and for the children's same-orign docShells.
*
* Note: Registering the listener only in the process root (instead of for
* every window) is enough to receive notifications for the whole process,
* because the notifications bubble up
*/
registerProgressListener() {
const webProgress = this.docShell
.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebProgress);
const flags =
Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT |
Ci.nsIWebProgress.NOTIFY_LOCATION;
try {
webProgress.addProgressListener(observer, flags);
} catch (ex) {
// Ignore NS_ERROR_FAILURE if the progress listener was already added
}
}
}
const observer = {
QueryInterface: ChromeUtils.generateQI([
"nsIWebProgressListener",
"nsISupportsWeakReference",
]),
/**
* Handle history stack changes (history.replaceState(), history.pushState())
* on the same document as page navigation
*/
onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
if (
!(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) ||
!(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE)
) {
return;
}
const navigatedWindow = aWebProgress.DOMWindow;
this.notifyProcessRootOfNavigation(navigatedWindow);
},
/*
* Handle certain state changes of requests as page navigation
* such as location changes (location.assign(), location.replace())
* See further comments for more details
*/
onStateChange(aWebProgress, aRequest, aStateFlags, _aStatus) {
if (
aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING &&
aStateFlags & Ci.nsIWebProgressListener.STATE_STOP
) {
// a document is restored from bfcache
return;
}
if (!(aStateFlags & Ci.nsIWebProgressListener.STATE_START)) {
return;
}
// We only care about when a page triggered a load, not the user. For example:
// clicking refresh/back/forward, typing a URL and hitting enter, and loading a bookmark aren't
// likely to be when a user wants to save formautofill data.
let channel = aRequest.QueryInterface(Ci.nsIChannel);
let triggeringPrincipal = channel.loadInfo.triggeringPrincipal;
if (
triggeringPrincipal.isNullPrincipal ||
triggeringPrincipal.equals(
Services.scriptSecurityManager.getSystemPrincipal()
)
) {
return;
}
// We don't handle history navigation, reloads (e.g. history.go(-1), history.back(), location.reload())
// Note: History state changes (e.g. history.replaceState(), history.pushState()) are handled in onLocationChange
if (!(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_NORMAL)) {
return;
}
const navigatedWindow = aWebProgress.DOMWindow;
this.notifyProcessRootOfNavigation(navigatedWindow);
},
/**
* Notify the current process root parent of the page navigation
* and pass on the navigated browsing context
*
* @param {Window} navigatedWindow
*/
notifyProcessRootOfNavigation(navigatedWindow) {
const processRootWindow = navigatedWindow.windowRoot.ownerGlobal;
const formHandlerChild =
processRootWindow.windowGlobalChild.getExistingActor("FormHandler");
const navigatedBrowsingContext = navigatedWindow.browsingContext;
formHandlerChild?.onNavigationObserved(navigatedBrowsingContext);
},
};