commit b36b73270863f3b8ddaffdb2961ba1ca307591de
parent b812274f7bcab51a916d8765a0fc054cdb3b00ef
Author: Valentin Gosu <valentin.gosu@gmail.com>
Date: Tue, 7 Oct 2025 12:28:44 +0000
Bug 1988988 - Add extension API support for masque proxy r=necko-reviewers,kershaw,robwu
Differential Revision: https://phabricator.services.mozilla.com/D265078
Diffstat:
4 files changed, 298 insertions(+), 7 deletions(-)
diff --git a/toolkit/components/extensions/ProxyChannelFilter.sys.mjs b/toolkit/components/extensions/ProxyChannelFilter.sys.mjs
@@ -35,6 +35,7 @@ const PROXY_TYPES = Object.freeze({
HTTP: "http",
SOCKS: "socks", // SOCKS5
SOCKS4: "socks4",
+ MASQUE: "masque",
});
const ProxyInfoData = {
@@ -46,6 +47,7 @@ const ProxyInfoData = {
"type",
"host",
"port",
+ "pathTemplate",
"username",
"password",
"proxyDNS",
@@ -102,6 +104,23 @@ const ProxyInfoData = {
proxyData.port = port;
},
+ pathTemplate(proxyData) {
+ let { pathTemplate } = proxyData;
+ if (proxyData.type !== PROXY_TYPES.MASQUE) {
+ if (pathTemplate !== undefined) {
+ throw new ExtensionError(
+ `ProxyInfoData: pathTemplate can only be used for "masque" proxies`
+ );
+ }
+ return;
+ }
+ if (typeof pathTemplate !== "string" || !pathTemplate) {
+ throw new ExtensionError(
+ `ProxyInfoData: Invalid proxy path template: "${pathTemplate}"`
+ );
+ }
+ },
+
username(proxyData) {
let { username } = proxyData;
if (username !== undefined && typeof username !== "string") {
@@ -109,6 +128,11 @@ const ProxyInfoData = {
`ProxyInfoData: Invalid proxy server username: "${username}"`
);
}
+ if (username !== undefined && proxyData.type === PROXY_TYPES.MASQUE) {
+ throw new ExtensionError(
+ `ProxyInfoData: Username not expected for "masque" proxy info`
+ );
+ }
},
password(proxyData) {
@@ -118,6 +142,11 @@ const ProxyInfoData = {
`ProxyInfoData: Invalid proxy server password: "${password}"`
);
}
+ if (password !== undefined && proxyData.type === PROXY_TYPES.MASQUE) {
+ throw new ExtensionError(
+ `ProxyInfoData: Password not expected for "masque" proxy info`
+ );
+ }
},
proxyDNS(proxyData) {
@@ -162,9 +191,9 @@ const ProxyInfoData = {
`ProxyInfoData: Invalid proxy server authorization header: "${proxyAuthorizationHeader}"`
);
}
- if (type !== "https" && type !== "http") {
+ if (type !== "https" && type !== "http" && type !== "masque") {
throw new ExtensionError(
- `ProxyInfoData: ProxyAuthorizationHeader requires type "https" or "http"`
+ `ProxyInfoData: ProxyAuthorizationHeader requires type "https" or "http" or "masque"`
);
}
},
@@ -198,6 +227,7 @@ const ProxyInfoData = {
type,
host,
port,
+ pathTemplate,
username,
password,
proxyDNS,
@@ -229,6 +259,17 @@ const ProxyInfoData = {
failoverTimeout ? failoverTimeout : PROXY_TIMEOUT_SEC,
failoverProxy
);
+ } else if (type == PROXY_TYPES.MASQUE) {
+ proxyInfo = lazy.ProxyService.newMASQUEProxyInfo(
+ host,
+ port,
+ pathTemplate,
+ proxyAuthorizationHeader,
+ connectionIsolationKey,
+ proxyDNS ? TRANSPARENT_PROXY_RESOLVES_HOST : 0,
+ failoverTimeout ? failoverTimeout : PROXY_TIMEOUT_SEC,
+ failoverProxy
+ );
} else {
proxyInfo = lazy.ProxyService.newProxyInfo(
type,
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_http3.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_http3.js
@@ -0,0 +1,144 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+let { promiseShutdownManager, promiseStartupManager } = AddonTestUtils;
+
+function readFile(file) {
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(file, -1, 0, 0);
+ let data = NetUtil.readInputStreamToString(fstream, fstream.available());
+ fstream.close();
+ return data;
+}
+
+function addCertFromFile(certdb, filename, trustString) {
+ let certFile = do_get_file(filename, false);
+ let pem = readFile(certFile)
+ .replace(/-----BEGIN CERTIFICATE-----/, "")
+ .replace(/-----END CERTIFICATE-----/, "")
+ .replace(/[\r\n]/g, "");
+ certdb.addCertFromBase64(pem, trustString);
+}
+
+let proxyHost;
+let proxyPort;
+
+/**
+ * Sets up HTTP3 proxy configuration for extension tests
+ */
+add_setup(async function setup() {
+ Services.prefs.setBoolPref("network.proxy.allow_hijacking_localhost", true);
+ Services.prefs.setBoolPref("network.dns.disableIPv6", true);
+ Services.prefs.setIntPref("network.webtransport.datagram_size", 1500);
+ Services.prefs.setCharPref("network.dns.localDomains", "foo.example.com");
+
+ let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService(
+ Ci.nsIX509CertDB
+ );
+ addCertFromFile(
+ certdb,
+ "../../../../../netwerk/test/unit/proxy-ca.pem",
+ "CTu,u,u"
+ );
+ addCertFromFile(
+ certdb,
+ "../../../../../netwerk/test/unit/http2-ca.pem",
+ "CTu,u,u"
+ );
+
+ proxyHost = "foo.example.com";
+ proxyPort = Services.env.get("MOZHTTP3_PORT_MASQUE");
+
+ // A dummy request to make sure AltSvcCache::mStorage is ready.
+ // It doesn't really matter if it succeeds or fails.
+ try {
+ await fetch("https://localhost");
+ } catch (e) {}
+
+ // In case the prefs have a different value by default.
+ Services.prefs.setBoolPref(
+ "extensions.webextensions.early_background_wakeup_on_request",
+ false
+ );
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("network.proxy.allow_hijacking_localhost");
+ });
+});
+
+add_task(async function test_connect_udp() {
+ await promiseStartupManager();
+
+ let h3Port = Services.env.get("MOZHTTP3_PORT");
+ ok(h3Port, `MOZHTTP3_PORT should be set: ${h3Port}`);
+
+ function background(proxyInfo) {
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest: ${JSON.stringify(details)}`);
+ browser.test.sendMessage("onRequest", details.url);
+ return proxyInfo;
+ },
+ { urls: ["<all_urls>"] }
+ );
+ }
+
+ let proxyInfo = {
+ host: proxyHost,
+ port: proxyPort,
+ type: "masque",
+ pathTemplate: "/.well-known/masque/udp/{target_host}/{target_port}/",
+ connectionIsolationKey: "masque-udp",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background: `(${background})(${JSON.stringify(proxyInfo)})`,
+ });
+
+ await extension.startup();
+
+ Services.prefs.setCharPref(
+ "network.http.http3.alt-svc-mapping-for-testing",
+ `alt1.example.com;h3=:${h3Port}`
+ );
+
+ // Test the HTTP3 connection through the extension proxy
+ let response = await ExtensionTestUtils.fetch(
+ `https://alt1.example.com:${h3Port}/no_body`,
+ `https://alt1.example.com:${h3Port}/`
+ );
+ Assert.equal(response, "Hello World", "HTTP3 proxy request succeeded");
+
+ equal(
+ await extension.awaitMessage("onRequest"),
+ `https://alt1.example.com:${h3Port}/no_body`,
+ "Observed proxy.onRequest for speculative document request"
+ );
+ equal(
+ await extension.awaitMessage("onRequest"),
+ `https://alt1.example.com:${h3Port}/no_body`,
+ "Observed proxy.onRequest for document request"
+ );
+ equal(
+ await extension.awaitMessage("onRequest"),
+ `https://alt1.example.com:${h3Port}/`,
+ "Observed proxy.onRequest for fetch() call"
+ );
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js b/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js
@@ -194,7 +194,7 @@ add_task(async function test_proxyInfo_results() {
],
expected: {
error:
- 'ProxyInfoData: ProxyAuthorizationHeader requires type "https" or "http"',
+ 'ProxyInfoData: ProxyAuthorizationHeader requires type "https" or "http" or "masque"',
},
},
{
@@ -304,7 +304,7 @@ add_task(async function test_proxyInfo_results() {
expected: {
proxyInfo: {
host: "foo.bar",
- port: "3128",
+ port: 3128,
type: "http",
},
},
@@ -342,7 +342,7 @@ add_task(async function test_proxyInfo_results() {
expected: {
proxyInfo: {
host: "foo.bar",
- port: "3128",
+ port: 3128,
type: "https",
},
},
@@ -441,7 +441,7 @@ add_task(async function test_proxyInfo_results() {
expected: {
proxyInfo: {
host: "foo.bar",
- port: "3128",
+ port: 3128,
type: "https",
proxyAuthorizationHeader: "test",
connectionIsolationKey: "key",
@@ -461,11 +461,114 @@ add_task(async function test_proxyInfo_results() {
expected: {
proxyInfo: {
host: "foo.bar",
- port: "3128",
+ port: 3128,
+ type: "http",
+ proxyAuthorizationHeader: "test",
+ connectionIsolationKey: "key",
+ },
+ },
+ },
+ {
+ proxy: [
+ {
type: "http",
+ host: "foo.bar",
+ port: 8080,
+ username: "mungosantamaria",
+ password: "pass123",
+ proxyDNS: true,
+ failoverTimeout: 3,
+ pathTemplate: "/.well-known/masque/udp/{target_host}/{target_port}/",
+ },
+ ],
+ expected: {
+ error: `ProxyInfoData: pathTemplate can only be used for "masque" proxies`,
+ },
+ },
+ {
+ proxy: [
+ {
+ host: "foo.bar",
+ port: 3128,
+ pathTemplate: "/.well-known/masque/udp/{target_host}/{target_port}/",
+ type: "masque",
+ },
+ ],
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: 3128,
+ type: "masque",
+ pathTemplate: "/.well-known/masque/udp/{target_host}/{target_port}/",
+ },
+ },
+ },
+ {
+ proxy: [
+ {
+ host: "foo.bar",
+ port: 3128,
+ proxyAuthorizationHeader: "test",
+ connectionIsolationKey: "key",
+ pathTemplate: "/.well-known/masque/udp/{target_host}/{target_port}/",
+ type: "masque",
+ },
+ ],
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: 3128,
+ type: "masque",
+ proxyAuthorizationHeader: "test",
+ connectionIsolationKey: "key",
+ pathTemplate: "/.well-known/masque/udp/{target_host}/{target_port}/",
+ },
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "masque",
+ host: "foo.bar",
+ port: 3128,
+ proxyAuthorizationHeader: "test",
+ connectionIsolationKey: "key",
+ },
+ ],
+ expected: {
+ error: `ProxyInfoData: Invalid proxy path template: "undefined"`,
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "masque",
+ host: "foo.bar",
+ port: 3128,
+ proxyAuthorizationHeader: "test",
+ connectionIsolationKey: "key",
+ pathTemplate: "/.well-known/masque/udp/{target_host}/{target_port}/",
+ username: "mungosantamaria",
+ },
+ ],
+ expected: {
+ error: `ProxyInfoData: Username not expected for "masque" proxy info`,
+ },
+ },
+ {
+ proxy: [
+ {
+ host: "foo.bar",
+ port: 3128,
proxyAuthorizationHeader: "test",
connectionIsolationKey: "key",
+ pathTemplate: "/.well-known/masque/udp/{target_host}/{target_port}/",
+ type: "masque",
+ password: "pass123",
},
+ ],
+ expected: {
+ error: `ProxyInfoData: Password not expected for "masque" proxy info`,
},
},
];
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-remote.toml b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.toml
@@ -34,6 +34,9 @@ skip-if = ["tsan"]
["test_ext_ipcBlob.js"]
skip-if = ["os == 'android' && processor == 'x86_64'"]
+["test_ext_proxy_http3.js"]
+support-files = ["!/netwerk/test/unit/proxy-ca.pem", "!/netwerk/test/unit/http2-ca.pem"]
+
["test_extension_process_alive.js"]
["test_process_crash_telemetry.js"]