Bug 1425535 - Implement browserSettings.proxyConfig API, r?mixedpuppy draft
authorBob Silverberg <bsilverberg@mozilla.com>
Thu, 11 Jan 2018 15:22:03 -0500
changeset 721793 44be96dbd0ff93274ca60619dfad9fbba32a8cfd
parent 721495 4e429d313fd2e0f9202271ee8f3fb798817ec3e7
child 746435 5e63e18461911019c518841dfda76291f5f6f775
push id95949
push userbmo:bob.silverberg@gmail.com
push dateWed, 17 Jan 2018 21:29:43 +0000
reviewersmixedpuppy
bugs1425535
milestone59.0a1
Bug 1425535 - Implement browserSettings.proxyConfig API, r?mixedpuppy This allows an extension to read and set proxy settings via a config object. MozReview-Commit-ID: 55wn0RO74E4
toolkit/components/extensions/ext-browserSettings.js
toolkit/components/extensions/schemas/browser_settings.json
toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js
--- a/toolkit/components/extensions/ext-browserSettings.js
+++ b/toolkit/components/extensions/ext-browserSettings.js
@@ -12,47 +12,67 @@ XPCOMUtils.defineLazyServiceGetter(this,
                                    "nsIAboutNewTabService");
 
 Cu.import("resource://gre/modules/ExtensionPreferencesManager.jsm");
 
 var {
   ExtensionError,
 } = ExtensionUtils;
 
+const proxySvc = Ci.nsIProtocolProxyService;
+
+const PROXY_TYPES_MAP = new Map([
+  ["none", proxySvc.PROXYCONFIG_DIRECT],
+  ["autoDetect", proxySvc.PROXYCONFIG_WPAD],
+  ["system", proxySvc.PROXYCONFIG_SYSTEM],
+  ["manual", proxySvc.PROXYCONFIG_MANUAL],
+  ["autoConfig", proxySvc.PROXYCONFIG_PAC],
+]);
+
 const HOMEPAGE_OVERRIDE_SETTING = "homepage_override";
 const HOMEPAGE_URL_PREF = "browser.startup.homepage";
 const URL_STORE_TYPE = "url_overrides";
 const NEW_TAB_OVERRIDE_SETTING = "newTabURL";
 
 const PERM_DENY_ACTION = Services.perms.DENY_ACTION;
 
-const getSettingsAPI = (extension, name, callback, storeType, readOnly = false) => {
+const checkUnsupported = (name, unsupportedPlatforms) => {
+  if (unsupportedPlatforms.includes(AppConstants.platform)) {
+    throw new ExtensionError(
+      `${AppConstants.platform} is not a supported platform for the ${name} setting.`);
+  }
+};
+
+const getSettingsAPI = (extension, name, callback, storeType, readOnly = false, unsupportedPlatforms = []) => {
   return {
     async get(details) {
+      checkUnsupported(name, unsupportedPlatforms);
       let levelOfControl = details.incognito ?
         "not_controllable" :
         await ExtensionPreferencesManager.getLevelOfControl(
           extension.id, name, storeType);
       levelOfControl =
         (readOnly && levelOfControl === "controllable_by_this_extension") ?
           "not_controllable" :
           levelOfControl;
       return {
         levelOfControl,
         value: await callback(),
       };
     },
     set(details) {
+      checkUnsupported(name, unsupportedPlatforms);
       if (!readOnly) {
         return ExtensionPreferencesManager.setSetting(
           extension.id, name, details.value);
       }
       return false;
     },
     clear(details) {
+      checkUnsupported(name, unsupportedPlatforms);
       if (!readOnly) {
         return ExtensionPreferencesManager.removeSetting(extension.id, name);
       }
       return false;
     },
   };
 };
 
@@ -120,16 +140,66 @@ ExtensionPreferencesManager.addSetting("
     "browser.search.openintab",
   ],
 
   setCallback(value) {
     return {[this.prefNames[0]]: value};
   },
 });
 
+ExtensionPreferencesManager.addSetting("proxyConfig", {
+  prefNames: [
+    "network.proxy.type",
+    "network.proxy.http",
+    "network.proxy.http_port",
+    "network.proxy.share_proxy_settings",
+    "network.proxy.ftp",
+    "network.proxy.ftp_port",
+    "network.proxy.ssl",
+    "network.proxy.ssl_port",
+    "network.proxy.socks",
+    "network.proxy.socks_port",
+    "network.proxy.socks_version",
+    "network.proxy.socks_remote_dns",
+    "network.proxy.no_proxies_on",
+    "network.proxy.autoconfig_url",
+    "signon.autologin.proxy",
+  ],
+
+  setCallback(value) {
+    let prefs = {
+      "network.proxy.type": PROXY_TYPES_MAP.get(value.proxyType),
+      "signon.autologin.proxy": value.autoLogin,
+      "network.proxy.socks_remote_dns": value.proxyDNS,
+      "network.proxy.autoconfig_url": value.autoConfigUrl,
+      "network.proxy.share_proxy_settings": value.httpProxyAll,
+      "network.proxy.socks_version": value.socksVersion,
+      "network.proxy.no_proxies_on": value.passthrough,
+    };
+
+    for (let prop of ["http", "ftp", "ssl", "socks"]) {
+      if (value[prop]) {
+        let url = new URL(prop === "socks" ?
+                          `http://${value[prop]}` :
+                          value[prop]);
+        prefs[`network.proxy.${prop}`] = prop === "socks" ?
+          url.hostname :
+          `${url.protocol}//${url.hostname}`;
+        let port = parseInt(url.port, 10);
+        prefs[`network.proxy.${prop}_port`] = isNaN(port) ? 0 : port;
+      } else {
+        prefs[`network.proxy.${prop}`] = undefined;
+        prefs[`network.proxy.${prop}_port`] = undefined;
+      }
+    }
+
+    return prefs;
+  },
+});
+
 ExtensionPreferencesManager.addSetting("webNotificationsDisabled", {
   prefNames: [
     "permissions.default.desktop-notification",
   ],
 
   setCallback(value) {
     return {[this.prefNames[0]]: value ? PERM_DENY_ACTION : undefined};
   },
@@ -200,16 +270,96 @@ this.browserSettings = class extends Ext
           () => {
             return Services.prefs.getBoolPref("browser.tabs.loadBookmarksInTabs");
           }),
         openSearchResultsInNewTabs: getSettingsAPI(
           extension, "openSearchResultsInNewTabs",
           () => {
             return Services.prefs.getBoolPref("browser.search.openintab");
           }),
+        proxyConfig: Object.assign(
+          getSettingsAPI(
+            extension, "proxyConfig",
+            () => {
+              let prefValue = Services.prefs.getIntPref("network.proxy.type");
+              let proxyConfig = {
+                proxyType:
+                  Array.from(
+                    PROXY_TYPES_MAP.entries()).find(entry => entry[1] === prefValue)[0],
+                autoConfigUrl: Services.prefs.getCharPref("network.proxy.autoconfig_url"),
+                autoLogin: Services.prefs.getBoolPref("signon.autologin.proxy"),
+                proxyDNS: Services.prefs.getBoolPref("network.proxy.socks_remote_dns"),
+                httpProxyAll: Services.prefs.getBoolPref("network.proxy.share_proxy_settings"),
+                socksVersion: Services.prefs.getIntPref("network.proxy.socks_version"),
+                passthrough: Services.prefs.getCharPref("network.proxy.no_proxies_on"),
+              };
+
+              for (let prop of ["http", "ftp", "ssl", "socks"]) {
+                let url = Services.prefs.getCharPref(`network.proxy.${prop}`);
+                let port = Services.prefs.getIntPref(`network.proxy.${prop}_port`);
+                proxyConfig[prop] = port ? `${url}:${port}` : url;
+              }
+
+              return proxyConfig;
+            },
+            // proxyConfig is unsupported on android.
+            undefined, false, ["android"]
+          ),
+          {
+            set: details => {
+              if (AppConstants.platform === "android") {
+                throw new ExtensionError(
+                  "proxyConfig is not supported on android.");
+              }
+
+              let value = details.value;
+
+              if (!PROXY_TYPES_MAP.has(value.proxyType)) {
+                throw new ExtensionError(
+                  `${value.proxyType} is not a valid value for proxyType.`);
+              }
+
+              for (let prop of ["http", "ftp", "ssl", "socks"]) {
+                let url = value[prop];
+                if (url) {
+                  if (prop === "socks") {
+                    url = `http://${url}`;
+                  }
+                  try {
+                    new URL(url);
+                  } catch (e) {
+                    throw new ExtensionError(
+                      `${value[prop]} is not a valid value for ${prop}.`);
+                  }
+                }
+              }
+
+              if (value.proxyType === "autoConfig" || value.autoConfigUrl) {
+                try {
+                  new URL(value.autoConfigUrl);
+                } catch (e) {
+                  throw new ExtensionError(
+                    `${value.autoConfigUrl} is not a valid value for autoConfigUrl.`);
+                }
+              }
+
+              if (value.socksVersion !== undefined) {
+                if (!Number.isInteger(value.socksVersion) ||
+                    value.socksVersion < 4 ||
+                    value.socksVersion > 5) {
+                  throw new ExtensionError(
+                    `${value.socksVersion} is not a valid value for socksVersion.`);
+                }
+              }
+
+              return ExtensionPreferencesManager.setSetting(
+                extension.id, "proxyConfig", value);
+            },
+          }
+        ),
         webNotificationsDisabled: getSettingsAPI(
           extension, "webNotificationsDisabled",
           () => {
             let prefValue =
               Services.prefs.getIntPref(
                 "permissions.default.desktop-notification", null);
             return prefValue === PERM_DENY_ACTION;
           }),
--- a/toolkit/components/extensions/schemas/browser_settings.json
+++ b/toolkit/components/extensions/schemas/browser_settings.json
@@ -3,22 +3,24 @@
 // found in the LICENSE file.
 
 [
   {
     "namespace": "manifest",
     "types": [
       {
         "$extend": "OptionalPermission",
-        "choices": [{
-          "type": "string",
-          "enum": [
-            "browserSettings"
-          ]
-        }]
+        "choices": [
+          {
+            "type": "string",
+            "enum": [
+              "browserSettings"
+            ]
+          }
+        ]
       }
     ]
   },
   {
     "namespace": "browserSettings",
     "description": "Use the <code>browser.browserSettings</code> API to control global settings of the browser.",
     "permissions": ["browserSettings"],
     "types": [
@@ -28,16 +30,87 @@
         "enum": ["normal", "none", "once"],
         "description": "How images should be animated in the browser."
       },
       {
         "id": "ContextMenuMouseEvent",
         "type": "string",
         "enum": ["mouseup", "mousedown"],
         "description": "After which mouse event context menus should popup."
+      },
+      {
+        "id": "ProxyConfig",
+        "type": "object",
+        "description": "An object which describes proxy settings.",
+        "properties": {
+          "proxyType": {
+            "type": "string",
+            "optional": true,
+            "enum": [
+              "none",
+              "autoDetect",
+              "system",
+              "manual",
+              "autoConfig"
+            ],
+            "description": "The type of proxy to use."
+          },
+          "http": {
+            "type": "string",
+            "optional": true,
+            "description": "The address of the http proxy, can include a port."
+          },
+          "httpProxyAll":{
+            "type": "boolean",
+            "optional": true,
+            "description": "Use the http proxy server for all protocols."
+          },
+          "ftp": {
+            "type": "string",
+            "optional": true,
+            "description": "The address of the ftp proxy, can include a port."
+          },
+          "ssl": {
+            "type": "string",
+            "optional": true,
+            "description": "The address of the ssl proxy, can include a port."
+          },
+          "socks": {
+            "type": "string",
+            "optional": true,
+            "description": "The address of the socks proxy, can include a port."
+          },
+          "socksVersion": {
+            "type": "integer",
+            "optional": true,
+            "description": "The version of the socks proxy.",
+            "minimum": 4,
+            "maximum": 5
+          },
+          "passthrough": {
+            "type": "string",
+            "optional": true,
+            "description": "A list of hosts which should not be proxied."
+          },
+          "autoConfigUrl": {
+            "type": "string",
+            "optional": true,
+            "description": "A URL to use to configure the proxy."
+          },
+          "autoLogin": {
+            "type": "boolean",
+            "optional": true,
+            "description": "Do not prompt for authentication if password is saved."
+          },
+          "proxyDNS": {
+            "type": "boolean",
+            "optional": true,
+            "description": "Proxy DNS when using SOCKS v5."
+          }
+        }
       }
     ],
     "properties": {
       "allowPopupsForUserEvents": {
         "$ref": "types.Setting",
         "description": "Allows or disallows pop-up windows from opening in response to user events."
       },
       "cacheEnabled": {
@@ -63,15 +136,19 @@
       "openBookmarksInNewTabs": {
         "$ref": "types.Setting",
         "description": "This boolean setting controls whether bookmarks are opened in the current tab or in a new tab."
       },
       "openSearchResultsInNewTabs": {
         "$ref": "types.Setting",
         "description": "This boolean setting controls whether search results are opened in the current tab or in a new tab."
       },
+      "proxyConfig": {
+        "$ref": "types.Setting",
+        "description": "Configures proxy settings. This setting's value is an object of type ProxyConfig."
+      },
       "webNotificationsDisabled": {
         "$ref": "types.Setting",
         "description": "Disables webAPI notifications."
       }
     }
   }
 ]
--- a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js
@@ -13,29 +13,45 @@ const {
   promiseStartupManager,
 } = AddonTestUtils;
 
 AddonTestUtils.init(this);
 
 createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
 
 add_task(async function test_browser_settings() {
+  const proxySvc = Ci.nsIProtocolProxyService;
   const PERM_DENY_ACTION = Services.perms.DENY_ACTION;
   const PERM_UNKNOWN_ACTION = Services.perms.UNKNOWN_ACTION;
 
   // Create an object to hold the values to which we will initialize the prefs.
   const PREFS = {
     "browser.cache.disk.enable": true,
     "browser.cache.memory.enable": true,
     "dom.popup_allowed_events": Preferences.get("dom.popup_allowed_events"),
     "image.animation_mode": "none",
     "permissions.default.desktop-notification": PERM_UNKNOWN_ACTION,
     "ui.context_menus.after_mouseup": false,
     "browser.tabs.loadBookmarksInTabs": false,
     "browser.search.openintab": false,
+    "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM,
+    "network.proxy.http": "",
+    "network.proxy.http_port": 0,
+    "network.proxy.share_proxy_settings": false,
+    "network.proxy.ftp": "",
+    "network.proxy.ftp_port": 0,
+    "network.proxy.ssl": "",
+    "network.proxy.ssl_port": 0,
+    "network.proxy.socks": "",
+    "network.proxy.socks_port": 0,
+    "network.proxy.socks_version": 5,
+    "network.proxy.socks_remote_dns": false,
+    "network.proxy.no_proxies_on": "localhost, 127.0.0.1",
+    "network.proxy.autoconfig_url": "",
+    "signon.autologin.proxy": false,
   };
 
   async function background() {
     browser.test.onMessage.addListener(async (msg, apiName, value) => {
       let apiObj = browser.browserSettings[apiName];
       let result = await apiObj.set({value});
       if (msg === "set") {
         browser.test.assertTrue(result, "set returns true.");
@@ -65,21 +81,21 @@ add_task(async function test_browser_set
       permissions: ["browserSettings"],
     },
     useAddonManager: "temporary",
   });
 
   await promiseStartupManager();
   await extension.startup();
 
-  async function testSetting(setting, value, expected) {
+  async function testSetting(setting, value, expected, expectedValue = value) {
     extension.sendMessage("set", setting, value);
     let data = await extension.awaitMessage("settingData");
-    equal(data.value, value,
-          `The ${setting} setting has the expected value.`);
+    deepEqual(data.value, expectedValue,
+              `The ${setting} setting has the expected value.`);
     equal(data.levelOfControl, "controlled_by_this_extension",
           `The ${setting} setting has the expected levelOfControl.`);
     for (let pref in expected) {
       equal(Preferences.get(pref), expected[pref], `${pref} set correctly for ${value}`);
     }
   }
 
   async function testNoOpSetting(setting, value, expected) {
@@ -157,16 +173,163 @@ add_task(async function test_browser_set
 
   await testSetting(
     "openSearchResultsInNewTabs", true,
     {"browser.search.openintab": true});
   await testSetting(
     "openSearchResultsInNewTabs", false,
     {"browser.search.openintab": false});
 
+  async function testProxy(config, expectedPrefs) {
+    // proxyConfig is not supported on Android.
+    if (AppConstants.platform === "android") {
+      return Promise.resolve();
+    }
+
+    let proxyConfig = {
+      proxyType: "system",
+      autoConfigUrl: "",
+      autoLogin: false,
+      proxyDNS: false,
+      httpProxyAll: false,
+      socksVersion: 5,
+      passthrough: "localhost, 127.0.0.1",
+      http: "",
+      ftp: "",
+      ssl: "",
+      socks: "",
+    };
+    return testSetting(
+      "proxyConfig", config, expectedPrefs, Object.assign(proxyConfig, config)
+    );
+  }
+
+  await testProxy(
+    {proxyType: "none"},
+    {"network.proxy.type": proxySvc.PROXYCONFIG_DIRECT},
+  );
+
+  await testProxy(
+    {
+      proxyType: "autoDetect",
+      autoLogin: true,
+      proxyDNS: true,
+    },
+    {
+      "network.proxy.type": proxySvc.PROXYCONFIG_WPAD,
+      "signon.autologin.proxy": true,
+      "network.proxy.socks_remote_dns": true,
+    },
+  );
+
+  await testProxy(
+    {
+      proxyType: "system",
+      autoLogin: false,
+      proxyDNS: false,
+    },
+    {
+      "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM,
+      "signon.autologin.proxy": false,
+      "network.proxy.socks_remote_dns": false,
+    },
+  );
+
+  await testProxy(
+    {
+      proxyType: "autoConfig",
+      autoConfigUrl: "http://mozilla.org",
+    },
+    {
+      "network.proxy.type": proxySvc.PROXYCONFIG_PAC,
+      "network.proxy.autoconfig_url": "http://mozilla.org",
+    },
+  );
+
+  await testProxy(
+    {
+      proxyType: "manual",
+      http: "http://www.mozilla.org",
+      autoConfigUrl: "",
+    },
+    {
+      "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+      "network.proxy.http": "http://www.mozilla.org",
+      "network.proxy.http_port": 0,
+      "network.proxy.autoconfig_url": "",
+    }
+  );
+
+  await testProxy(
+    {
+      proxyType: "manual",
+      http: "http://www.mozilla.org:8080",
+      httpProxyAll: true,
+    },
+    {
+      "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+      "network.proxy.http": "http://www.mozilla.org",
+      "network.proxy.http_port": 8080,
+      "network.proxy.share_proxy_settings": true,
+    }
+  );
+
+  await testProxy(
+    {
+      proxyType: "manual",
+      http: "http://www.mozilla.org:8080",
+      httpProxyAll: false,
+      ftp: "http://www.mozilla.org:8081",
+      ssl: "http://www.mozilla.org:8082",
+      socks: "mozilla.org:8083",
+      socksVersion: 4,
+      passthrough: ".mozilla.org",
+    },
+    {
+      "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+      "network.proxy.http": "http://www.mozilla.org",
+      "network.proxy.http_port": 8080,
+      "network.proxy.share_proxy_settings": false,
+      "network.proxy.ftp": "http://www.mozilla.org",
+      "network.proxy.ftp_port": 8081,
+      "network.proxy.ssl": "http://www.mozilla.org",
+      "network.proxy.ssl_port": 8082,
+      "network.proxy.socks": "mozilla.org",
+      "network.proxy.socks_port": 8083,
+      "network.proxy.socks_version": 4,
+      "network.proxy.no_proxies_on": ".mozilla.org",
+    }
+  );
+
+  // Test resetting values.
+  await testProxy(
+    {
+      proxyType: "none",
+      http: "",
+      ftp: "",
+      ssl: "",
+      socks: "",
+      socksVersion: 5,
+      passthrough: "",
+    },
+    {
+      "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT,
+      "network.proxy.http": "",
+      "network.proxy.http_port": 0,
+      "network.proxy.ftp": "",
+      "network.proxy.ftp_port": 0,
+      "network.proxy.ssl": "",
+      "network.proxy.ssl_port": 0,
+      "network.proxy.socks": "",
+      "network.proxy.socks_port": 0,
+      "network.proxy.socks_version": 5,
+      "network.proxy.no_proxies_on": "",
+    }
+  );
+
   await extension.unload();
   await promiseShutdownManager();
 });
 
 add_task(async function test_bad_value() {
   async function background() {
     await browser.test.assertRejects(
       browser.browserSettings.contextMenuShowEvent.set({value: "bad"}),
@@ -182,8 +345,93 @@ add_task(async function test_bad_value()
       permissions: ["browserSettings"],
     },
   });
 
   await extension.startup();
   await extension.awaitMessage("done");
   await extension.unload();
 });
+
+add_task(async function test_bad_value_proxy_config() {
+  let background = AppConstants.platform === "android" ?
+    async () => {
+      await browser.test.assertRejects(
+        browser.browserSettings.proxyConfig.set({value: {
+          proxyType: "none",
+        }}),
+        /proxyConfig is not supported on android/,
+        "proxyConfig.set rejects on Android.");
+
+      await browser.test.assertRejects(
+        browser.browserSettings.proxyConfig.get({}),
+        /android is not a supported platform for the proxyConfig setting/,
+        "proxyConfig.get rejects on Android.");
+
+      await browser.test.assertRejects(
+        browser.browserSettings.proxyConfig.clear({}),
+        /android is not a supported platform for the proxyConfig setting/,
+        "proxyConfig.clear rejects on Android.");
+
+      browser.test.sendMessage("done");
+    } :
+    async () => {
+      await browser.test.assertRejects(
+        browser.browserSettings.proxyConfig.set({value: {
+          proxyType: "abc",
+        }}),
+        /abc is not a valid value for proxyType/,
+        "proxyConfig.set rejects with an invalid proxyType value.");
+
+      for (let protocol of ["http", "ftp", "ssl"]) {
+        let value = {proxyType: "manual"};
+        value[protocol] = "abc";
+        await browser.test.assertRejects(
+          browser.browserSettings.proxyConfig.set({value}),
+          `abc is not a valid value for ${protocol}.`,
+          `proxyConfig.set rejects with an invalid ${protocol} value.`);
+      }
+
+      await browser.test.assertRejects(
+        browser.browserSettings.proxyConfig.set({value: {
+          proxyType: "autoConfig",
+        }}),
+        /undefined is not a valid value for autoConfigUrl/,
+        "proxyConfig.set for type autoConfig rejects with an empty autoConfigUrl value.");
+
+      await browser.test.assertRejects(
+        browser.browserSettings.proxyConfig.set({value: {
+          proxyType: "autoConfig",
+          autoConfigUrl: "abc",
+        }}),
+        /abc is not a valid value for autoConfigUrl/,
+        "proxyConfig.set rejects with an invalid autoConfigUrl value.");
+
+      await browser.test.assertRejects(
+        browser.browserSettings.proxyConfig.set({value: {
+          proxyType: "manual",
+          socksVersion: "abc",
+        }}),
+        /abc is not a valid value for socksVersion/,
+        "proxyConfig.set rejects with an invalid socksVersion value.");
+
+      await browser.test.assertRejects(
+        browser.browserSettings.proxyConfig.set({value: {
+          proxyType: "manual",
+          socksVersion: 3,
+        }}),
+        /3 is not a valid value for socksVersion/,
+        "proxyConfig.set rejects with an invalid socksVersion value.");
+
+      browser.test.sendMessage("done");
+    };
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      permissions: ["browserSettings"],
+    },
+  });
+
+  await extension.startup();
+  await extension.awaitMessage("done");
+  await extension.unload();
+});