tor-browser

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

commit 0ac2f426113b56cea6794274afaec8483c707956
parent f36ac04143f4e2299c9be5ab99ac74c425103e0e
Author: Andrea Marchesini <amarchesini@mozilla.com>
Date:   Mon, 13 Oct 2025 21:56:17 +0000

Bug 1990007 - IP Protection auto-start r=ip-protection-reviewers,rking,fchasen

Differential Revision: https://phabricator.services.mozilla.com/D267242

Diffstat:
Mbrowser/app/profile/firefox.js | 1+
Abrowser/components/ipprotection/IPPAutoStart.sys.mjs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/ipprotection/IPPChannelFilter.sys.mjs | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mbrowser/components/ipprotection/IPPProxyManager.sys.mjs | 29++++++++++++++++++++---------
Mbrowser/components/ipprotection/IPProtectionHelpers.sys.mjs | 11++++++++---
Mbrowser/components/ipprotection/moz.build | 1+
Mbrowser/components/ipprotection/tests/browser/browser_IPPChannelFilter.js | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mbrowser/components/ipprotection/tests/browser/browser_ipprotection_proxy_errors.js | 8++------
Mbrowser/components/ipprotection/tests/browser/browser_ipprotection_toolbar.js | 14++++++++++++++
Mbrowser/components/ipprotection/tests/browser/browser_ipprotection_usage.js | 8++------
10 files changed, 332 insertions(+), 67 deletions(-)

diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js @@ -3468,6 +3468,7 @@ pref("browser.contextual-services.contextId.rotation-in-days", 7); pref("browser.contextual-services.contextId.rust-component.enabled", true); // Pref to enable the IP protection feature +pref("browser.ipProtection.autoStartEnabled", false); pref("browser.ipProtection.enabled", false); pref("browser.ipProtection.userEnabled", false); pref("browser.ipProtection.variant", ""); diff --git a/browser/components/ipprotection/IPPAutoStart.sys.mjs b/browser/components/ipprotection/IPPAutoStart.sys.mjs @@ -0,0 +1,160 @@ +/* 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 = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + IPProtectionService: + "resource:///modules/ipprotection/IPProtectionService.sys.mjs", + IPProtectionStates: + "resource:///modules/ipprotection/IPProtectionService.sys.mjs", +}); + +const AUTOSTART_PREF = "browser.ipProtection.autoStartEnabled"; + +/** + * This class monitors the auto-start pref and if it sees a READY state, it + * calls `start()`. This is done only if the previous state was not a ACTIVE + * because, in that case, more likely the VPN on/off state is an user decision. + */ +class IPPAutoStart { + #shouldStartWhenReady = false; + + constructor() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "autoStart", + AUTOSTART_PREF, + false, + (_pref, _oldVal, featureEnabled) => { + if (featureEnabled) { + this.init(); + } else { + this.uninit(); + } + } + ); + } + + init() { + if (this.autoStart && !this.handleEvent) { + this.handleEvent = this.#handleEvent.bind(this); + this.#shouldStartWhenReady = true; + + lazy.IPProtectionService.addEventListener( + "IPProtectionService:StateChanged", + this.handleEvent + ); + } + } + + initOnStartupCompleted() {} + + uninit() { + if (this.handleEvent) { + lazy.IPProtectionService.removeEventListener( + "IPProtectionService:StateChanged", + this.handleEvent + ); + + delete this.handleEvent; + this.#shouldStartWhenReady = false; + } + } + + #handleEvent(_event) { + switch (lazy.IPProtectionService.state) { + case lazy.IPProtectionStates.UNINITIALIZED: + case lazy.IPProtectionStates.UNAVAILABLE: + case lazy.IPProtectionStates.UNAUTHENTICATED: + case lazy.IPProtectionStates.ENROLLING: + case lazy.IPProtectionStates.ERROR: + this.#shouldStartWhenReady = true; + break; + + case lazy.IPProtectionStates.READY: + if (this.#shouldStartWhenReady) { + this.#shouldStartWhenReady = false; + lazy.IPProtectionService.start(); + } + break; + + default: + break; + } + } +} + +/** + * This class monitors the startup phases and registers/unregisters the channel + * filter to avoid data leak. The activation of the VPN is done by the + * IPPAutoStart object above. + */ +class IPPEarlyStartupFilter { + #autoStartAndAtStartup = false; + + constructor() { + this.handleEvent = this.#handleEvent.bind(this); + + this.#autoStartAndAtStartup = Services.prefs.getBoolPref( + AUTOSTART_PREF, + false + ); + } + + init() { + if (this.#autoStartAndAtStartup) { + lazy.IPProtectionService.proxyManager.createChannelFilter(); + + lazy.IPProtectionService.addEventListener( + "IPProtectionService:StateChanged", + this.handleEvent + ); + } + } + + initOnStartupCompleted() {} + + uninit() { + if (this.autoStartAndAtStartup) { + this.#autoStartAndAtStartup = false; + + lazy.IPProtectionService.removeEventListener( + "IPProtectionService:StateChanged", + this.handleEvent + ); + } + } + + #cancelChannelFilter() { + lazy.IPProtectionService.proxyManager.cancelChannelFilter(); + } + + #handleEvent(_event) { + switch (lazy.IPProtectionService.state) { + case lazy.IPProtectionStates.UNAVAILABLE: + case lazy.IPProtectionStates.UNAUTHENTICATED: + case lazy.IPProtectionStates.ERROR: + // These states block the auto-start at startup. + this.#cancelChannelFilter(); + this.uninit(); + break; + + case lazy.IPProtectionStates.ACTIVE: + // We have completed our task. + this.uninit(); + break; + + default: + // Let's ignoring any other state. + break; + } + } +} + +const IPPAutoStartHelpers = [new IPPAutoStart(), new IPPEarlyStartupFilter()]; + +export { IPPAutoStartHelpers }; diff --git a/browser/components/ipprotection/IPPChannelFilter.sys.mjs b/browser/components/ipprotection/IPPChannelFilter.sys.mjs @@ -29,22 +29,31 @@ const DEFAULT_EXCLUDED_URL_PREFS = [ */ export class IPPChannelFilter { /** - * Creates a new IPPChannelFilter that can connect to a proxy server. + * Creates a new IPPChannelFilter that can connect to a proxy server. After + * created, the proxy can be immediately activated. It will suspend all the + * received nsIChannel until the object is fully initialized. + * + * @param {Array<string>} [excludedPages] - list of page URLs whose *origin* should bypass the proxy + */ + static create(excludedPages = []) { + return new IPPChannelFilter(excludedPages); + } + + /** + * Initialize a IPPChannelFilter object. After this step, the filter, if + * active, will process the new and the pending channels. * * @param {string} authToken - a bearer token for the proxy server. * @param {string} host - the host of the proxy server. * @param {number} port - the port of the proxy server. * @param {string} proxyType - "socks" or "http" or "https" - * @param {Array<string>} [excludedPages] - list of page URLs whose *origin* should bypass the proxy */ - static create( - authToken = "", - host = "", - port = 443, - proxyType = "https", - excludedPages = [] - ) { - const proxyInfo = lazy.ProxyService.newProxyInfo( + initialize(authToken = "", host = "", port = 443, proxyType = "https") { + if (this.proxyInfo) { + throw new Error("Double initialization?!?"); + } + + const newInfo = lazy.ProxyService.newProxyInfo( proxyType, host, port, @@ -54,24 +63,20 @@ export class IPPChannelFilter { failOverTimeout, null // Failover proxy info ); - if (!proxyInfo) { + if (!newInfo) { throw new Error("Failed to create proxy info"); } - return new IPPChannelFilter(proxyInfo, excludedPages); + + Object.freeze(newInfo); + this.proxyInfo = newInfo; + + this.#processPendingChannels(); } /** - * @param {nsIProxyInfo} proxyInfo * @param {Array<string>} [excludedPages] */ - constructor(proxyInfo, excludedPages = []) { - if (!proxyInfo) { - throw new Error("ProxyInfo is required for IPPChannelFilter"); - } - - Object.freeze(proxyInfo); - this.proxyInfo = proxyInfo; - + constructor(excludedPages = []) { // Normalize and store excluded origins (scheme://host[:port]) this.#excludedOrigins = new Set(); excludedPages.forEach(url => { @@ -95,7 +100,7 @@ export class IPPChannelFilter { * would be used by default for the given URI. This may be null. * @param {nsIProxyProtocolFilterResult} proxyFilter */ - async applyFilter(channel, _defaultProxyInfo, proxyFilter) { + applyFilter(channel, _defaultProxyInfo, proxyFilter) { // If this channel should be excluded (origin match), do nothing if (this.shouldExclude(channel)) { // Calling this with "null" will enforce a non-proxy connection @@ -103,6 +108,12 @@ export class IPPChannelFilter { return; } + if (!this.proxyInfo) { + // We are not initialized yet! + this.#pendingChannels.push({ channel, proxyFilter }); + return; + } + proxyFilter.onProxyFilterResult(this.proxyInfo); // Notify observers that the channel is being proxied @@ -121,12 +132,17 @@ export class IPPChannelFilter { try { const uri = channel.URI; // nsIURI if (!uri) { - return false; + return true; + } + + if (!["http", "https"].includes(uri.scheme)) { + return true; } + const origin = uri.prePath; // scheme://host[:port] return this.#excludedOrigins.has(origin); } catch (_) { - return false; + return true; } } @@ -163,7 +179,11 @@ export class IPPChannelFilter { if (!this.#active) { return; } + lazy.ProxyService.unregisterChannelFilter(this); + + this.#abortPendingChannels(); + this.#active = false; this.#abort.abort(); } @@ -176,6 +196,10 @@ export class IPPChannelFilter { return this.proxyInfo.connectionIsolationKey; } + get hasPendingChannels() { + return !!this.#pendingChannels.length; + } + /** * Replaces the authentication token used by the proxy connection. * --> Important <--: This Changes the isolationKey of the Connection! @@ -239,10 +263,29 @@ export class IPPChannelFilter { return this.#active; } + #processPendingChannels() { + if (this.#pendingChannels.length) { + this.#pendingChannels.forEach(data => + this.applyFilter(data.channel, null, data.proxyFilter) + ); + this.#pendingChannels = []; + } + } + + #abortPendingChannels() { + if (this.#pendingChannels.length) { + this.#pendingChannels.forEach(data => + data.channel.cancel(Cr.NS_BINDING_ABORTED) + ); + this.#pendingChannels = []; + } + } + #abort = new AbortController(); #observers = []; #active = false; #excludedOrigins = new Set(); + #pendingChannels = []; static makeIsolationKey() { return Math.random().toString(36).slice(2, 18).padEnd(16, "0"); diff --git a/browser/components/ipprotection/IPPProxyManager.sys.mjs b/browser/components/ipprotection/IPPProxyManager.sys.mjs @@ -70,7 +70,7 @@ class IPPProxyManager { } get active() { - return !!this.#connection?.active; + return !!this.#connection?.active && !!this.#connection?.proxyInfo; } get isolationKey() { @@ -86,6 +86,20 @@ class IPPProxyManager { this.handleProxyErrorEvent = this.#handleProxyErrorEvent.bind(this); } + createChannelFilter() { + if (!this.#connection) { + this.#connection = lazy.IPPChannelFilter.create(); + this.#connection.start(); + } + } + + cancelChannelFilter() { + if (this.#connection) { + this.#connection.stop(); + this.#connection = null; + } + } + /** * Starts the proxy connection: * - Gets a new proxy pass if needed. @@ -95,6 +109,8 @@ class IPPProxyManager { * @returns {Promise<boolean|Error>} */ async start() { + this.createChannelFilter(); + // If the current proxy pass is valid, no need to re-authenticate. // Throws an error if the proxy pass is not available. if (!this.#pass?.isValid()) { @@ -104,18 +120,13 @@ class IPPProxyManager { const location = await lazy.getDefaultLocation(); const server = await lazy.selectServer(location?.city); lazy.logConsole.debug("Server:", server?.hostname); - if (this.#connection?.active) { - this.#connection.stop(); - } - this.#connection = lazy.IPPChannelFilter.create( + this.#connection.initialize( this.#pass.asBearerToken(), server.hostname, server.port ); - this.#connection.start(); - this.usageObserver.start(); this.usageObserver.addIsolationKey(this.#connection.isolationKey); @@ -137,9 +148,9 @@ class IPPProxyManager { * @returns {int} */ stop() { - this.#connection?.stop(); + this.cancelChannelFilter(); + this.networkErrorObserver.stop(); - this.#connection = null; lazy.logConsole.info("Stopped"); diff --git a/browser/components/ipprotection/IPProtectionHelpers.sys.mjs b/browser/components/ipprotection/IPProtectionHelpers.sys.mjs @@ -19,6 +19,7 @@ ChromeUtils.defineESModuleGetters(lazy, { NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", }); +import { IPPAutoStartHelpers } from "resource:///modules/ipprotection/IPPAutoStart.sys.mjs"; import { IPPSignInWatcher } from "resource:///modules/ipprotection/IPPSignInWatcher.sys.mjs"; import { IPPStartupCache } from "resource:///modules/ipprotection/IPPStartupCache.sys.mjs"; @@ -155,13 +156,17 @@ class EligibilityHelper { } } +// The order is important! Eligibility must be the last one because nimbus +// triggers the callback immdiately, which could compute a new state for all +// the helpers. const IPPHelpers = [ IPPStartupCache, + IPPSignInWatcher, + new UIHelper(), new AccountResetHelper(), - new EligibilityHelper(), new VPNAddonHelper(), - new UIHelper(), - IPPSignInWatcher, + new EligibilityHelper(), + ...IPPAutoStartHelpers, ]; export { IPPHelpers }; diff --git a/browser/components/ipprotection/moz.build b/browser/components/ipprotection/moz.build @@ -11,6 +11,7 @@ JAR_MANIFESTS += ["jar.mn"] EXTRA_JS_MODULES.ipprotection += [ "GuardianClient.sys.mjs", + "IPPAutoStart.sys.mjs", "IPPChannelFilter.sys.mjs", "IPPExceptionsManager.sys.mjs", "IPPNetworkErrorObserver.sys.mjs", diff --git a/browser/components/ipprotection/tests/browser/browser_IPPChannelFilter.js b/browser/components/ipprotection/tests/browser/browser_IPPChannelFilter.js @@ -10,12 +10,8 @@ const { IPPChannelFilter } = ChromeUtils.importESModule( add_task(async function test_createConnection_and_proxy() { await withProxyServer(async proxyInfo => { // Create the IPP connection filter - const filter = IPPChannelFilter.create( - "", - proxyInfo.host, - proxyInfo.port, - proxyInfo.type - ); + const filter = IPPChannelFilter.create(); + filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); filter.start(); let tab = await BrowserTestUtils.openNewForegroundTab( @@ -42,13 +38,10 @@ add_task(async function test_exclusion_and_proxy() { await withProxyServer(async proxyInfo => { // Create the IPP connection filter - const filter = IPPChannelFilter.create( - "", - proxyInfo.host, - proxyInfo.port, - proxyInfo.type, - ["http://localhost:" + server.identity.primaryPort] - ); + const filter = IPPChannelFilter.create([ + "http://localhost:" + server.identity.primaryPort, + ]); + filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); proxyInfo.gotConnection.then(() => { Assert.ok(false, "Proxy connection should not be made for excluded URL"); }); @@ -64,6 +57,55 @@ add_task(async function test_exclusion_and_proxy() { }); }); +add_task(async function test_channel_suspend_resume() { + const server = new HttpServer(); + server.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain"); + response.write("Hello World"); + }); + server.start(-1); + + await withProxyServer(async proxyInfo => { + // Create the IPP connection filter + const filter = IPPChannelFilter.create(); + filter.start(); + + let tab = BrowserTestUtils.openNewForegroundTab( + gBrowser, + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://localhost:" + server.identity.primaryPort + ); + + const pendingChannels = new Promise(resolve => { + const id = setInterval(() => { + if (filter.hasPendingChannels) { + clearInterval(id); + resolve(true); + } + }, 500); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => { + clearInterval(id); + resolve(false); + }, 5000); + }); + + Assert.ok( + await pendingChannels, + "Proxy connection qeues channels when not initialized" + ); + + filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); + + Assert.ok(!filter.hasPendingChannels, "All the pending channels are gone."); + + await BrowserTestUtils.removeTab(await tab); + filter.stop(); + }); +}); + // Second test: check observer and proxy info on channel add_task(async function channelfilter_proxiedChannels() { // Disable DOH, as otherwise the iterator will have @@ -73,12 +115,8 @@ add_task(async function channelfilter_proxiedChannels() { }); await withProxyServer(async proxyInfo => { - const filter = IPPChannelFilter.create( - "", - proxyInfo.host, - proxyInfo.port, - proxyInfo.type - ); + const filter = IPPChannelFilter.create(); + filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); filter.start(); const channelIter = filter.proxiedChannels(); let nextChannel = channelIter.next(); diff --git a/browser/components/ipprotection/tests/browser/browser_ipprotection_proxy_errors.js b/browser/components/ipprotection/tests/browser/browser_ipprotection_proxy_errors.js @@ -20,12 +20,8 @@ add_task(async function test_createConnection_and_proxy() { await withProxyServer(async proxyInfo => { // Create the IPP connection filter - const filter = IPPChannelFilter.create( - "", - proxyInfo.host, - proxyInfo.port, - proxyInfo.type - ); + const filter = IPPChannelFilter.create(); + filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); filter.start(); const observer = new IPPNetworkErrorObserver(); diff --git a/browser/components/ipprotection/tests/browser/browser_ipprotection_toolbar.js b/browser/components/ipprotection/tests/browser/browser_ipprotection_toolbar.js @@ -202,6 +202,11 @@ add_task(async function customize_toolbar_remove_widget() { * back to the initial area on re-init. */ add_task(async function toolbar_placement_customized() { + setupService({ + isSignedIn: true, + isEnrolled: true, + }); + let start = CustomizableUI.getPlacementOfWidget(IPProtectionWidget.WIDGET_ID); Assert.equal( start.area, @@ -227,9 +232,18 @@ add_task(async function toolbar_placement_customized() { let widget = document.getElementById(IPProtectionWidget.WIDGET_ID); Assert.equal(widget, null, "IP Protection widget is removed"); + const waitForStateChange = BrowserTestUtils.waitForEvent( + lazy.IPProtectionService, + "IPProtectionService:StateChanged", + false, + () => lazy.IPProtectionService.state === lazy.IPProtectionStates.READY + ); + // Reenable the feature await setupExperiment(); + await waitForStateChange; + let restored = CustomizableUI.getPlacementOfWidget( IPProtectionWidget.WIDGET_ID ); diff --git a/browser/components/ipprotection/tests/browser/browser_ipprotection_usage.js b/browser/components/ipprotection/tests/browser/browser_ipprotection_usage.js @@ -14,12 +14,8 @@ const { IPProtectionUsage } = ChromeUtils.importESModule( add_task(async function test_createConnection_and_proxy() { await withProxyServer(async proxyInfo => { // Create the IPP connection filter - const filter = IPPChannelFilter.create( - "", - proxyInfo.host, - proxyInfo.port, - proxyInfo.type - ); + const filter = IPPChannelFilter.create(); + filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); filter.start(); const observer = new IPProtectionUsage();