Bug 1884632 - P1. Handle heuristic for page navigations centrally in FormHandlerChild r=dimi,credential-management-reviewers,sgalich,geckoview-reviewers,owlish

FormHandler is a central place for heuristics that other components like the LoginManager and FormAutofill
rely on. This patch moves the heuristics that detect page navigations to the FormHandler.
It also applies two changes:
- Heuristic to capture on page navigation no longer relies on the process' active element in FormAutofill
- Capturing in cross-origin frames is introduced

Introduced page navigation heuristic:
When LoginManager/FormAutofill detect a form that they expect a submission for, a FormHandler actor pair is
created in the current window context, which registers the web progress listener that listens for "page navigations",
e.g. location changes of the observed window/document or history session changes.
- If the form is in a same-orign frame, we register the listener only at the top level.
- If the form is in a cross-origin frame, we additionally set up a listener with the root
  of the cross-origin process, so that we are aware of page navigations in both processes.
When a page navigation is observed, all existing (same-origin and cross-origin) FormHandler parents in the
browsing context subtree notify their children.
(Note: We don't create any new actors in this step, because they won't have any form to submit anyway).
When the corresponding FormHandlerChild(ren) are notified of the page navigation, they fire the "form-submission-detected" event.

On "form-submission-detected" event:
- The LoginManagerChild instance(s) kept track of all modified login forms and infers capturing them.
- The FormAutofillChild instance(s) kept track of all identified formautofill forms and infers capturing them.

Differential Revision: https://phabricator.services.mozilla.com/D204927
This commit is contained in:
jneuberger 2024-05-13 21:31:38 +00:00
parent bba6492d85
commit 04bddea373
9 changed files with 425 additions and 287 deletions

View file

@ -171,7 +171,7 @@ this.formautofill = class extends ExtensionAPI {
esModuleURI: "resource://autofill/FormAutofillChild.sys.mjs",
events: {
focusin: {},
"form-submission-detected": {},
"form-submission-detected": { createActor: false },
},
},
allFrames: true,

View file

@ -792,7 +792,7 @@ function startup() {
esModuleURI: "resource://autofill/FormAutofillChild.sys.mjs",
events: {
focusin: {},
DOMFormBeforeSubmit: {},
"form-submission-detected": {},
},
},
allFrames: true,

View file

@ -35,64 +35,6 @@ const formFillController = Cc[
"@mozilla.org/satchel/form-fill-controller;1"
].getService(Ci.nsIFormFillController);
const observer = {
QueryInterface: ChromeUtils.generateQI([
"nsIWebProgressListener",
"nsISupportsWeakReference",
]),
onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
// Only handle pushState/replaceState here.
if (
!(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) ||
!(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE)
) {
return;
}
const window = aWebProgress.DOMWindow;
const formAutofillChild = window.windowGlobalChild.getActor("FormAutofill");
formAutofillChild.onPageNavigation();
},
onStateChange(aWebProgress, aRequest, aStateFlags, _aStatus) {
if (
// if restoring a previously-rendered presentation (bfcache)
aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING &&
aStateFlags & Ci.nsIWebProgressListener.STATE_STOP
) {
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 a formautofill data.
let channel = aRequest.QueryInterface(Ci.nsIChannel);
let triggeringPrincipal = channel.loadInfo.triggeringPrincipal;
if (
triggeringPrincipal.isNullPrincipal ||
triggeringPrincipal.equals(
Services.scriptSecurityManager.getSystemPrincipal()
)
) {
return;
}
// Don't handle history navigation, reload, or pushState not triggered via chrome UI.
// e.g. history.go(-1), location.reload(), history.replaceState()
if (!(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_NORMAL)) {
return;
}
const window = aWebProgress.DOMWindow;
const formAutofillChild = window.windowGlobalChild.getActor("FormAutofill");
formAutofillChild.onPageNavigation();
},
};
/**
* Handles content's interactions for the frame.
*/
@ -119,6 +61,12 @@ export class FormAutofillChild extends JSWindowActorChild {
);
lazy.AutoCompleteChild.addPopupStateListener(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;
}
didDestroy() {
@ -180,9 +128,8 @@ export class FormAutofillChild extends JSWindowActorChild {
if (lazy.FormAutofill.captureOnFormRemoval) {
this.registerDOMDocFetchSuccessEventListener();
}
if (lazy.FormAutofill.captureOnPageNavigation) {
this.registerProgressListener();
}
this.manager.getActor("FormHandler").registerFormSubmissionInterest();
}
this._hasPendingTask = false;
@ -195,108 +142,30 @@ export class FormAutofillChild extends JSWindowActorChild {
}
/**
* Gets the highest accessible docShell
*
* @returns {DocShell} highest accessible docShell
*/
getHighestDocShell() {
const window = this.document.defaultView;
let docShell;
for (
let browsingContext = BrowsingContext.getFromWindow(window);
browsingContext?.docShell;
browsingContext = browsingContext.parent
) {
docShell = browsingContext.docShell;
}
return docShell
.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebProgress);
}
/**
* After being notified of a page navigation, we check whether
* the navigated window is the active window or one of its parents
* (active window = activeHandler.window)
*
* @returns {boolean} whether the navigation affects the active window
*/
isActiveWindowNavigation() {
const activeWindow = lazy.FormAutofillContent.activeHandler?.window;
const navigatedWindow = this.document.defaultView;
if (!activeWindow || !navigatedWindow) {
return false;
}
const navigatedBrowsingContext =
BrowsingContext.getFromWindow(navigatedWindow);
for (
let browsingContext = BrowsingContext.getFromWindow(activeWindow);
browsingContext?.docShell;
browsingContext = browsingContext.parent
) {
if (navigatedBrowsingContext === browsingContext) {
return true;
}
}
return false;
}
/**
* Infer a form submission after document is navigated
* We received a form-submission-detected event because
* the page was navigated.
*/
onPageNavigation() {
if (!this.isActiveWindowNavigation()) {
if (!lazy.FormAutofill.captureOnPageNavigation) {
return;
}
// TODO: We should not use FormAutofillContent and let the
// parent decides which child to notify
const activeChild = lazy.FormAutofillContent.activeAutofillChild;
const activeElement = activeChild.activeFieldDetail?.elementWeakRef.deref();
if (!activeElement) {
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;
// We only capture the form of the active field right now,
// this means that we might miss some fields (see bug 1871356)
activeChild.formSubmitted(activeElement, formSubmissionReason);
}
/**
* After a form submission we unregister the
* nsIWebProgressListener from the top level doc shell
*/
unregisterProgressListener() {
const docShell = this.getHighestDocShell();
try {
docShell.removeProgressListener(observer);
} catch (ex) {
// Ignore NS_ERROR_FAILURE if the progress listener was not registered
}
}
/**
* After a focusin event and after we identified formautofill fields,
* we set up a nsIWebProgressListener that notifies of a request state
* change or window location change in the top level doc shell
*/
registerProgressListener() {
const docShell = this.getHighestDocShell();
const flags =
Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT |
Ci.nsIWebProgress.NOTIFY_LOCATION;
try {
docShell.addProgressListener(observer, flags);
} catch (ex) {
// Ignore NS_ERROR_FAILURE if the progress listener was already added
for (const form of weakIdentifiedForms) {
// Disconnected forms are captured by the form removal heuristic
if (!form.isConnected) {
continue;
}
this.formSubmitted(form, formSubmissionReason);
}
}
@ -389,7 +258,9 @@ export class FormAutofillChild extends JSWindowActorChild {
}
case "form-submission-detected": {
if (lazy.FormAutofill.isAutofillEnabled) {
this.onFormSubmission(evt);
const formElement = evt.detail.form;
const formSubmissionReason = evt.detail.reason;
this.onFormSubmission(formElement, formSubmissionReason);
}
break;
}
@ -430,13 +301,24 @@ export class FormAutofillChild extends JSWindowActorChild {
/**
* Handle form-submission-detected event (dispatched by FormHandlerChild)
*
* @param {CustomEvent} evt form-submission-detected event
* 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(evt) {
const formElement = evt.detail.form;
const formSubmissionReason = evt.detail.reason;
this.formSubmitted(formElement, formSubmissionReason);
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;
default:
throw new Error("Unknown submission reason");
}
}
/**
@ -466,16 +348,6 @@ export class FormAutofillChild extends JSWindowActorChild {
this.unregisterDOMDocFetchSuccessEventListener();
}
/**
* Unregister all listeners that notify of a form submission,
* because we just detected and acted on a form submission
*/
unregisterFormSubmissionListeners() {
this.unregisterDOMDocFetchSuccessEventListener();
this.unregisterDOMFormRemovedEventListener();
this.unregisterProgressListener();
}
async receiveMessage(message) {
if (!lazy.FormAutofill.isAutofillEnabled) {
return;
@ -563,7 +435,14 @@ export class FormAutofillChild extends JSWindowActorChild {
// Unregister the form submission listeners after handling a form submission
this.debug("Unregistering form submission listeners");
this.unregisterFormSubmissionListeners();
this.unregisterDOMDocFetchSuccessEventListener();
this.unregisterDOMFormRemovedEventListener();
// 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;
[records.address, records.creditCard].forEach((rs, idx) => {
lazy.AutofillTelemetry.recordSubmittedSectionCount(

View file

@ -97,66 +97,17 @@ const observer = {
"nsISupportsWeakReference",
]),
// nsIWebProgressListener
onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
// Only handle pushState/replaceState here.
if (
!(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) ||
!(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE)
) {
return;
}
const window = aWebProgress.DOMWindow;
lazy.log(
"onLocationChange handled:",
aLocation.displaySpec,
window.document
);
LoginManagerChild.forWindow(window)._onNavigation(window.document);
},
onStateChange(aWebProgress, aRequest, aState, _aStatus) {
const window = aWebProgress.DOMWindow;
const loginManagerChild = () => LoginManagerChild.forWindow(window);
if (
aState & Ci.nsIWebProgressListener.STATE_RESTORING &&
aState & Ci.nsIWebProgressListener.STATE_STOP
) {
// Re-fill a document restored from bfcache since password field values
// aren't persisted there.
loginManagerChild()._onDocumentRestored(window.document);
return;
const window = aWebProgress.DOMWindow;
const loginManagerChild = LoginManagerChild.forWindow(window);
loginManagerChild._onDocumentRestored(window.document);
}
if (!(aState & 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 a login.
let channel = aRequest.QueryInterface(Ci.nsIChannel);
let triggeringPrincipal = channel.loadInfo.triggeringPrincipal;
if (
triggeringPrincipal.isNullPrincipal ||
triggeringPrincipal.equals(
Services.scriptSecurityManager.getSystemPrincipal()
)
) {
return;
}
// Don't handle history navigation, reload, or pushState not triggered via chrome UI.
// e.g. history.go(-1), location.reload(), history.replaceState()
if (!(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_NORMAL)) {
lazy.log(`loadType isn't LOAD_CMD_NORMAL: ${aWebProgress.loadType}.`);
return;
}
lazy.log(`Handled channel: ${channel}`);
loginManagerChild()._onNavigation(window.document);
},
// nsIDOMEventListener
@ -1460,6 +1411,12 @@ export class LoginManagerChild extends JSWindowActorChild {
*/
#fieldsWithPasswordGenerationForcedOn = new WeakSet();
/**
* Tracks whether the web progress listener that listens for the
* restoring of documents from the bfcache is already added
*/
#isListeningForDocumentRestoring = false;
static forWindow(window) {
return window.windowGlobalChild?.getActor("LoginManager");
}
@ -1594,7 +1551,7 @@ export class LoginManagerChild extends JSWindowActorChild {
break;
}
case "DOMInputPasswordAdded": {
this.#onDOMInputPasswordAdded(event, this.document.defaultView);
this.#onDOMInputPasswordAdded(event);
let formLike = lazy.LoginFormFactory.createFromField(
event.originalTarget
);
@ -1602,11 +1559,9 @@ export class LoginManagerChild extends JSWindowActorChild {
break;
}
case "form-submission-detected": {
if (lazy.LoginHelper.enabled) {
const form = event.detail.form;
const reason = event.detail.reason;
this.#onFormSubmission(form, reason);
}
const form = event.detail.form;
const reason = event.detail.reason;
this.#onFormSubmission(form, reason);
break;
}
}
@ -1641,20 +1596,21 @@ export class LoginManagerChild extends JSWindowActorChild {
});
}
setupProgressListener(window) {
if (!lazy.LoginHelper.formlessCaptureEnabled) {
/**
* Set up web progress listener that listens for the restoring of a document
* from the bfcache in order to refill the previously autofilled login fields
*/
#ensureDocumentRestoredListenerRegistered() {
if (this.#isListeningForDocumentRestoring) {
// The web progress listener is already set up
return;
}
// Get the highest accessible docshell and attach the progress listener to that.
let docShell;
for (
let browsingContext = BrowsingContext.getFromWindow(window);
browsingContext?.docShell;
browsingContext = browsingContext.parent
) {
docShell = browsingContext.docShell;
// Get the docshell of the process root and attach the progress listener to that.
let currentBrowsingContext = this.browsingContext;
while (currentBrowsingContext.parent) {
currentBrowsingContext = currentBrowsingContext.parent;
}
const docShell = currentBrowsingContext.docShell;
try {
let webProgress = docShell
@ -1665,6 +1621,7 @@ export class LoginManagerChild extends JSWindowActorChild {
Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT |
Ci.nsIWebProgress.NOTIFY_LOCATION
);
this.#isListeningForDocumentRestoring = true;
} catch (ex) {
// Ignore NS_ERROR_FAILURE if the progress listener was already added
}
@ -1776,14 +1733,26 @@ export class LoginManagerChild extends JSWindowActorChild {
/**
* 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 form submission reason (heuristic that detected the form submission)
* @param {string} reason heuristic that detected the form submission
* (see FormHandlerChild.FORM_SUBMISSION_REASON)
*/
#onFormSubmission(form, reason) {
// We're invoked before the content's |submit| event handlers, so we
// can grab form data before it might be modified (see bug 257781).
let formLike = lazy.LoginFormFactory.createFromForm(form);
this._onFormSubmit(formLike, reason);
switch (reason) {
case lazy.FORM_SUBMISSION_REASON.PAGE_NAVIGATION:
this._onPageNavigation();
break;
case lazy.FORM_SUBMISSION_REASON.FORM_SUBMIT_EVENT: {
// We're invoked before the content's |submit| event handlers, so we
// can grab form data before it might be modified (see bug 257781).
let formLike = lazy.LoginFormFactory.createFromForm(form);
this._onFormSubmit(formLike, reason);
break;
}
}
}
onDocumentVisibilityChange(event) {
@ -1828,12 +1797,15 @@ export class LoginManagerChild extends JSWindowActorChild {
return Services.cpmm.sharedData.get("isPrimaryPasswordSet");
}
#onDOMFormHasPassword(event, window) {
#onDOMFormHasPassword(event) {
if (!event.isTrusted) {
return;
}
this.setupProgressListener(window);
if (lazy.LoginHelper.formlessCaptureEnabled) {
this.manager.getActor("FormHandler").registerFormSubmissionInterest();
}
this.#ensureDocumentRestoredListenerRegistered();
const isPrimaryPasswordSet = this.#getIsPrimaryPasswordSet();
let document = event.target.ownerDocument;
@ -1924,12 +1896,15 @@ export class LoginManagerChild extends JSWindowActorChild {
.add(!!usernameField);
}
#onDOMInputPasswordAdded(event, window) {
#onDOMInputPasswordAdded(event) {
if (!event.isTrusted) {
return;
}
this.setupProgressListener(window);
if (lazy.LoginHelper.formlessCaptureEnabled) {
this.manager.getActor("FormHandler").registerFormSubmissionInterest();
}
this.#ensureDocumentRestoredListenerRegistered();
let pwField = event.originalTarget;
if (pwField.form) {
@ -2029,6 +2004,10 @@ export class LoginManagerChild extends JSWindowActorChild {
return;
}
if (lazy.LoginHelper.formlessCaptureEnabled) {
this.manager.getActor("FormHandler").registerFormSubmissionInterest();
}
// set up input event listeners so we know if the user has interacted with these fields
// * input: Listen for the field getting blanked (without blurring) or a paste
// * change: Listen for changes to the field filled with the generated password so we can preserve edits.
@ -2252,11 +2231,10 @@ export class LoginManagerChild extends JSWindowActorChild {
/**
* Fill a page that was restored from bfcache since we wouldn't receive
* DOMInputPasswordAdded or DOMFormHasPassword events for it.
* @param {Document} aDocument that was restored from bfcache.
*/
_onDocumentRestored(aDocument) {
_onDocumentRestored() {
let rootElsWeakSet =
lazy.LoginFormFactory.getRootElementsWeakSetForDocument(aDocument);
lazy.LoginFormFactory.getRootElementsWeakSetForDocument(this.document);
let weakLoginFormRootElements =
ChromeUtils.nondeterministicGetWeakSetKeys(rootElsWeakSet);
@ -2278,16 +2256,13 @@ export class LoginManagerChild extends JSWindowActorChild {
* Trigger capture on any relevant FormLikes due to a navigation alone (not
* necessarily due to an actual form submission). This method is used to
* capture logins for cases where form submit events are not used.
*
* To avoid multiple notifications for the same LoginForm, this currently
* avoids capturing when dealing with a real <form> which are ideally already
* using a submit event.
*
* @param {Document} document being navigated
*/
_onNavigation(aDocument) {
_onPageNavigation() {
if (!lazy.LoginHelper.formlessCaptureEnabled) {
return;
}
let rootElsWeakSet =
lazy.LoginFormFactory.getRootElementsWeakSetForDocument(aDocument);
lazy.LoginFormFactory.getRootElementsWeakSetForDocument(this.document);
let weakLoginFormRootElements =
ChromeUtils.nondeterministicGetWeakSetKeys(rootElsWeakSet);
@ -2304,15 +2279,15 @@ export class LoginManagerChild extends JSWindowActorChild {
}
/**
* Called by our observer when notified of a form submission.
* [Note that this happens before any DOM onsubmit handlers are invoked.]
* Called after detecting a form submission.
* Looks for a password change in the submitted form, so we can update
* our stored password.
*
* @param {LoginForm} form
* @param {LoginForm} form form to be submitted
* @param {string} reason form submission reason
*/
_onFormSubmit(form, reason) {
lazy.log("Detected form submission.");
lazy.log(`Handling form submission - infered by ${reason}`);
this._maybeSendFormInteractionMessage(
form,
@ -2381,10 +2356,6 @@ export class LoginManagerChild extends JSWindowActorChild {
...docState._getFormFields(form, true, recipes, { ignoreConnect }),
};
if (fields.usernameField) {
this.markAsAutoCompletableField(fields.usernameField);
}
// It's possible the field triggering this message isn't one of those found by _getFormFields' heuristics
if (
passwordField &&
@ -2462,6 +2433,10 @@ export class LoginManagerChild extends JSWindowActorChild {
return;
}
if (usernameField) {
this.markAsAutoCompletableField(usernameField);
}
let fullyMungedPattern = /^\*+$|^•+$|^\.+$/;
// Check `isSubmission` to allow munged passwords in dismissed by default doorhangers (since
// they are initiated by the user) in case this matches their actual password.

View file

@ -3,9 +3,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* The FormHandlerChild is the place to implement logic that is shared
* by child actors like FormAutofillChild, LoginManagerChild and FormHistoryChild
* or in general components that deal with form data.
* The FormHandler actor pair implements the logic for detecting and
* notifying of form submissions. Currently the components FormAutofill
* and LoginManager are listening for the "form-submission-detected"
* event that is dispatched by the FormHandlerChild.
*/
export const FORM_SUBMISSION_REASON = {
@ -16,10 +17,30 @@ export const FORM_SUBMISSION_REASON = {
};
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;
handleEvent(event) {
if (!event.isTrusted) {
return;
}
if (!this.#hasRegisteredFormSubmissionInterest) {
return;
}
switch (event.type) {
case "DOMFormBeforeSubmit":
this.processDOMFormBeforeSubmitEvent(event);
@ -29,10 +50,24 @@ export class FormHandlerChild extends JSWindowActorChild {
}
}
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 DOMFormBeforeSubmitEvent that is dispatched
* after a form submit event. Extract event data
* that is relevant to the form submission listeners
* Process the DOMFormBeforeSubmit event that is dispatched
* after a form submit event.
*
* @param {Event} event DOMFormBeforeSubmit
*/
@ -46,15 +81,24 @@ export class FormHandlerChild extends JSWindowActorChild {
// handle form-removal-after-fetch
processFormRemovalAfterFetch(_params) {}
// handle iframe-pagehide
processIframePagehide(_params) {}
// handle page-navigation
processPageNavigation(_params) {}
/**
* 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 also transfer
* the information:
* 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)
@ -69,4 +113,168 @@ export class FormHandlerChild extends JSWindowActorChild {
});
this.document.dispatchEvent(formSubmissionEvent);
}
/**
* 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
);
}
/**
* 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.
*/
registerFormSubmissionInterest() {
if (this.#hasRegisteredFormSubmissionInterest) {
return;
}
// 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;
}
/**
* 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);
},
};

View file

@ -0,0 +1,73 @@
/* 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/. */
export class FormHandlerParent extends JSWindowActorParent {
receiveMessage(message) {
switch (message.name) {
case "FormHandler:EnsureParentExists": {
// This dummy message is sent to make sure that the parent exists,
// because we use the existence of the parent to determine whether to
// notify the corresponding child when a page navigation occurs.
break;
}
case "FormHandler:NotifyNavigatedSubtree": {
this.onPageNavigated(message.data);
break;
}
case "FormHandler:RegisterProgressListenerAtTopLevel": {
this.registerProgressListenerAtTopLevel();
break;
}
}
}
/**
* Go through the subtree of the navigated browsing context and
* let the existing FormHandler parents (same-origin and cross-origin)
* notify their corresponding children of the detected page navigation
* If the current browsing context is the navigated one, skip it,
* because the page navigation was processed directly in the child.
*
* @param {BrowsingContext} navigatedBrowsingContext
*/
onPageNavigated(navigatedBrowsingContext) {
const browsingContexts =
navigatedBrowsingContext.getAllBrowsingContextsInSubtree();
if (this.browsingContext === navigatedBrowsingContext) {
// Don't notify the child of the navigated process root,
// since the page navigation was already processed in that child
browsingContexts.shift();
}
for (const context of browsingContexts) {
const windowGlobal = context.currentWindowGlobal;
if (!windowGlobal) {
continue;
}
// This next step doesn't create the FormHandler actor pair. We only
// check whether the FormHandlerParent already exists.
// If it exists, somebody in that window context registered an interest
// in form submissions, so we send a message.
// If it doesn't exist, then nobody in that window context is interested
// in the form submissions, so we don't need to send a message.
const formHandlerActor = windowGlobal.getExistingActor("FormHandler");
formHandlerActor?.sendAsyncMessage(
"FormHandler:FormSubmissionByNavigation"
);
}
}
/**
* Send a dummy message to the FormHandlerChild of the top.
* This is to make sure that the top child is being created and has
* registered the progress listener that listens for page navigations.
*/
registerProgressListenerAtTopLevel() {
const topLevelFormHandler =
this.browsingContext.top.currentWindowGlobal.getActor("FormHandler");
topLevelFormHandler.sendAsyncMessage("FormHandler:EnsureChildExists");
}
}

View file

@ -26,7 +26,6 @@ function log(message) {
if (!lazy.gDebug) {
return;
}
dump("satchelFormListener: " + message + "\n");
Services.console.logStringMessage("satchelFormListener: " + message);
}

View file

@ -57,6 +57,7 @@ include("/ipc/chromium/chromium-config.mozbuild")
FINAL_TARGET_FILES.actors += [
"FormHandlerChild.sys.mjs",
"FormHandlerParent.sys.mjs",
"FormHistoryChild.sys.mjs",
"FormHistoryParent.sys.mjs",
]

View file

@ -325,10 +325,13 @@ let JSWINDOWACTORS = {
},
FormHandler: {
parent: {
esModuleURI: "resource://gre/actors/FormHandlerParent.sys.mjs",
},
child: {
esModuleURI: "resource://gre/actors/FormHandlerChild.sys.mjs",
events: {
DOMFormBeforeSubmit: {},
DOMFormBeforeSubmit: { createActor: false },
},
},
@ -366,7 +369,7 @@ let JSWINDOWACTORS = {
child: {
esModuleURI: "resource://gre/modules/LoginManagerChild.sys.mjs",
events: {
"form-submission-detected": {},
"form-submission-detected": { createActor: false },
DOMFormHasPassword: {},
DOMFormHasPossibleUsername: {},
DOMInputPasswordAdded: {},