commit 4472f565485ca34d1955563b0f02b509625cc2b3
parent 2ad8fb7839696d260065d176572acfbef13f034e
Author: kpatenio <kpatenio@mozilla.com>
Date: Sat, 4 Oct 2025 00:34:50 +0000
Bug 1990013 — add a domain exclusions pref, mode pref, and exceptions manager class for ip protection. r=ip-protection-reviewers,rking
- Added a new pref browser.ipProtection.domain-exclusions listing domains to be excluded
- Added a new pref browser.ipProtection.exceptions-mode to track exclusions and inclusions
- Added a new class IPPExceptionsManager.sys.mjs that manages exceptions
- Made IPProtectionService.sys.mjs init IPPExceptionsManager.sys.mjs when loaded
- Added xpcshell tests to verify manager initialization and validation for exclusions
Differential Revision: https://phabricator.services.mozilla.com/D266211
Diffstat:
6 files changed, 462 insertions(+), 0 deletions(-)
diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
@@ -3478,6 +3478,8 @@ pref("browser.contextual-services.contextId.rust-component.enabled", true);
pref("browser.ipProtection.enabled", false);
pref("browser.ipProtection.userEnabled", false);
pref("browser.ipProtection.variant", "");
+pref("browser.ipProtection.exceptionsMode", "all");
+pref("browser.ipProtection.domainExclusions", "");
pref("browser.ipProtection.log", false);
pref("browser.ipProtection.guardian.endpoint", "https://vpn.mozilla.org/");
diff --git a/browser/components/ipprotection/IPPExceptionsManager.sys.mjs b/browser/components/ipprotection/IPPExceptionsManager.sys.mjs
@@ -0,0 +1,274 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+const MODE_PREF = "browser.ipProtection.exceptionsMode";
+const EXCLUSIONS_PREF = "browser.ipProtection.domainExclusions";
+const LOG_PREF = "browser.ipProtection.log";
+
+const MODE = {
+ ALL: "all",
+ SELECT: "select",
+};
+
+ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
+ return console.createInstance({
+ prefix: "IPPExceptionsManager",
+ maxLogLevel: Services.prefs.getBoolPref(LOG_PREF, false) ? "Debug" : "Warn",
+ });
+});
+
+/**
+ * Manages site inclusions and exclusions for IP Protection.
+ */
+class ExceptionsManager {
+ #inited = false;
+ #exclusions = null;
+ #mode = MODE.ALL;
+
+ /**
+ * The set of domains to exclude from the VPN.
+ *
+ * @returns {Set<string>}
+ * A set of domain names as strings.
+ *
+ * @example
+ * Set { "https://www.example.com", "https://www.bbc.co.uk" }
+ */
+ get exclusions() {
+ if (!this.#exclusions || !(this.#exclusions instanceof Set)) {
+ this.#exclusions = new Set();
+ }
+ return this.#exclusions;
+ }
+
+ /**
+ * The type of site exceptions for VPN.
+ *
+ * @see MODE
+ */
+ get mode() {
+ return this.#mode;
+ }
+
+ init() {
+ if (this.#inited) {
+ return;
+ }
+
+ this.#mode = this.#getModePref();
+ this.#loadExceptions();
+ this.#inited = true;
+ }
+
+ uninit() {
+ if (!this.#inited) {
+ return;
+ }
+
+ this.#unloadExceptions();
+ this.#inited = false;
+ }
+
+ #getModePref() {
+ let modePrefVal;
+
+ try {
+ modePrefVal = this.exceptionsMode;
+ } catch (e) {
+ lazy.logConsole.error(
+ `Unable to read pref ${MODE_PREF}. Falling back to default.`
+ );
+ return MODE.ALL;
+ }
+
+ if (typeof modePrefVal !== "string") {
+ lazy.logConsole.error(
+ `Mode ${modePrefVal} is not a string. Falling back to default.`
+ );
+ return MODE.ALL;
+ }
+
+ return modePrefVal;
+ }
+
+ /**
+ * Changes the protection exceptions mode.
+ *
+ * @param {"all"|"select"} newMode
+ * The type of exceptions
+ *
+ * @see MODE
+ */
+ changeMode(newMode) {
+ if (!Object.values(MODE).includes(newMode)) {
+ lazy.logConsole.error(
+ `Invalid mode ${newMode} found. Falling back to default.`
+ );
+ newMode = MODE.ALL;
+ }
+
+ this.#mode = newMode;
+ this.#updateModePref();
+ }
+
+ /**
+ * Updates the value of browser.ipProtection.exceptionsMode
+ * according to the current mode property.
+ */
+ #updateModePref() {
+ Services.prefs.setStringPref(MODE_PREF, this.#mode);
+ }
+
+ #getExceptionPref(pref) {
+ let prefString;
+
+ if (pref === EXCLUSIONS_PREF) {
+ prefString = this.domainExclusions;
+ }
+
+ if (typeof prefString !== "string") {
+ lazy.logConsole.error(`${prefString} is not a string`);
+ return "";
+ }
+
+ return prefString;
+ }
+
+ /**
+ * If mode is MODE.ALL, initializes the exclusions set with domains from
+ * browser.ipProtection.domainExclusions.
+ *
+ * @see MODE
+ * @see exclusions
+ */
+ #loadExceptions() {
+ if (this.#mode == MODE.ALL) {
+ this.#loadExclusions();
+ }
+ }
+
+ #loadExclusions() {
+ this.#exclusions = new Set();
+ let prefString = this.#getExceptionPref(EXCLUSIONS_PREF);
+
+ if (!prefString) {
+ return;
+ }
+
+ let domains = prefString.trim().split(",");
+
+ for (let domain of domains) {
+ if (!this.#canExcludeDomain(domain)) {
+ continue;
+ }
+
+ let uri = Services.io.newURI(domain);
+ this.#exclusions.add(uri.prePath);
+ }
+ }
+
+ /**
+ * Checks if we can exclude a domain from VPN usage.
+ *
+ * @param {string} domain
+ * The domain name.
+ * @returns {boolean}
+ * True if we can exclude the domain because it meets our exclusion rules.
+ * Else false.
+ */
+ #canExcludeDomain(domain) {
+ try {
+ return !!Services.io.newURI(domain);
+ } catch (e) {
+ lazy.logConsole.error(e);
+ return false;
+ }
+ }
+
+ /**
+ * If mode is MODE.ALL, adds a new domain the exclusions set if the domain is valid.
+ *
+ * @param {string} domain
+ * The domain to add to the exclusions or inclusions set.
+ *
+ * @see MODE
+ * @see exclusions
+ */
+ addException(domain) {
+ // TODO: to be called by IPProtectionPanel or other classes (Bug 1990975, Bug 1990972)
+ if (this.#mode == MODE.ALL) {
+ this.#addExclusion(domain);
+ }
+ }
+
+ #addExclusion(domain) {
+ if (!this.#canExcludeDomain(domain)) {
+ return;
+ }
+
+ this.#exclusions.add(domain);
+ this.#updateExclusionPref();
+ }
+
+ /**
+ * If mode is MODE.ALL, removes a domain from the exclusions set.
+ *
+ * @param {string} domain
+ * The domain to remove from the exclusions or inclusions set.
+ *
+ * @see MODE
+ * @see exclusions
+ */
+ removeException(domain) {
+ // TODO: to be called by IPProtectionPanel or other classes (Bug 1990975, Bug 1990972)
+ if (this.#mode == MODE.ALL) {
+ this.#removeExclusion(domain);
+ }
+ }
+
+ #removeExclusion(domain) {
+ if (this.#exclusions.delete(domain)) {
+ this.#updateExclusionPref();
+ }
+ }
+
+ /**
+ * Updates the value of browser.ipProtection.domainExclusions
+ * according to the latest version of the exclusions set.
+ */
+ #updateExclusionPref() {
+ let newPrefString = [...this.#exclusions].join(",");
+ Services.prefs.setStringPref(EXCLUSIONS_PREF, newPrefString);
+ }
+
+ /**
+ * Clear the exclusions set.
+ */
+ #unloadExceptions() {
+ // TODO: clear inclusions set here too
+ this.#exclusions = null;
+ }
+}
+
+const IPPExceptionsManager = new ExceptionsManager();
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ IPPExceptionsManager,
+ "domainExclusions",
+ EXCLUSIONS_PREF,
+ ""
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ IPPExceptionsManager,
+ "exceptionsMode",
+ MODE_PREF,
+ MODE.ALL
+);
+
+export { IPPExceptionsManager };
diff --git a/browser/components/ipprotection/IPProtectionHelpers.sys.mjs b/browser/components/ipprotection/IPProtectionHelpers.sys.mjs
@@ -8,6 +8,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
CustomizableUI:
"moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
+ IPPExceptionsManager:
+ "resource:///modules/ipprotection/IPPExceptionsManager.sys.mjs",
IPProtection: "resource:///modules/ipprotection/IPProtection.sys.mjs",
IPProtectionWidget: "resource:///modules/ipprotection/IPProtection.sys.mjs",
IPProtectionService:
@@ -41,6 +43,7 @@ class UIHelper {
this.handleEvent
);
lazy.IPProtection.uninit();
+ lazy.IPPExceptionsManager.uninit();
}
#handleEvent(_event) {
@@ -52,6 +55,7 @@ class UIHelper {
state !== lazy.IPProtectionStates.UNAVAILABLE
) {
lazy.IPProtection.init();
+ lazy.IPPExceptionsManager.init();
}
}
}
diff --git a/browser/components/ipprotection/moz.build b/browser/components/ipprotection/moz.build
@@ -12,6 +12,7 @@ JAR_MANIFESTS += ["jar.mn"]
EXTRA_JS_MODULES.ipprotection += [
"GuardianClient.sys.mjs",
"IPPChannelFilter.sys.mjs",
+ "IPPExceptionsManager.sys.mjs",
"IPPNetworkErrorObserver.sys.mjs",
"IPPProxyManager.sys.mjs",
"IPProtection.sys.mjs",
diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPPExceptionsManager_exclusions.js b/browser/components/ipprotection/tests/xpcshell/test_IPPExceptionsManager_exclusions.js
@@ -0,0 +1,179 @@
+/* Any copyright is dedicated to the Public Domain.
+https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { IPPExceptionsManager } = ChromeUtils.importESModule(
+ "resource:///modules/ipprotection/IPPExceptionsManager.sys.mjs"
+);
+
+const EXCLUSIONS_PREF = "browser.ipProtection.domainExclusions";
+
+/**
+ * Tests manager initialization when there are no exclusions.
+ */
+add_task(async function test_IPPExceptionsManager_init_with_no_exclusions() {
+ const stringPref = "";
+
+ Services.prefs.setStringPref(EXCLUSIONS_PREF, stringPref);
+
+ IPPExceptionsManager.init();
+
+ Assert.ok(IPPExceptionsManager.exclusions, "exclusions set found");
+ Assert.ok(
+ !IPPExceptionsManager.exclusions.size,
+ "exclusions set should be empty"
+ );
+
+ let newStringPref = Services.prefs.getStringPref(EXCLUSIONS_PREF);
+
+ Assert.ok(!newStringPref, "String pref should be empty");
+
+ Services.prefs.clearUserPref(EXCLUSIONS_PREF);
+
+ IPPExceptionsManager.uninit();
+});
+
+/**
+ * Tests the manager initialization with registered exclusions.
+ */
+add_task(async function test_IPPExceptionsManager_init_with_exclusions() {
+ const site1 = "https://www.example.com";
+ const site2 = "https://www.example.org";
+ const site3 = "https://www.another.example.ca";
+ const stringPref = `${site1},${site2},${site3}`;
+
+ Services.prefs.setStringPref(EXCLUSIONS_PREF, stringPref);
+
+ IPPExceptionsManager.init();
+
+ Assert.ok(IPPExceptionsManager.exclusions, "exclusions set found");
+ Assert.equal(
+ IPPExceptionsManager.exclusions.size,
+ 3,
+ "exclusions set should have 3 domains"
+ );
+
+ Assert.ok(
+ IPPExceptionsManager.exclusions.has(site1),
+ `exclusions set should include ${site1}`
+ );
+ Assert.ok(
+ IPPExceptionsManager.exclusions.has(site2),
+ `exclusions set should include ${site2}`
+ );
+ Assert.ok(
+ IPPExceptionsManager.exclusions.has(site3),
+ `exclusions set should include ${site3}`
+ );
+
+ let newStringPref = Services.prefs.getStringPref(EXCLUSIONS_PREF);
+
+ Assert.ok(newStringPref.includes(site1), `String pref includes ${site1}`);
+ Assert.ok(newStringPref.includes(site2), `String pref includes ${site2}`);
+ Assert.ok(newStringPref.includes(site3), `String pref includes ${site3}`);
+
+ Services.prefs.clearUserPref(EXCLUSIONS_PREF);
+ IPPExceptionsManager.uninit();
+});
+
+/**
+ * Tests the manager initialization with an invalid pref string for exclusions.
+ */
+add_task(
+ async function test_IPPExceptionsManager_init_with_invalid_exclusions() {
+ const invalidStringPref = "noturl";
+
+ Services.prefs.setStringPref(EXCLUSIONS_PREF, invalidStringPref);
+
+ IPPExceptionsManager.init();
+
+ Assert.ok(IPPExceptionsManager.exclusions, "exclusions set found");
+ Assert.ok(
+ !IPPExceptionsManager.exclusions.size,
+ "exclusions set should have 0 valid domains"
+ );
+
+ Services.prefs.clearUserPref(EXCLUSIONS_PREF);
+ IPPExceptionsManager.uninit();
+ }
+);
+
+/**
+ * Tests that we can add valid domains to the exclusions set.
+ */
+add_task(async function test_IPPExceptionsManager_add_exclusions() {
+ const site1 = "https://www.example.com";
+
+ Services.prefs.setStringPref(EXCLUSIONS_PREF, site1);
+
+ IPPExceptionsManager.init();
+
+ Assert.ok(IPPExceptionsManager.exclusions, "exclusions set found");
+
+ const validSite = "https://www.another.example.com";
+ const dupeSite = site1;
+ const invalidSite = "noturl";
+
+ IPPExceptionsManager.addException(validSite);
+ IPPExceptionsManager.addException(dupeSite);
+ IPPExceptionsManager.addException(invalidSite);
+ IPPExceptionsManager.addException(null);
+ IPPExceptionsManager.addException(undefined);
+
+ Assert.equal(
+ IPPExceptionsManager.exclusions.size,
+ 2,
+ "exclusions set should only have 2 domains"
+ );
+
+ Assert.ok(
+ IPPExceptionsManager.exclusions.has(site1),
+ `exclusions set should include ${site1}`
+ );
+ Assert.ok(
+ IPPExceptionsManager.exclusions.has(validSite),
+ `exclusions set should include ${validSite}`
+ );
+
+ let newStringPref = Services.prefs.getStringPref(EXCLUSIONS_PREF);
+
+ Assert.ok(newStringPref.includes(site1), `String pref includes ${site1}`);
+ Assert.ok(
+ newStringPref.includes(validSite),
+ `String pref includes ${validSite}`
+ );
+
+ Services.prefs.clearUserPref(EXCLUSIONS_PREF);
+ IPPExceptionsManager.uninit();
+});
+
+/**
+ * Tests that we can remove domains from the exclusions set.
+ */
+add_task(async function test_IPPExceptionsManager_remove_exclusions() {
+ const site1 = "https://www.example.com";
+
+ Services.prefs.setStringPref(EXCLUSIONS_PREF, site1);
+
+ IPPExceptionsManager.init();
+
+ Assert.ok(IPPExceptionsManager.exclusions, "exclusions set found");
+
+ const invalidSite = "urlDoesntExist";
+
+ IPPExceptionsManager.removeException(site1);
+ IPPExceptionsManager.removeException(invalidSite);
+
+ Assert.ok(
+ !IPPExceptionsManager.exclusions.size,
+ "exclusions set should be empty"
+ );
+
+ let newStringPref = Services.prefs.getStringPref(EXCLUSIONS_PREF);
+
+ Assert.ok(!newStringPref, "String pref should be empty");
+
+ Services.prefs.clearUserPref(EXCLUSIONS_PREF);
+ IPPExceptionsManager.uninit();
+});
diff --git a/browser/components/ipprotection/tests/xpcshell/xpcshell.toml b/browser/components/ipprotection/tests/xpcshell/xpcshell.toml
@@ -8,6 +8,8 @@ prefs = [
["test_GuardianClient.js"]
+["test_IPPExceptionsManager_exclusions.js"]
+
["test_IPProtection.js"]
["test_IPProtectionPanel.js"]