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:
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();