commit 2cb6d175faeb898a9f06659143349146e6865e9e
parent cde75246bb966f5e59cf44b7680550e242036e31
Author: Luca Greco <lgreco@mozilla.com>
Date: Wed, 8 Oct 2025 18:25:01 +0000
Bug 1979997 - Reset storage.local IDB if internal objectStore is detect as missing in ExtensionStorageLocalIDB openForPrincipal method. r=robwu
Differential Revision: https://phabricator.services.mozilla.com/D262485
Diffstat:
4 files changed, 349 insertions(+), 7 deletions(-)
diff --git a/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs b/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs
@@ -9,6 +9,16 @@ const lazy = XPCOMUtils.declareLazy({
ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs",
ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs",
getTrimmedString: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
+
+ disabledAutoResetOnCorrupted: {
+ // NOTE: this pref is meant to disable the auto reset of the
+ // IndexedDB backend database when it is detected as corrupted
+ // for debugging purpose.
+ pref: "extensions.webextensions.keepStorageOnCorrupted.storageLocal",
+ // TODO(Bug 1992973): change the default behavior as part of enabling auto-reset
+ // corrupted storage.local IndexedDB databases on all channels.
+ default: true,
+ },
});
// The userContextID reserved for the extension storage (its purpose is ensuring that the IndexedDB
@@ -158,20 +168,84 @@ var ErrorsTelemetry = {
},
};
-class ExtensionStorageLocalIDB extends IndexedDB {
+export class ExtensionStorageLocalIDB extends IndexedDB {
onupgradeneeded(event) {
if (event.oldVersion < 1) {
this.createObjectStore(IDB_DATA_STORENAME);
}
}
- static openForPrincipal(storagePrincipal) {
+ static get disabledAutoResetOnCorrupted() {
+ return lazy.disabledAutoResetOnCorrupted;
+ }
+
+ static async openForPrincipal(storagePrincipal) {
// The db is opened using an extension principal isolated in a reserved user context id.
- return /** @type {Promise<ExtensionStorageLocalIDB>} */ (
- super.openForPrincipal(storagePrincipal, IDB_NAME, {
- version: IDB_VERSION,
- })
- );
+ let result = await super.openForPrincipal(storagePrincipal, IDB_NAME, {
+ version: IDB_VERSION,
+ });
+
+ const isMissingObjestStore = db =>
+ !db.objectStoreNames.contains(IDB_DATA_STORENAME) &&
+ db.version >= IDB_VERSION;
+
+ // Delete and recreate the database from scratch if the expected object store
+ // isn't found in objectStoreNames DOMStringList.
+ //
+ // NOTE: the onupgradeneeded handler is expected to be executed before openForPrincipal
+ // resolves, and so if at this point the expected object store name isn't found, then
+ // it means that the database got corrupted (and if the database version is still
+ // set then the onupgradeneeded function would never recreate it).
+ if (isMissingObjestStore(result.db)) {
+ Glean.extensionsData.storageLocalCorruptedReset.record({
+ addon_id: storagePrincipal.addonId,
+ reason: "ObjectStoreNotFound",
+ after_reset: false,
+ reset_disabled: lazy.disabledAutoResetOnCorrupted,
+ });
+
+ if (!lazy.disabledAutoResetOnCorrupted) {
+ let resetErrorName = null;
+ try {
+ await this.resetForPrincipal(storagePrincipal);
+ } catch (err) {
+ Cu.reportError(err);
+ resetErrorName = ErrorsTelemetry.getErrorName(err);
+ }
+
+ // Now try again to open the db, which should create the object store
+ // from the onupgradedneeded event listener.
+ result = await super.openForPrincipal(storagePrincipal, IDB_NAME, {
+ version: IDB_VERSION,
+ });
+ // throw an error more specific than "An unexpected error occurred" if objectStoreNames
+ // doesn't still include the expected object store name.
+ if (isMissingObjestStore(result.db)) {
+ Glean.extensionsData.storageLocalCorruptedReset.record({
+ addon_id: storagePrincipal.addonId,
+ reason: "ObjectStoreNotFound",
+ after_reset: true,
+ reset_disabled: lazy.disabledAutoResetOnCorrupted,
+ reset_error_name: resetErrorName,
+ });
+ const { ExtensionError } = lazy.ExtensionUtils;
+ throw new ExtensionError("Corrupted storage.local backend");
+ }
+ }
+ }
+ /** @type {Promise<ExtensionStorageLocalIDB>} */
+ return result;
+ }
+
+ static async resetForPrincipal(storagePrincipal) {
+ await new Promise(resolve => {
+ // NOTE: using clearStoragesForPrincipal here to make sure we are completely
+ // dropping the corrupted indexeddb (storagePrincipal is only used for the storage.local
+ // IndexedDB backend and so the call that follows will not be clearing other storage
+ // backends that belongs to the API).
+ let req = Services.qms.clearStoragesForPrincipal(storagePrincipal);
+ req.callback = resolve;
+ });
}
async isEmpty() {
diff --git a/toolkit/components/extensions/metrics.yaml b/toolkit/components/extensions/metrics.yaml
@@ -384,6 +384,46 @@ extensions.data:
type: string
expires: 147
+ storage_local_corrupted_reset:
+ type: event
+ description: |
+ These events are collected when the underlying IndexedDB backend is detected
+ as corrupted and Firefox is resetting it to allow it to be recreated from
+ scratch.
+ bugs:
+ - https://bugzilla.mozilla.org/1979997
+ data_reviews:
+ - https://bugzilla.mozilla.org/1979997
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ extra_keys:
+ addon_id:
+ description: Id of the addon.
+ type: string
+ reason:
+ description: |
+ A string that identifies the reason for resetting the storage.local IndexedDB
+ database. e.g.
+ - "ObjectStoreNotFound": (The IndexedDB instance is missing the expected
+ ObjectStorage name).
+ type: string
+ after_reset:
+ description: |
+ Set to true if the storage.local IndexedDB was still corrupted
+ right after being completely reset.
+ type: boolean
+ reset_disabled:
+ description: |
+ Whether automatically reset corrupted databases is disabled.
+ type: boolean
+ reset_error_name:
+ description: |
+ The name of the error hit by the DB reset logic (if any).
+ type: string
+ expires: 152
+
sync_usage_quotas:
type: event
description: |
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_local_corrupted_idb.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local_corrupted_idb.js
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs",
+ ExtensionStorageLocalIDB:
+ "resource://gre/modules/ExtensionStorageIDB.sys.mjs",
+ Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1");
+
+// The userContextID reserved for the extension storage.
+const WEBEXT_STORAGE_USER_CONTEXT_ID = -1 >>> 0;
+
+add_setup(async () => {
+ Services.fog.testResetFOG();
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_idb_autoreset_default() {
+ // TODO(Bug 1992973): change the expected default behavior as part of enabling auto-reset
+ // corrupted storage.local IndexedDB databases on all channels.
+ Assert.equal(
+ ExtensionStorageLocalIDB.disabledAutoResetOnCorrupted,
+ true,
+ "Expect auto-reset on corrupted IDB storage to be disabled by default"
+ );
+});
+
+// This test the same kind of unexpected corruption of the underlying
+// idb database tracked by Bug 1979997.
+add_task(async function test_idb_reset_on_missing_object_store() {
+ const id = "test-corrupted-idb@test-ext";
+
+ // NOTE: this test extension is only used to derive the storagePrincipal,
+ // it doesn't require the "storage" permission nor use the storage.local
+ // API on purpose (so that we can directly control when the IndexedDB
+ // database gets open and closed and be able to tamper its underlying
+ // sqlite3 data).
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ const { uuid } = extension;
+ const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(
+ extension.extension
+ );
+
+ // Page sure the storage directory is created and its sqlite3
+ // file initialized.
+ let idbConn = await ExtensionStorageIDB.open(storagePrincipal);
+ await idbConn.close();
+
+ const baseDataPath = PathUtils.join(
+ PathUtils.profileDir,
+ "storage",
+ "default",
+ `moz-extension+++${uuid}^userContextId=${WEBEXT_STORAGE_USER_CONTEXT_ID}`,
+ "idb"
+ );
+
+ let sqliteFilePath = (await IOUtils.getChildren(baseDataPath)).find(
+ filePath => filePath.endsWith(".sqlite")
+ );
+ info(
+ `Mock corrupted IndexedDB by tampering sqlite3 file at ${sqliteFilePath}`
+ );
+ let db = await Sqlite.openConnection({ path: sqliteFilePath });
+ let rows = await db.execute("SELECT * FROM object_store;");
+ // Sanity check.
+ Assert.equal(
+ rows[0]?.getResultByName("name"),
+ "storage-local-data",
+ "Expected object_store entry found in the IndexedDB Sqlite3 data"
+ );
+ info(
+ "Force delete the storage-local-data object_store from the sqlite3 data"
+ );
+ await db.execute("DELETE FROM object_store;");
+ rows = await db.execute("SELECT * FROM object_store;");
+ // Sanity check.
+ Assert.deepEqual(
+ rows,
+ [],
+ "Force deleted object_store should not be found in the IndexedDB sqlite3 data"
+ );
+ await db.close();
+
+ info(
+ "Verify NotFoundError expected to be raised on corrupted IndexedDB sqlite3 data"
+ );
+ // Disable automatically drop corrupted database.
+ Services.prefs.setBoolPref(
+ "extensions.webextensions.keepStorageOnCorrupted.storageLocal",
+ true
+ );
+
+ idbConn = await ExtensionStorageIDB.open(storagePrincipal);
+ await Assert.rejects(
+ idbConn.isEmpty(),
+ err => {
+ return (
+ err.name === "NotFoundError" &&
+ err.message.includes(
+ "'storage-local-data' is not a known object store name"
+ )
+ );
+ },
+ "ExtensionStorageIDB isEmpty call throws the expected NotFoundError"
+ );
+ await idbConn.close();
+
+ info(
+ "Verify storageLocalCorruptedReset collected also when corrupted db are not automatically dropped"
+ );
+ const gleanEventsWithoutResetDB =
+ Glean.extensionsData.storageLocalCorruptedReset
+ .testGetValue()
+ ?.map(event => event.extra);
+ Assert.deepEqual(
+ gleanEventsWithoutResetDB ?? [],
+ [
+ {
+ addon_id: extension.id,
+ reason: "ObjectStoreNotFound",
+ after_reset: "false",
+ reset_disabled: "true",
+ },
+ ],
+ "Got the expected telemetry event recorded when the NotFoundError is being hit"
+ );
+ Services.fog.testResetFOG();
+
+ // Enable automatically drop corrupted database.
+ Services.prefs.setBoolPref(
+ "extensions.webextensions.keepStorageOnCorrupted.storageLocal",
+ false
+ );
+
+ info("Verify corrupted IndexedDB sqlite3 Glean telemetry when reset fails");
+ const sandbox = sinon.createSandbox();
+ sandbox
+ .stub(ExtensionStorageLocalIDB, "resetForPrincipal")
+ .callsFake(() =>
+ Promise.reject(
+ new DOMException("error message", "MockResetFailureErrorName")
+ )
+ );
+ await Assert.rejects(
+ ExtensionStorageIDB.open(storagePrincipal),
+ err => {
+ return (
+ err.name === "ExtensionError" &&
+ err.message.includes("Corrupted storage.local backend")
+ );
+ },
+ "ExtensionStorageIDB open to throws the expected ExtensionError"
+ );
+ sandbox.restore();
+ const gleanEventsOnResetFailure =
+ Glean.extensionsData.storageLocalCorruptedReset
+ .testGetValue()
+ ?.map(event => event.extra);
+ Assert.deepEqual(
+ gleanEventsOnResetFailure ?? [],
+ [
+ {
+ addon_id: extension.id,
+ reason: "ObjectStoreNotFound",
+ after_reset: "false",
+ reset_disabled: "false",
+ },
+ {
+ addon_id: extension.id,
+ reason: "ObjectStoreNotFound",
+ after_reset: "true",
+ reset_disabled: "false",
+ reset_error_name: "MockResetFailureErrorName",
+ },
+ ],
+ "Got the expected telemetry event recorded when the NotFoundError is being hit"
+ );
+ Services.fog.testResetFOG();
+
+ info(
+ "Verify corrupted IndexedDB sqlite3 data dropped and recreated by default when reset succeeded"
+ );
+ idbConn = await ExtensionStorageIDB.open(storagePrincipal);
+ Assert.equal(
+ await idbConn.isEmpty(),
+ true,
+ "ExtensionStorageIDB isEmpty call resolved as expected"
+ );
+ await idbConn.close();
+
+ const gleanEvents = Glean.extensionsData.storageLocalCorruptedReset
+ .testGetValue()
+ ?.map(event => event.extra);
+ Assert.deepEqual(
+ gleanEvents ?? [],
+ [
+ {
+ addon_id: extension.id,
+ reason: "ObjectStoreNotFound",
+ after_reset: "false",
+ reset_disabled: "false",
+ },
+ ],
+ "Got the expected telemetry event recorded when the NotFoundError is being hit"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common.toml b/toolkit/components/extensions/test/xpcshell/xpcshell-common.toml
@@ -568,6 +568,8 @@ skip-if = [
["test_ext_storage_local.js"]
skip-if = ["os == 'android' && debug"]
+["test_ext_storage_local_corrupted_idb.js"]
+
["test_ext_storage_managed.js"]
skip-if = ["os == 'android'"]