tor-browser

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

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:
Mbrowser/app/profile/firefox.js | 2++
Abrowser/components/ipprotection/IPPExceptionsManager.sys.mjs | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/ipprotection/IPProtectionHelpers.sys.mjs | 4++++
Mbrowser/components/ipprotection/moz.build | 1+
Abrowser/components/ipprotection/tests/xpcshell/test_IPPExceptionsManager_exclusions.js | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/ipprotection/tests/xpcshell/xpcshell.toml | 2++
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"]