Medium firefox Info Disclosure 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionInformation disclosure in the Form Autofill component
ComponentToolkit
Bug ClassInfo Disclosure
Tracker2054776
Fix commitc4147681c178 (firefox) +72/-21
CISA KEVNot listed
CreditedThe Mozilla Fuzzing Team
Disclosed2026-08-18

Background

Form History (satchel)
the Gecko toolkit component that records and autocompletes previously submitted form field values, backed by formhistory.sqlite.
`JSWindowActor` pair
a message-passing bridge where an untrusted content-process FormHistoryChild talks to a trusted parent-process FormHistoryParent.
`isAllowedEntry`/`isAllowedFieldname`
new central FormHistory predicates that decide whether a name/value pair may be persisted or a fieldname may be served.
`CreditCard.isValidNumber`
a helper that Luhn-validates a string to detect credit-card numbers so they are not stored.

Root Cause Analysis

The eligibility rules for what may enter form history — rejecting searchbar-history, oversized fields, and credit-card numbers — were enforced only in FormHistoryChild, which runs in the untrusted content process and whose messages the parent accepted verbatim. FormHistoryParent.#onFormSubmitEntries mapped every received entry straight into a FormHistory.update “bump” without re-validating, so a compromised or manipulated content process could bypass the child-side filters and persist sensitive data, and the parent also never checked for private-browsing mode.

The fix violates no invariant so much as it establishes one: it moves the checks into shared, trusted FormHistory.isAllowedEntry/isAllowedFieldname methods and calls them again in the parent, filtering submit entries, refusing autocomplete searches and removals for disallowed fieldnames, and returning early when the browsing context is private or unknown. Because the trusted parent now re-derives eligibility rather than trusting the child, content-supplied entries can no longer smuggle disallowed data into the persistent store.

Key insight
The single mistake was enforcing security-relevant persistence filters only in the untrusted content-process child while the trusted parent stored whatever it was handed; the fix re-validates every entry, search, and removal in the parent against centralized FormHistory predicates.

Attack Path

  1. Reach the parent A malicious page (or a compromised content process) drives the FormHistoryChild actor to emit form-submit messages to FormHistoryParent.
  2. Bypass child filters Because the credit-card, searchbar-history, and length checks lived only in the child, crafted messages arrive at the parent carrying disallowed name/value pairs.
  3. Persist sensitive data #onFormSubmitEntries bumps the entries into formhistory.sqlite without re-checking, storing card numbers, oversized values, or private-mode data.
  4. Retrieve via autocomplete A later #onAutoCompleteSearch (or a broad #onRemoveEntry unconstrained by fieldname) surfaces or manipulates the persisted entries, disclosing them.

Impact Assessment

An attacker gains persistence and later disclosure of sensitive form data — including credit-card numbers, search-bar history, and values entered during private browsing — into the durable formhistory.sqlite store where autocomplete can later reveal them. This affects the parent (privileged) process acting on data supplied by content, so the precondition is the ability to control content-process actor messages, most impactfully a compromised or manipulated content process rather than an ordinary page submission. The result is information disclosure of moderate severity, not memory corruption or code execution.

Changed Functions

FunctionChangeNotes
isAllowedFieldname
toolkit/components/satchel/FormHistory.sys.mjs
modified
isAllowedEntry
toolkit/components/satchel/FormHistory.sys.mjs
modified
if
toolkit/components/satchel/FormHistory.sys.mjs
modified
if
toolkit/components/satchel/FormHistoryChild.sys.mjs
modified
if
toolkit/components/satchel/FormHistoryParent.sys.mjs
modified

Files Changed

  • toolkit/components/satchel/FormHistory.sys.mjs
  • toolkit/components/satchel/FormHistoryChild.sys.mjs
  • toolkit/components/satchel/FormHistoryParent.sys.mjs

Audit Directions

  • Trust-boundary re-validation
    Audit every JSWindowActorParent handler that persists or queries state to confirm it re-checks constraints instead of trusting the content-process child.
  • Filter centralization
    Look for security filters (length caps, sensitive-data detection, allow/deny lists) implemented on only one side of an actor pair, and hoist them into a shared predicate enforced at the trusted end.
  • Private-browsing containment
    Verify parent-side persistence paths gate on browsingContext?.usePrivateBrowsing and treat a missing browsing context as private by default.
diff --git a/toolkit/components/satchel/FormHistory.sys.mjs b/toolkit/components/satchel/FormHistory.sys.mjs
index f621105779b..a55bcb0e7ca 100644
--- a/toolkit/components/satchel/FormHistory.sys.mjs
+++ b/toolkit/components/satchel/FormHistory.sys.mjs
@@ -86,6 +86,7 @@ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
 const lazy = {};
 
 ChromeUtils.defineESModuleGetters(lazy, {
+  CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
   Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
   setTimeout: "resource://gre/modules/Timer.sys.mjs",
 });
@@ -95,6 +96,8 @@ const DAY_IN_MS = 86400000; // 1 day in milliseconds
 const MAX_SEARCH_TOKENS = 10;
 const DB_FILENAME = "formhistory.sqlite";
 
+const MAX_FIELD_LENGTH = 200;
+
 var supportsDeletedTable = AppConstants.platform == "android";
 
 const wait = ms => new Promise(res => lazy.setTimeout(res, ms));
@@ -1015,6 +1018,45 @@ export let FormHistory = {
     return Prefs.get("enabled");
   },
 
+  /**
+   * Whether a fieldname is allowed to be written through FormHistory.
+   *
+   * @param {string} fieldname - the fieldname to test
+   * @returns {boolean}
+   */
+  isAllowedFieldname(fieldname) {
+    return !["searchbar-history"].includes(fieldname);
+  },
+
+  /**
+   * Whether an entry is eligible to be stored in form history.
+   *
+   * @param {string} name - the fieldname
+   * @param {string} value - the value
+   * @returns {boolean}
+   */
+  isAllowedEntry(name, value) {
+    if (typeof name != "string" || typeof value != "string") {
+      return false;
+    }
+
+    if (!this.isAllowedFieldname(name)) {
+      return false;
+    }
+
+    // Limit stored data to 200 characters.
+    if (name.length > MAX_FIELD_LENGTH || value.length > MAX_FIELD_LENGTH) {
+      return false;
+    }
+
+    // Don't save credit card numbers.
+    if (lazy.CreditCard.isValidNumber(value)) {
+      return false;
+    }
+
+    return true;
+  },
+
   async search(aSelectTerms, aSearchData, aRowFunc) {
     // if no terms selected, select everything
     if (!aSelectTerms) {
diff --git a/toolkit/components/satchel/FormHistoryChild.sys.mjs b/toolkit/components/satchel/FormHistoryChild.sys.mjs
index dbc3f9a1f78..52ce9d67717 100644
--- a/toolkit/components/satchel/FormHistoryChild.sys.mjs
+++ b/toolkit/components/satchel/FormHistoryChild.sys.mjs
@@ -7,7 +7,7 @@ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
 const lazy = {};
 
 ChromeUtils.defineESModuleGetters(lazy, {
-  CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+  FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
   FormHistoryAutoCompleteResult:
     "resource://gre/modules/FormHistoryAutoComplete.sys.mjs",
   FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs",
@@ -101,25 +101,13 @@ export class FormHistoryChild extends JSWindowActorChild {
         continue;
       }
 
-      // Don't save credit card numbers.
-      if (lazy.CreditCard.isValidNumber(value)) {
-        log("skipping saving a credit card number");
-        continue;
-      }
-
       const name = FormHistoryChild.getInputName(input);
       if (!name) {
         continue;
       }
 
-      if (name == "searchbar-history") {
-        log('addEntry for input name "' + name + '" is denied');
-        continue;
-      }
-
-      // Limit stored data to 200 characters.
-      if (name.length > 200 || value.length > 200) {
-        log("skipping input that has a name/value too large");
+      if (!lazy.FormHistory.isAllowedEntry(name, value)) {
+        log("skipping input that is not eligible to be stored");
         continue;
       }
 
diff --git a/toolkit/components/satchel/FormHistoryParent.sys.mjs b/toolkit/components/satchel/FormHistoryParent.sys.mjs
index 42e5054ae44..54abed8a57b 100644
--- a/toolkit/components/satchel/FormHistoryParent.sys.mjs
+++ b/toolkit/components/satchel/FormHistoryParent.sys.mjs
@@ -43,13 +43,23 @@ export class FormHistoryParent extends JSWindowActorParent {
   }
 
   #onFormSubmitEntries(entries) {
-    const changes = entries.map(entry => ({
-      op: "bump",
-      fieldname: entry.name,
-      value: entry.value,
-    }));
+    // Don't store form history in private browsing sessions, or when the
+    // browsing context cannot be determined.
+    if (this.browsingContext?.usePrivateBrowsing ?? true) {
+      return;
+    }
+
+    const changes = entries
+      .filter(entry => lazy.FormHistory.isAllowedEntry(entry.name, entry.value))
+      .map(entry => ({
+        op: "bump",
+        fieldname: entry.name,
+        value: entry.value,
+      }));
 
-    lazy.FormHistory.update(changes);
+    if (changes.length) {
+      lazy.FormHistory.update(changes);
+    }
   }
 
   get formOrigin() {
@@ -61,6 +71,11 @@ export class FormHistoryParent extends JSWindowActorParent {
   async #onAutoCompleteSearch({ searchString, params, scenarioName }) {
     searchString = searchString.trim().toLowerCase();
 
+    // The search bar manages its own history and is not served here.
+    if (!lazy.FormHistory.isAllowedFieldname(params?.fieldname)) {
+      return { formHistoryEntries: [], externalEntries: [] };
+    }
+
     let formHistoryPromise;
     if (
       FormHistoryParent.canSearchIncrementally(
@@ -99,6 +114,12 @@ export class FormHistoryParent extends JSWindowActorParent {
   }
 
   #onRemoveEntry({ inputName, value, guid }) {
+    // Removals must be scoped to a specific, allowed fieldname so the
+    // fieldname always constrains the query.
+    if (!inputName || !lazy.FormHistory.isAllowedFieldname(inputName)) {
+      return;
+    }
+
     lazy.FormHistory.update({
       op: "remove",
       fieldname: inputName,
Loading diff…