diff --git a/eslint.config.js b/eslint.config.js index da156002..00421932 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -95,6 +95,7 @@ module.exports = defineConfig([{ "backgroundLogic": true, "identityState": true, "messageHandler": true, + "siteAssociation": true, "sync": true, }, }, diff --git a/src/js/background/assignManager.js b/src/js/background/assignManager.js index c857cd38..65ea48de 100644 --- a/src/js/background/assignManager.js +++ b/src/js/background/assignManager.js @@ -70,7 +70,8 @@ window.assignManager = { }); }, - async set(pageUrlorUrlKey, data, exemptedTabIds, backup = true) { + async set(pageUrlorUrlKey, data, exemptedTabIds, backup = true, + fromFirefox = false) { const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey); if (exemptedTabIds) { exemptedTabIds.forEach((tabId) => { @@ -82,6 +83,9 @@ window.assignManager = { await this.area.set({ [siteStoreKey]: data }); + if (!fromFirefox) { + await siteAssociation.set(siteStoreKey, data.userContextId); + } const syncEnabled = await this.getSyncEnabled(); if (backup && syncEnabled) { await sync.storageArea.backup({undeleteSiteStoreKey: siteStoreKey}); @@ -89,11 +93,18 @@ window.assignManager = { return; }, - async remove(pageUrlorUrlKey, shouldSync = true) { + async remove(pageUrlorUrlKey, shouldSync = true, fromFirefox = false) { const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey); + const assignment = await this.getByUrlKey(siteStoreKey); // When we remove an assignment we should clear all the exemptions this.removeExempted(pageUrlorUrlKey); await this.area.remove([siteStoreKey]); + if (!fromFirefox) { + await siteAssociation.remove(siteStoreKey); + } + if (assignment) { + await assignManager._maybeRemoveSiteIsolation(assignment.userContextId); + } const syncEnabled = await this.getSyncEnabled(); if (shouldSync && syncEnabled) await sync.storageArea.backup({siteStoreKey}); return; @@ -616,9 +627,6 @@ window.assignManager = { await this.storageArea.remove(pageUrl); actionName = "removed from assigned sites list"; - - // remove site isolation if now empty - await this._maybeRemoveSiteIsolation(userContextId); } if (tabId) { diff --git a/src/js/background/index.html b/src/js/background/index.html index 610fbbe0..a46b6655 100644 --- a/src/js/background/index.html +++ b/src/js/background/index.html @@ -24,5 +24,6 @@ + diff --git a/src/js/background/siteAssociation.js b/src/js/background/siteAssociation.js new file mode 100644 index 00000000..eaba770e --- /dev/null +++ b/src/js/background/siteAssociation.js @@ -0,0 +1,172 @@ +/* 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/. */ + +/* + * Keeps the add-on's per-site assignments and Firefox's own per-site container + * associations (bug 2052136) in step. Firefox owns the truth: we import what it + * holds at every startup, hand over the assignments it doesn't have yet, and + * from then on the changes travel both ways as they happen. + */ +window.siteAssociation = { + STORAGE_PREFIX: "siteContainerMap@@_", + + get supported() { + return !!(browser.contextualIdentities && + browser.contextualIdentities.setSiteAssociation); + }, + + init() { + this.load().catch((e) => { + console.error("Could not load the site associations", e); + }); + }, + + // Importing first is what makes Firefox win every disagreement. + async load() { + if (!this.supported) { + return; + } + + // Listen before the first query so a change racing with it isn't lost. + browser.contextualIdentities.onSiteAssociationChanged.addListener( + (changeInfo) => { + this.onChanged(changeInfo).catch((e) => { + console.error("Could not apply a site association change", e); + }); + } + ); + + let associations; + try { + associations = + await browser.contextualIdentities.querySiteAssociations({}); + } catch (e) { + // Containers can be disabled by pref, in which case the API throws. + console.error("Could not read the existing site associations", e); + return; + } + + const sitesFromFirefox = new Set(); + for (const {site, cookieStoreId} of associations) { + sitesFromFirefox.add(site); + await this.applyFromFirefox(site, cookieStoreId); + } + + const assignedSites = await assignManager.storageArea.getAssignedSites(); + for (const siteStoreKey of Object.keys(assignedSites)) { + const site = this.siteFromStoreKey(siteStoreKey); + if (sitesFromFirefox.has(site)) { + continue; + } + await this._set( + site, + backgroundLogic.cookieStoreId(assignedSites[siteStoreKey].userContextId) + ); + } + }, + + // cookieStoreId is left out when the association was removed. + async onChanged({site, cookieStoreId}) { + if (cookieStoreId) { + await this.applyFromFirefox(site, cookieStoreId); + return; + } + await this.removeFromFirefox(site); + }, + + // neverAsk stays off for a site we don't know yet: the user never went + // through our confirm page for it. + async applyFromFirefox(site, cookieStoreId) { + const userContextId = + backgroundLogic.getUserContextIdFromCookieStoreId(cookieStoreId); + if (!userContextId) { + // Not a container: we have nothing to store. + return; + } + + const siteStoreKey = `${this.STORAGE_PREFIX}${site}`; + const assignment = + await assignManager.storageArea.getByUrlKey(siteStoreKey); + if (assignment && String(assignment.userContextId) === userContextId) { + return; + } + + await assignManager.storageArea.set( + siteStoreKey, + { + userContextId, + neverAsk: assignment ? assignment.neverAsk : false + }, + undefined, // exemptedTabIds + true, // backup + true // fromFirefox + ); + }, + + async removeFromFirefox(site) { + const siteStoreKey = `${this.STORAGE_PREFIX}${site}`; + if (!await assignManager.storageArea.getByUrlKey(siteStoreKey)) { + return; + } + + await assignManager.storageArea.remove( + siteStoreKey, + true, // shouldSync + true // fromFirefox + ); + }, + + /* + * getSiteStoreKey concatenates hostname and port without a separator, so an + * assignment for https://example.com:8080 becomes "example.com8080" and can + * no longer be parsed back into a hostname. We hand that string to Firefox + * as is: it matches no navigation, so the assignment stays handled by our own + * webRequest listener. Guessing "example.com" would also capture + * https://example.com, which the user never assigned. + */ + siteFromStoreKey(pageUrlOrUrlKey) { + const siteStoreKey = + assignManager.storageArea.getSiteStoreKey(pageUrlOrUrlKey); + return siteStoreKey.slice(this.STORAGE_PREFIX.length); + }, + + async set(pageUrlOrUrlKey, userContextId) { + if (!this.supported) { + return; + } + await this._set( + this.siteFromStoreKey(pageUrlOrUrlKey), + backgroundLogic.cookieStoreId(userContextId) + ); + }, + + async remove(pageUrlOrUrlKey) { + if (!this.supported) { + return; + } + await this._remove(this.siteFromStoreKey(pageUrlOrUrlKey)); + }, + + async _set(site, cookieStoreId) { + try { + await browser.contextualIdentities.setSiteAssociation({ + site, + cookieStoreId + }); + } catch (e) { + // Invalid host or missing container: the assignment stays in storage. + console.error(`Could not associate ${site} with ${cookieStoreId}`, e); + } + }, + + async _remove(site) { + try { + await browser.contextualIdentities.removeSiteAssociation({site}); + } catch (e) { + console.error(`Could not remove the association for ${site}`, e); + } + } +}; + +siteAssociation.init(); diff --git a/test/features/assignment.test.js b/test/features/assignment.test.js index 990f3ffe..d647e674 100644 --- a/test/features/assignment.test.js +++ b/test/features/assignment.test.js @@ -1,4 +1,4 @@ -const {initializeWithTab} = require("../common"); +const {initializeWithTab, expect} = require("../common"); describe("Assignment Reopen Feature", function () { const url = "http://example.com"; @@ -36,6 +36,60 @@ describe("Assignment Reopen Feature", function () { }); +describe("Assignment Site Isolation", function () { + const cookieStoreId = "firefox-container-4"; + + beforeEach(async function () { + this.webExt = await initializeWithTab({ + cookieStoreId, + url: "http://example.com" + }); + + const {assignManager, identityState} = this.webExt.background.window; + await assignManager.storageArea.set("http://example.com", { + userContextId: "4", + neverAsk: false + }); + const state = await identityState.storageArea.get(cookieStoreId); + state.isIsolated = "locked"; + await identityState.storageArea.set(cookieStoreId, state); + }); + + afterEach(function () { + this.webExt.destroy(); + }); + + function isolationState(webExt) { + return webExt.background.window + .identityState.storageArea.get(cookieStoreId); + } + + it("should be locked while the container has an assignment", async function () { + const state = await isolationState(this.webExt); + expect(state.isIsolated).to.equal("locked"); + }); + + it("should go away with the last assignment of the container", async function () { + const {assignManager} = this.webExt.background.window; + await assignManager.storageArea.remove("http://example.com"); + + const state = await isolationState(this.webExt); + expect(state).to.not.have.property("isIsolated"); + }); + + it("should stay while the container has another assignment", async function () { + const {assignManager} = this.webExt.background.window; + await assignManager.storageArea.set("http://other.example", { + userContextId: "4", + neverAsk: false + }); + await assignManager.storageArea.remove("http://example.com"); + + const state = await isolationState(this.webExt); + expect(state.isIsolated).to.equal("locked"); + }); +}); + describe("Assignment Comfirm Page Feature", function () { const url = "http://example.com"; diff --git a/test/features/site-association.test.js b/test/features/site-association.test.js new file mode 100644 index 00000000..6520ec9b --- /dev/null +++ b/test/features/site-association.test.js @@ -0,0 +1,251 @@ +const {initializeWithTab, sinon, expect, nextTick} = require("../common"); + +/* + * Firefox's per-site container associations (bug 2052136) are not part of the + * fake WebExtension API, so we install the pieces we use by hand and re-run + * the loading against them. + */ +function fakeSiteAssociationAPI(webExt, associations = []) { + const {contextualIdentities} = webExt.background.browser; + contextualIdentities.querySiteAssociations = + sinon.stub().resolves(associations); + contextualIdentities.setSiteAssociation = sinon.stub().resolves(); + contextualIdentities.removeSiteAssociation = sinon.stub().resolves(); + contextualIdentities.onSiteAssociationChanged = {addListener: sinon.stub()}; + return contextualIdentities; +} + +// Hands a change to the listener siteAssociation.load() registered. The +// listener can't be awaited, so give the storage writes a tick to land. +async function fireChange(contextualIdentities, changeInfo) { + const [listener] = + contextualIdentities.onSiteAssociationChanged.addListener.firstCall.args; + listener(changeInfo); + await nextTick(); + await nextTick(); +} + +function assignedSites(webExt) { + return webExt.background.window.assignManager.storageArea.getAssignedSites(); +} + +describe("Site Associations", function () { + const url = "http://example.com"; + const exampleKey = "siteContainerMap@@_example.com"; + + beforeEach(async function () { + this.webExt = await initializeWithTab({ + cookieStoreId: "firefox-default", + url + }); + + // popup click to assign example.com to a container + await this.webExt.popup.helper.clickElementById("always-open-in"); + await this.webExt.popup.helper.clickElementByQuerySelectorAll("#picker-identities-list > .menu-item"); + }); + + afterEach(function () { + this.webExt.destroy(); + }); + + describe("on a Firefox without the API", function () { + it("should not throw when an assignment changes", async function () { + const {siteAssociation} = this.webExt.background.window; + expect(siteAssociation.supported).to.be.false; + await siteAssociation.load(); + await siteAssociation.set(url, "4"); + await siteAssociation.remove(url); + }); + }); + + describe("handing our assignments over", function () { + it("should hand over the assignments Firefox doesn't have", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt); + await this.webExt.background.window.siteAssociation.load(); + + contextualIdentities.setSiteAssociation.should.have.been.calledOnceWith({ + site: "example.com", + cookieStoreId: "firefox-container-4" + }); + contextualIdentities.removeSiteAssociation.should.not.have.been.called; + }); + + it("should not hand over an association Firefox already has", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt, [ + {site: "example.com", cookieStoreId: "firefox-container-4"} + ]); + await this.webExt.background.window.siteAssociation.load(); + + contextualIdentities.setSiteAssociation.should.not.have.been.called; + contextualIdentities.removeSiteAssociation.should.not.have.been.called; + }); + + it("should let Firefox win when the two disagree", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt, [ + {site: "example.com", cookieStoreId: "firefox-container-2"} + ]); + await this.webExt.background.window.siteAssociation.load(); + + contextualIdentities.setSiteAssociation.should.not.have.been.called; + contextualIdentities.removeSiteAssociation.should.not.have.been.called; + + const sites = await assignedSites(this.webExt); + sites[exampleKey].userContextId.should.equal("2"); + }); + + it("should hand nothing over once Firefox holds it", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt); + const {siteAssociation} = this.webExt.background.window; + await siteAssociation.load(); + + // Second startup: this time Firefox reports the association back. + contextualIdentities.setSiteAssociation.resetHistory(); + contextualIdentities.querySiteAssociations = sinon.stub().resolves([ + {site: "example.com", cookieStoreId: "firefox-container-4"} + ]); + await siteAssociation.load(); + + contextualIdentities.setSiteAssociation.should.not.have.been.called; + }); + + it("should try again when the associations can't be read", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt); + contextualIdentities.querySiteAssociations = + sinon.stub().rejects(new Error("containers are disabled")); + const {siteAssociation} = this.webExt.background.window; + await siteAssociation.load(); + contextualIdentities.setSiteAssociation.should.not.have.been.called; + + contextualIdentities.querySiteAssociations = sinon.stub().resolves([]); + await siteAssociation.load(); + contextualIdentities.setSiteAssociation.should.have.been.calledOnceWith({ + site: "example.com", + cookieStoreId: "firefox-container-4" + }); + }); + }); + + describe("importing what Firefox holds", function () { + it("should adopt the associations it doesn't know about", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt, [ + {site: "example.com", cookieStoreId: "firefox-container-4"}, + {site: "unassigned.example", cookieStoreId: "firefox-container-2"} + ]); + await this.webExt.background.window.siteAssociation.load(); + + contextualIdentities.removeSiteAssociation.should.not.have.been.called; + contextualIdentities.setSiteAssociation.should.not.have.been.called; + + const sites = await assignedSites(this.webExt); + Object.keys(sites).should.have.members([ + exampleKey, + "siteContainerMap@@_unassigned.example" + ]); + sites["siteContainerMap@@_unassigned.example"].userContextId + .should.equal("2"); + }); + + it("should adopt an association added in Firefox", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt); + await this.webExt.background.window.siteAssociation.load(); + contextualIdentities.setSiteAssociation.resetHistory(); + + await fireChange(contextualIdentities, { + site: "added.example", + cookieStoreId: "firefox-container-2" + }); + + const sites = await assignedSites(this.webExt); + sites["siteContainerMap@@_added.example"].userContextId + .should.equal("2"); + // What came from Firefox doesn't need to be pushed back to it. + contextualIdentities.setSiteAssociation.should.not.have.been.called; + }); + + it("should follow an association changed in Firefox", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt, [ + {site: "example.com", cookieStoreId: "firefox-container-4"} + ]); + await this.webExt.background.window.siteAssociation.load(); + + await fireChange(contextualIdentities, { + site: "example.com", + cookieStoreId: "firefox-container-2" + }); + + const sites = await assignedSites(this.webExt); + sites[exampleKey].userContextId.should.equal("2"); + contextualIdentities.setSiteAssociation.should.not.have.been.called; + }); + + it("should drop an association removed in Firefox", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt, [ + {site: "example.com", cookieStoreId: "firefox-container-4"} + ]); + await this.webExt.background.window.siteAssociation.load(); + + await fireChange(contextualIdentities, {site: "example.com"}); + + const sites = await assignedSites(this.webExt); + expect(sites).to.not.have.property(exampleKey); + contextualIdentities.removeSiteAssociation.should.not.have.been.called; + }); + + it("should unlock a container emptied from Firefox", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt, [ + {site: "example.com", cookieStoreId: "firefox-container-4"} + ]); + const {siteAssociation, identityState} = this.webExt.background.window; + await siteAssociation.load(); + const isolated = + await identityState.storageArea.get("firefox-container-4"); + isolated.isIsolated = "locked"; + await identityState.storageArea.set("firefox-container-4", isolated); + + await fireChange(contextualIdentities, {site: "example.com"}); + + const state = + await identityState.storageArea.get("firefox-container-4"); + expect(state).to.not.have.property("isIsolated"); + }); + + it("should ignore an association outside of a container", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt); + await this.webExt.background.window.siteAssociation.load(); + + await fireChange(contextualIdentities, { + site: "default.example", + cookieStoreId: "firefox-default" + }); + + const sites = await assignedSites(this.webExt); + expect(sites).to.not.have.property("siteContainerMap@@_default.example"); + }); + }); + + describe("forwarding our own changes", function () { + it("should tell Firefox when an assignment is removed", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt); + await this.webExt.background.window.assignManager.storageArea.remove(url); + await nextTick(); + + contextualIdentities.removeSiteAssociation.should.have.been.calledOnceWith({ + site: "example.com" + }); + }); + + it("should tell Firefox when an assignment is added", async function () { + const contextualIdentities = fakeSiteAssociationAPI(this.webExt); + await this.webExt.background.window.assignManager.storageArea.set( + "http://other.example/some/path", + {userContextId: "2", neverAsk: false} + ); + await nextTick(); + + contextualIdentities.setSiteAssociation.should.have.been.calledOnceWith({ + site: "other.example", + cookieStoreId: "firefox-container-2" + }); + }); + }); +});