tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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:
Mtoolkit/components/extensions/ExtensionStorageIDB.sys.mjs | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mtoolkit/components/extensions/metrics.yaml | 40++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/extensions/test/xpcshell/test_ext_storage_local_corrupted_idb.js | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/components/extensions/test/xpcshell/xpcshell-common.toml | 2++
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'"]