commit 5798f0f05fae4e218f6dd8ad5ca1259a36169301 parent a8a77e18fb194d02868d14ca70318b28e5d8a19d Author: Sebastian Streich <sstreich@mozilla.com> Date: Mon, 17 Nov 2025 19:33:20 +0000 Bug 1998454 - Use Masque when declared supported by server r=ip-protection-reviewers,baku Differential Revision: https://phabricator.services.mozilla.com/D271613 Diffstat:
10 files changed, 575 insertions(+), 38 deletions(-)
diff --git a/browser/components/ipprotection/IPPChannelFilter.sys.mjs b/browser/components/ipprotection/IPPChannelFilter.sys.mjs @@ -70,35 +70,90 @@ export class IPPChannelFilter { } /** + * Takes a protocol definition and constructs the appropriate nsIProxyInfo + * + * @typedef {import("./IPProtectionServerlist.sys.mjs").MasqueProtocol} MasqueProtocol + * @typedef {import("./IPProtectionServerlist.sys.mjs").ConnectProtocol } ConnectProtocol + * + * @param {string} authToken - a bearer token for the proxy server. + * @param {string} isolationKey - the isolation key for the proxy connection. + * @param {MasqueProtocol|ConnectProtocol} protocol - the protocol definition. + * @param {nsIProxyInfo} fallBackInfo - optional fallback proxy info. + * @returns {nsIProxyInfo} + */ + static constructProxyInfo( + authToken, + isolationKey, + protocol, + fallBackInfo = null + ) { + switch (protocol.name) { + case "masque": + return lazy.ProxyService.newMASQUEProxyInfo( + protocol.host, + protocol.port, + protocol.templateString, + authToken, + isolationKey, + TRANSPARENT_PROXY_RESOLVES_HOST, + failOverTimeout, + fallBackInfo + ); + case "connect": + return lazy.ProxyService.newProxyInfo( + protocol.scheme, + protocol.host, + protocol.port, + authToken, + isolationKey, + TRANSPARENT_PROXY_RESOLVES_HOST, + failOverTimeout, + fallBackInfo + ); + default: + throw new Error( + "Cannot construct ProxyInfo for Unknown server-protocol: " + + protocol.name + ); + } + } + /** + * Takes a server definition and constructs the appropriate nsIProxyInfo + * If the server supports multiple Protocols, a fallback chain will be created. + * The first protocol in the list will be the primary one, with the others as fallbacks. + * + * @typedef {import("./IPProtectionServerlist.sys.mjs").Server} Server + * @param {string} authToken - a bearer token for the proxy server. + * @param {Server} server - the server to connect to. + * @returns {nsIProxyInfo} + */ + static serverToProxyInfo(authToken, server) { + const isolationKey = IPPChannelFilter.makeIsolationKey(); + return server.protocols.reduceRight((fallBackInfo, protocol) => { + return IPPChannelFilter.constructProxyInfo( + authToken, + isolationKey, + protocol, + fallBackInfo + ); + }, null); + } + + /** * Initialize a IPPChannelFilter object. After this step, the filter, if * active, will process the new and the pending channels. * + * @typedef {import("./IPProtectionServerlist.sys.mjs").Server} Server * @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 {Server} server - the server to connect to. */ - initialize(authToken = "", host = "", port = 443, proxyType = "https") { + initialize(authToken = "", server) { if (this.proxyInfo) { throw new Error("Double initialization?!?"); } - - const newInfo = lazy.ProxyService.newProxyInfo( - proxyType, - host, - port, - authToken, - IPPChannelFilter.makeIsolationKey(), - TRANSPARENT_PROXY_RESOLVES_HOST, - failOverTimeout, - null // Failover proxy info - ); - if (!newInfo) { - throw new Error("Failed to create proxy info"); - } - - Object.freeze(newInfo); - this.proxyInfo = newInfo; + const proxyInfo = IPPChannelFilter.serverToProxyInfo(authToken, server); + Object.freeze(proxyInfo); + this.proxyInfo = proxyInfo; this.#processPendingChannels(); } diff --git a/browser/components/ipprotection/IPPProxyManager.sys.mjs b/browser/components/ipprotection/IPPProxyManager.sys.mjs @@ -264,11 +264,7 @@ class IPPProxyManagerSingleton extends EventTarget { lazy.logConsole.debug("Server:", server?.hostname); - this.#connection.initialize( - this.#pass.asBearerToken(), - server.hostname, - server.port - ); + this.#connection.initialize(this.#pass.asBearerToken(), server); this.usageObserver.start(); this.usageObserver.addIsolationKey(this.#connection.isolationKey); diff --git a/browser/components/ipprotection/IPProtectionServerlist.sys.mjs b/browser/components/ipprotection/IPProtectionServerlist.sys.mjs @@ -19,9 +19,58 @@ ChromeUtils.defineESModuleGetters(lazy, { }); /** + * + */ +export class IProtocol { + name = ""; + static construct(data) { + switch (data.name) { + case "masque": + return new MasqueProtocol(data); + case "connect": + return new ConnectProtocol(data); + default: + throw new Error("Unknown protocol: " + data.name); + } + } +} + +/** + * + */ +export class MasqueProtocol extends IProtocol { + name = "masque"; + host = ""; + port = 0; + templateString = ""; + constructor(data) { + super(); + this.host = data.host || ""; + this.port = data.port || 0; + this.templateString = data.templateString || ""; + } +} + +/** + * + */ +export class ConnectProtocol extends IProtocol { + name = "connect"; + host = ""; + port = 0; + scheme = "https"; + constructor(data) { + super(); + this.host = data.host || ""; + this.port = data.port || 0; + this.scheme = data.scheme || "https"; + } +} + +/** * Class representing a server. */ -class Server { +export class Server { /** * Port of the server * @@ -42,10 +91,29 @@ class Server { */ quarantined = false; + /** + * List of supported protocols + * + * @type {Array<MasqueProtocol|ConnectProtocol>} + */ + protocols = []; + constructor(data) { this.port = data.port || 443; this.hostname = data.hostname || ""; this.quarantined = !!data.quarantined; + this.protocols = (data.protocols || []).map(p => IProtocol.construct(p)); + + // Default to connect if no protocols are specified + if (this.protocols.length === 0) { + this.protocols = [ + new ConnectProtocol({ + name: "connect", + host: this.hostname, + port: this.port, + }), + ]; + } } } diff --git a/browser/components/ipprotection/tests/browser/browser_IPPChannelFilter.js b/browser/components/ipprotection/tests/browser/browser_IPPChannelFilter.js @@ -11,7 +11,7 @@ add_task(async function test_createConnection_and_proxy() { await withProxyServer(async proxyInfo => { // Create the IPP connection filter const filter = IPPChannelFilter.create(); - filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); + filter.initialize("", proxyInfo.server); filter.start(); let tab = await BrowserTestUtils.openNewForegroundTab( @@ -41,7 +41,7 @@ add_task(async function test_exclusion_and_proxy() { const filter = IPPChannelFilter.create([ "http://localhost:" + server.identity.primaryPort, ]); - filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); + filter.initialize("", proxyInfo.server); proxyInfo.gotConnection.then(() => { Assert.ok(false, "Proxy connection should not be made for excluded URL"); }); @@ -73,7 +73,8 @@ add_task(async function test_essential_exclusion() { filter.addEssentialExclusion( "http://localhost:" + server.identity.primaryPort ); - filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); + + filter.initialize("", proxyInfo.server); proxyInfo.gotConnection.then(() => { Assert.ok(false, "Proxy connection should not be made for excluded URL"); }); @@ -129,7 +130,7 @@ add_task(async function test_channel_suspend_resume() { "Proxy connection qeues channels when not initialized" ); - filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); + filter.initialize("", proxyInfo.server); Assert.ok(!filter.hasPendingChannels, "All the pending channels are gone."); @@ -148,7 +149,7 @@ add_task(async function channelfilter_proxiedChannels() { await withProxyServer(async proxyInfo => { const filter = IPPChannelFilter.create(); - filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); + filter.initialize("", proxyInfo.server); 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 @@ -21,7 +21,7 @@ add_task(async function test_createConnection_and_proxy() { await withProxyServer(async proxyInfo => { // Create the IPP connection filter const filter = IPPChannelFilter.create(); - filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); + filter.initialize("", proxyInfo.server); filter.start(); const observer = new IPPNetworkErrorObserver(); diff --git a/browser/components/ipprotection/tests/browser/browser_ipprotection_usage.js b/browser/components/ipprotection/tests/browser/browser_ipprotection_usage.js @@ -15,7 +15,7 @@ add_task(async function test_createConnection_and_proxy() { await withProxyServer(async proxyInfo => { // Create the IPP connection filter const filter = IPPChannelFilter.create(); - filter.initialize("", proxyInfo.host, proxyInfo.port, proxyInfo.type); + filter.initialize("", proxyInfo.server); filter.start(); const observer = new IPProtectionUsage(); diff --git a/browser/components/ipprotection/tests/browser/head.js b/browser/components/ipprotection/tests/browser/head.js @@ -33,6 +33,10 @@ const { NimbusTestUtils } = ChromeUtils.importESModule( "resource://testing-common/NimbusTestUtils.sys.mjs" ); +const { Server } = ChromeUtils.importESModule( + "resource:///modules/ipprotection/IPProtectionServerlist.sys.mjs" +); + ChromeUtils.defineESModuleGetters(this, { sinon: "resource://testing-common/Sinon.sys.mjs", ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", @@ -209,8 +213,19 @@ async function withProxyServer(testFn, handler) { server.start(-1); await testFn({ - host: `localhost`, - port: server.identity.primaryPort, + server: new Server({ + hostname: "localhost", + port: server.identity.primaryPort, + quarantined: false, + protocols: [ + { + name: "connect", + host: "localhost", + scheme: "http", + port: server.identity.primaryPort, + }, + ], + }), type: "http", gotConnection: promise, }); diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPPChannelFilter.js b/browser/components/ipprotection/tests/xpcshell/test_IPPChannelFilter.js @@ -0,0 +1,374 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { IPPChannelFilter } = ChromeUtils.importESModule( + "resource:///modules/ipprotection/IPPChannelFilter.sys.mjs" +); + +const { MasqueProtocol, ConnectProtocol, Server } = ChromeUtils.importESModule( + "resource:///modules/ipprotection/IPProtectionServerlist.sys.mjs" +); + +add_task(async function test_constructProxyInfo_masque_protocol() { + const authToken = "Bearer test-token"; + const isolationKey = "test-isolation-key"; + const fallBackInfo = null; + + const masqueProtocol = new MasqueProtocol({ + name: "masque", + host: "masque.example.com", + port: 443, + templateString: "proxy/{target_host}/{target_port}/", + }); + + const proxyInfo = IPPChannelFilter.constructProxyInfo( + authToken, + isolationKey, + masqueProtocol, + fallBackInfo + ); + + Assert.equal(proxyInfo.type, "masque", "Proxy type should be masque"); + Assert.equal(proxyInfo.host, "masque.example.com", "Host should match"); + Assert.equal(proxyInfo.port, 443, "Port should match"); + Assert.equal( + proxyInfo.connectionIsolationKey, + isolationKey, + "Isolation key should match" + ); + Assert.equal( + proxyInfo.flags & Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST, + Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST, + "Should have TRANSPARENT_PROXY_RESOLVES_HOST flag" + ); + Assert.equal( + proxyInfo.failoverTimeout, + 10, + "Failover timeout should be 10 seconds" + ); + Assert.equal(proxyInfo.failoverProxy, null, "Should have no fallback proxy"); +}); + +add_task(async function test_constructProxyInfo_connect_protocol_https() { + const authToken = "Bearer test-token"; + const isolationKey = "test-isolation-key"; + const fallBackInfo = null; + + const connectProtocol = new ConnectProtocol({ + name: "connect", + host: "connect.example.com", + port: 8443, + scheme: "https", + }); + + const proxyInfo = IPPChannelFilter.constructProxyInfo( + authToken, + isolationKey, + connectProtocol, + fallBackInfo + ); + + Assert.equal(proxyInfo.type, "https", "Proxy type should be https"); + Assert.equal(proxyInfo.host, "connect.example.com", "Host should match"); + Assert.equal(proxyInfo.port, 8443, "Port should match"); + Assert.equal( + proxyInfo.connectionIsolationKey, + isolationKey, + "Isolation key should match" + ); + Assert.equal( + proxyInfo.flags & Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST, + Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST, + "Should have TRANSPARENT_PROXY_RESOLVES_HOST flag" + ); + Assert.equal( + proxyInfo.failoverTimeout, + 10, + "Failover timeout should be 10 seconds" + ); + Assert.equal(proxyInfo.failoverProxy, null, "Should have no fallback proxy"); +}); + +add_task(async function test_constructProxyInfo_connect_protocol_http() { + const authToken = "Bearer test-token"; + const isolationKey = "test-isolation-key"; + const fallBackInfo = null; + + const connectProtocol = new ConnectProtocol({ + name: "connect", + host: "connect.example.com", + port: 8080, + scheme: "http", + }); + + const proxyInfo = IPPChannelFilter.constructProxyInfo( + authToken, + isolationKey, + connectProtocol, + fallBackInfo + ); + + Assert.equal(proxyInfo.type, "http", "Proxy type should be http"); + Assert.equal(proxyInfo.host, "connect.example.com", "Host should match"); + Assert.equal(proxyInfo.port, 8080, "Port should match"); + Assert.equal( + proxyInfo.connectionIsolationKey, + isolationKey, + "Isolation key should match" + ); +}); + +add_task(async function test_constructProxyInfo_with_fallback() { + const authToken = "Bearer test-token"; + const isolationKey = "test-isolation-key"; + + // Create a fallback proxy + const fallbackProtocol = new ConnectProtocol({ + name: "connect", + host: "fallback.example.com", + port: 8080, + scheme: "http", + }); + + const fallBackInfo = IPPChannelFilter.constructProxyInfo( + authToken, + isolationKey, + fallbackProtocol, + null + ); + + const primaryProtocol = new MasqueProtocol({ + name: "masque", + host: "primary.example.com", + port: 443, + templateString: "proxy/{target_host}/{target_port}/", + }); + + const proxyInfo = IPPChannelFilter.constructProxyInfo( + authToken, + isolationKey, + primaryProtocol, + fallBackInfo + ); + + Assert.equal(proxyInfo.type, "masque", "Primary proxy type should be masque"); + Assert.equal( + proxyInfo.host, + "primary.example.com", + "Primary host should match" + ); + Assert.notEqual( + proxyInfo.failoverProxy, + null, + "Should have a fallback proxy" + ); + Assert.equal( + proxyInfo.failoverProxy.type, + "http", + "Fallback proxy type should be http" + ); + Assert.equal( + proxyInfo.failoverProxy.host, + "fallback.example.com", + "Fallback host should match" + ); +}); + +add_task(async function test_constructProxyInfo_unknown_protocol() { + const authToken = "Bearer test-token"; + const isolationKey = "test-isolation-key"; + + const unknownProtocol = { + name: "unknown", + host: "unknown.example.com", + port: 443, + }; + + Assert.throws( + () => + IPPChannelFilter.constructProxyInfo( + authToken, + isolationKey, + unknownProtocol, + null + ), + /Cannot construct ProxyInfo for Unknown server-protocol: unknown/, + "Should throw error for unknown protocol" + ); +}); + +add_task(async function test_serverToProxyInfo_single_protocol() { + const authToken = "Bearer test-token"; + + const server = new Server({ + hostname: "single.example.com", + port: 443, + protocols: [ + { + name: "masque", + host: "single.example.com", + port: 443, + templateString: "proxy/{target_host}/{target_port}/", + }, + ], + }); + + const proxyInfo = IPPChannelFilter.serverToProxyInfo(authToken, server); + + Assert.equal(proxyInfo.type, "masque", "Proxy type should be masque"); + Assert.equal(proxyInfo.host, "single.example.com", "Host should match"); + Assert.equal(proxyInfo.port, 443, "Port should match"); + Assert.equal(proxyInfo.failoverProxy, null, "Should have no fallback proxy"); + Assert.notEqual( + proxyInfo.connectionIsolationKey, + "", + "Should have generated isolation key" + ); +}); + +add_task( + async function test_serverToProxyInfo_multiple_protocols_fallback_chain() { + const authToken = "Bearer test-token"; + + const server = new Server({ + hostname: "multi.example.com", + port: 443, + protocols: [ + { + name: "masque", + host: "multi.example.com", + port: 443, + templateString: "proxy/{target_host}/{target_port}/", + }, + { + name: "connect", + host: "multi.example.com", + port: 8443, + scheme: "https", + }, + { + name: "connect", + host: "multi.example.com", + port: 8080, + scheme: "http", + }, + ], + }); + + const proxyInfo = IPPChannelFilter.serverToProxyInfo(authToken, server); + + // Verify the primary proxy (first protocol - masque) + Assert.equal( + proxyInfo.type, + "masque", + "Primary proxy type should be masque" + ); + Assert.equal( + proxyInfo.host, + "multi.example.com", + "Primary host should match" + ); + Assert.equal(proxyInfo.port, 443, "Primary port should match"); + + // Verify the first fallback (second protocol - https connect) + const firstFallback = proxyInfo.failoverProxy; + Assert.notEqual(firstFallback, null, "Should have first fallback proxy"); + Assert.equal( + firstFallback.type, + "https", + "First fallback type should be https" + ); + Assert.equal( + firstFallback.host, + "multi.example.com", + "First fallback host should match" + ); + Assert.equal(firstFallback.port, 8443, "First fallback port should match"); + + // Verify the second fallback (third protocol - http connect) + const secondFallback = firstFallback.failoverProxy; + Assert.notEqual(secondFallback, null, "Should have second fallback proxy"); + Assert.equal( + secondFallback.type, + "http", + "Second fallback type should be http" + ); + Assert.equal( + secondFallback.host, + "multi.example.com", + "Second fallback host should match" + ); + Assert.equal( + secondFallback.port, + 8080, + "Second fallback port should match" + ); + + // Verify end of chain + Assert.equal( + secondFallback.failoverProxy, + null, + "Should be end of fallback chain" + ); + + // Verify all proxies share the same isolation key + const isolationKey = proxyInfo.connectionIsolationKey; + Assert.equal( + firstFallback.connectionIsolationKey, + isolationKey, + "First fallback should share isolation key" + ); + Assert.equal( + secondFallback.connectionIsolationKey, + isolationKey, + "Second fallback should share isolation key" + ); + } +); + +add_task(async function test_serverToProxyInfo_empty_protocols() { + const authToken = "Bearer test-token"; + + // Server with no protocols (should default to connect) + const server = new Server({ + hostname: "default.example.com", + port: 443, + protocols: [], + }); + + const proxyInfo = IPPChannelFilter.serverToProxyInfo(authToken, server); + + Assert.equal(proxyInfo.type, "https", "Should default to https connect"); + Assert.equal(proxyInfo.host, "default.example.com", "Host should match"); + Assert.equal(proxyInfo.port, 443, "Port should match"); + Assert.equal(proxyInfo.failoverProxy, null, "Should have no fallback proxy"); +}); + +add_task(async function test_serverToProxyInfo_isolation_key_uniqueness() { + const authToken = "Bearer test-token"; + + const server = new Server({ + hostname: "isolation.example.com", + port: 443, + protocols: [ + { + name: "connect", + host: "isolation.example.com", + port: 443, + scheme: "https", + }, + ], + }); + + const proxyInfo1 = IPPChannelFilter.serverToProxyInfo(authToken, server); + const proxyInfo2 = IPPChannelFilter.serverToProxyInfo(authToken, server); + + Assert.notEqual( + proxyInfo1.connectionIsolationKey, + proxyInfo2.connectionIsolationKey, + "Each call should generate unique isolation keys" + ); +}); diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPProtectionServerlist.js b/browser/components/ipprotection/tests/xpcshell/test_IPProtectionServerlist.js @@ -13,16 +13,40 @@ const TEST_SERVER_1 = { hostname: "test1.example.com", port: 443, quarantined: false, + protocols: [ + { + name: "connect", + host: "test1.example.com", + port: 8443, + scheme: "https", + }, + ], }; const TEST_SERVER_2 = { hostname: "test2.example.com", port: 443, quarantined: false, + protocols: [ + { + name: "connect", + host: "test2.example.com", + port: 8443, + scheme: "https", + }, + ], }; const TEST_SERVER_QUARANTINED = { hostname: "quarantined.example.com", port: 443, quarantined: true, + protocols: [ + { + name: "connect", + host: "quarantined.example.com", + port: 8443, + scheme: "https", + }, + ], }; const TEST_US_CITY = { @@ -66,7 +90,7 @@ add_setup(async function () { add_task(async function test_getDefaultLocation() { const { country, city } = IPProtectionServerlist.getDefaultLocation(); Assert.equal(country.code, "US", "The default country should be US"); - Assert.deepEqual(city, TEST_US_CITY, "The correct city should be returned"); + Assert.deepEqual(city, TEST_US_CITY, "The default city should be returned"); }); add_task(async function test_selectServer() { diff --git a/browser/components/ipprotection/tests/xpcshell/xpcshell.toml b/browser/components/ipprotection/tests/xpcshell/xpcshell.toml @@ -1,5 +1,7 @@ [DEFAULT] -run-if = ["os != 'android'"] +run-if = [ + "os != 'android'", +] head = "head.js" firefox-appdir = "browser" prefs = [ @@ -9,6 +11,8 @@ prefs = [ ["test_GuardianClient.js"] +["test_IPPChannelFilter.js"] + ["test_IPPExceptionsManager.js"] ["test_IPPStartupCache.js"]