CVE-2026-74966
Overview
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
FormHistoryChildtalks to a trusted parent-processFormHistoryParent. - `isAllowedEntry`/`isAllowedFieldname`
- new central
FormHistorypredicates that decide whether aname/valuepair 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.
FormHistory predicates.Attack Path
- Reach the parent
A malicious page (or a compromised content process) drives the
FormHistoryChildactor to emit form-submit messages toFormHistoryParent. - Bypass child filters
Because the credit-card,
searchbar-history, and length checks lived only in the child, crafted messages arrive at the parent carrying disallowedname/valuepairs. - Persist sensitive data
#onFormSubmitEntriesbumps the entries intoformhistory.sqlitewithout re-checking, storing card numbers, oversized values, or private-mode data. - Retrieve via autocomplete
A later
#onAutoCompleteSearch(or a broad#onRemoveEntryunconstrained by fieldname) surfaces or manipulates the persisted entries, disclosing them.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
isAllowedFieldnametoolkit/components/satchel/FormHistory.sys.mjs |
modified | |
isAllowedEntrytoolkit/components/satchel/FormHistory.sys.mjs |
modified | |
iftoolkit/components/satchel/FormHistory.sys.mjs |
modified | |
iftoolkit/components/satchel/FormHistoryChild.sys.mjs |
modified | |
iftoolkit/components/satchel/FormHistoryParent.sys.mjs |
modified |
Files Changed
toolkit/components/satchel/FormHistory.sys.mjstoolkit/components/satchel/FormHistoryChild.sys.mjstoolkit/components/satchel/FormHistoryParent.sys.mjs
Audit Directions
- Trust-boundary re-validationAudit every
JSWindowActorParenthandler that persists or queries state to confirm it re-checks constraints instead of trusting the content-process child. - Filter centralizationLook 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 containmentVerify parent-side persistence paths gate on
browsingContext?.usePrivateBrowsingand treat a missing browsing context as private by default.
Patch
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,