Bug 1353510 add PAC script API for compatibility, r?kmag draft
authorShane Caraveo <scaraveo@mozilla.com>
Mon, 11 Sep 2017 15:42:58 -0700
changeset 662651 49c832cccd02e29c3fec1de8b3bddd83bb34386a
parent 662650 720d17019c339891f6807c3b79ef44669ccd39f7
child 730935 c0a489f92a6ce390286d873d7aa923934dd35eb1
push id79153
push usermixedpuppy@gmail.com
push dateMon, 11 Sep 2017 22:43:36 +0000
reviewerskmag
bugs1353510
milestone57.0a1
Bug 1353510 add PAC script API for compatibility, r?kmag MozReview-Commit-ID: 5oF4EjHGlJi
toolkit/components/extensions/ProxyScriptContext.jsm
toolkit/components/extensions/test/xpcshell/test_proxy_pac_api.js
toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
--- a/toolkit/components/extensions/ProxyScriptContext.jsm
+++ b/toolkit/components/extensions/ProxyScriptContext.jsm
@@ -17,16 +17,19 @@ Cu.import("resource://gre/modules/Extens
 
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionChild",
                                   "resource://gre/modules/ExtensionChild.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyServiceGetter(this, "ProxyService",
                                    "@mozilla.org/network/protocol-proxy-service;1",
                                    "nsIProtocolProxyService");
+XPCOMUtils.defineLazyServiceGetter(this, "DNS",
+                                   "@mozilla.org/network/dns-service;1",
+                                   "nsIDNSService");
 
 const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content";
 
 // DNS is resolved on the SOCKS proxy server.
 const {TRANSPARENT_PROXY_RESOLVES_HOST} = Ci.nsIProxyInfo;
 
 // The length of time (seconds) to wait for a proxy to resolve before ignoring it.
 const PROXY_TIMEOUT_SEC = 10;
@@ -47,16 +50,274 @@ const PROXY_TYPES = Object.freeze({
   DIRECT: "direct",
   HTTPS: "https",
   PROXY: "http", // Synonym for PROXY_TYPES.HTTP
   HTTP: "http",
   SOCKS: "socks", // SOCKS5
   SOCKS4: "socks4",
 });
 
+// PAC functions from nsProxyAutoConfig.js.
+function proxyAlert(msg) {
+  try {
+    // It would appear that the console service is threadsafe.
+    Services.console.logStringMessage(`PAC-alert: ${msg}`);
+  } catch (e) {
+    Cu.reportError(`PAC: proxyAlert ERROR: ${e}`);
+  }
+}
+
+// wrapper for getting local IP address called by PAC file
+function myIpAddress() {
+  try {
+    return DNS.resolve(DNS.myHostName, Ci.nsIDNSService.RESOLVE_OFFLINE).getNextAddrAsString();
+  } catch (e) {
+    return "127.0.0.1";
+  }
+}
+
+// wrapper for resolving hostnames called by PAC file
+function dnsResolve(host) {
+  try {
+    return DNS.resolve(host, Ci.nsIDNSService.RESOLVE_OFFLINE).getNextAddrAsString();
+  } catch (e) {
+    return null;
+  }
+}
+
+// PACUtils is siphoned from netwerk/base/ProxyAutoConfig.cpp rather than
+// nsProxyAutoConfig.js, the latter which appears to be unused code.  Code
+// remains unchanged.
+const PACUtils = `
+function dnsDomainIs(host, domain) {
+  return (host.length >= domain.length &&
+          host.substring(host.length - domain.length) == domain);
+}
+
+function dnsDomainLevels(host) {
+  return host.split(".").length - 1;
+}
+
+function isValidIpAddress(ipchars) {
+  let matches = /^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/.exec(ipchars);
+  if (matches == null) {
+    return false;
+  } else if (matches[1] > 255 || matches[2] > 255 ||
+             matches[3] > 255 || matches[4] > 255) {
+    return false;
+  }
+  return true;
+}
+
+function convert_addr(ipchars) {
+  let bytes = ipchars.split(".");
+  let result = ((bytes[0] & 0xff) << 24) |
+               ((bytes[1] & 0xff) << 16) |
+               ((bytes[2] & 0xff) << 8) |
+                (bytes[3] & 0xff);
+  return result;
+}
+
+function isInNet(ipaddr, pattern, maskstr) {
+  if (!isValidIpAddress(pattern) || !isValidIpAddress(maskstr)) {
+    return false;
+  }
+  if (!isValidIpAddress(ipaddr)) {
+    ipaddr = dnsResolve(ipaddr);
+    if (ipaddr == null) {
+      return false;
+    }
+  }
+  let host = convert_addr(ipaddr);
+  let pat  = convert_addr(pattern);
+  let mask = convert_addr(maskstr);
+  return ((host & mask) == (pat & mask));
+}
+
+function isPlainHostName(host) {
+  return (host.search("\\\\.") == -1);
+}
+
+function isResolvable(host) {
+  let ip = dnsResolve(host);
+  return (ip != null);
+}
+
+function localHostOrDomainIs(host, hostdom) {
+  return (host == hostdom) || (hostdom.lastIndexOf(host + ".", 0) == 0);
+}
+
+function shExpMatch(url, pattern) {
+  pattern = pattern.replace(/\\./g, "\\\\.");
+  pattern = pattern.replace(/\\*/g, ".*");
+  pattern = pattern.replace(/\\?/g, ".");
+  let newRe = new RegExp("^" + pattern + "$");
+  return newRe.test(url);
+}
+
+let wdays = {SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6};
+let months = {JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, JUL: 6, AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11};
+
+function weekdayRange() {
+  function getDay(weekday) {
+    if (weekday in wdays) {
+      return wdays[weekday];
+    }
+    return -1;
+  }
+  let date = new Date();
+  let argc = arguments.length;
+  let wday;
+  if (argc < 1) {
+    return false;
+  }
+  if (arguments[argc - 1] == "GMT") {
+    argc--;
+    wday = date.getUTCDay();
+  } else {
+    wday = date.getDay();
+  }
+  let wd1 = getDay(arguments[0]);
+  let wd2 = (argc == 2) ? getDay(arguments[1]) : wd1;
+  if (wd1 == -1 || wd2 == -1) {
+    return false;
+  }
+  if (wd1 <= wd2) {
+    return (wd1 <= wday && wday <= wd2);
+  }
+  return (wd2 >= wday || wday >= wd1);
+}
+
+function dateRange() {
+  function getMonth(name) {
+    if (name in months) {
+      return months[name];
+    }
+    return -1;
+  }
+  let date = new Date();
+  let argc = arguments.length;
+  if (argc < 1) {
+    return false;
+  }
+  let isGMT = (arguments[argc - 1] == "GMT");
+
+  if (isGMT) {
+    argc--;
+  }
+  // function will work even without explict handling of this case
+  if (argc == 1) {
+    let tmp = parseInt(arguments[0], 10);
+    if (isNaN(tmp)) {
+      return ((isGMT ? date.getUTCMonth() : date.getMonth()) == getMonth(arguments[0]));
+    } else if (tmp < 32) {
+      return ((isGMT ? date.getUTCDate() : date.getDate()) == tmp);
+    }
+    return ((isGMT ? date.getUTCFullYear() : date.getFullYear()) == tmp);
+  }
+  let year = date.getFullYear();
+  let date1, date2;
+  date1 = new Date(year, 0, 1, 0, 0, 0);
+  date2 = new Date(year, 11, 31, 23, 59, 59);
+  let adjustMonth = false;
+  for (let i = 0; i < (argc >> 1); i++) {
+    let tmp = parseInt(arguments[i], 10);
+    if (isNaN(tmp)) {
+      let mon = getMonth(arguments[i]);
+      date1.setMonth(mon);
+    } else if (tmp < 32) {
+      adjustMonth = (argc <= 2);
+      date1.setDate(tmp);
+    } else {
+      date1.setFullYear(tmp);
+    }
+  }
+  for (let i = (argc >> 1); i < argc; i++) {
+    let tmp = parseInt(arguments[i], 10);
+    if (isNaN(tmp)) {
+      let mon = getMonth(arguments[i]);
+      date2.setMonth(mon);
+    } else if (tmp < 32) {
+      date2.setDate(tmp);
+    } else {
+      date2.setFullYear(tmp);
+    }
+  }
+  if (adjustMonth) {
+    date1.setMonth(date.getMonth());
+    date2.setMonth(date.getMonth());
+  }
+  if (isGMT) {
+    let tmp = date;
+    tmp.setFullYear(date.getUTCFullYear());
+    tmp.setMonth(date.getUTCMonth());
+    tmp.setDate(date.getUTCDate());
+    tmp.setHours(date.getUTCHours());
+    tmp.setMinutes(date.getUTCMinutes());
+    tmp.setSeconds(date.getUTCSeconds());
+    date = tmp;
+  }
+  return (date1 <= date2) ? (date1 <= date) && (date <= date2)
+          : (date2 >= date) || (date >= date1);
+}
+
+function timeRange() {
+  let argc = arguments.length;
+  let date = new Date();
+  let isGMT = false;
+
+  if (argc < 1) {
+    return false;
+  }
+  if (arguments[argc - 1] == "GMT") {
+    isGMT = true;
+    argc--;
+  }
+
+  let hour = isGMT ? date.getUTCHours() : date.getHours();
+  let date1, date2;
+  date1 = new Date();
+  date2 = new Date();
+
+  if (argc == 1) {
+    return (hour == arguments[0]);
+  } else if (argc == 2) {
+    return ((arguments[0] <= hour) && (hour <= arguments[1]));
+  }
+  switch (argc) {
+    case 6:
+      date1.setSeconds(arguments[2]);
+      date2.setSeconds(arguments[5]);
+      // fall through
+    case 4:
+      let middle = argc >> 1;
+      date1.setHours(arguments[0]);
+      date1.setMinutes(arguments[1]);
+      date2.setHours(arguments[middle]);
+      date2.setMinutes(arguments[middle + 1]);
+      if (middle == 2) {
+        date2.setSeconds(59);
+      }
+      break;
+    default:
+      throw new Error("timeRange: bad number of arguments");
+  }
+
+  if (isGMT) {
+    date.setFullYear(date.getUTCFullYear());
+    date.setMonth(date.getUTCMonth());
+    date.setDate(date.getUTCDate());
+    date.setHours(date.getUTCHours());
+    date.setMinutes(date.getUTCMinutes());
+    date.setSeconds(date.getUTCSeconds());
+  }
+  return (date1 <= date2) ? (date1 <= date) && (date <= date2)
+          : (date2 >= date) || (date >= date1);
+}`;
+
 const ProxyInfoData = {
   validate(proxyData) {
     if (proxyData.type && proxyData.type.toLowerCase() === "direct") {
       return {type: proxyData.type};
     }
     for (let prop of ["type", "host", "port", "username", "password", "proxyDNS", "failoverTimeout"]) {
       this[prop](proxyData);
     }
@@ -190,16 +451,23 @@ class ProxyScriptContext extends BaseCon
     super("proxy_script", extension);
     this.contextInfo = contextInfo;
     this.extension = extension;
     this.messageManager = Services.cpmm;
     this.sandbox = Cu.Sandbox(this.extension.principal, {
       sandboxName: `proxyscript:${extension.id}:${url}`,
       metadata: {addonID: extension.id},
     });
+
+    // add predefined functions to pac
+    Cu.evalInSandbox(PACUtils, this.sandbox);
+    this.sandbox.importFunction(myIpAddress);
+    this.sandbox.importFunction(dnsResolve);
+    this.sandbox.importFunction(proxyAlert, "alert");
+
     this.url = url;
     this.FindProxyForURL = null;
   }
 
   /**
    * Loads and validates a proxy script into the sandbox, and then
    * registers a new proxy filter for the context.
    *
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_pac_api.js
@@ -0,0 +1,199 @@
+"use strict";
+
+/* eslint no-unused-vars: ["error", {"args": "none", "varsIgnorePattern": "^(FindProxyForURL)$"}] */
+
+Cu.import("resource://gre/modules/Extension.jsm");
+Cu.import("resource://gre/modules/ProxyScriptContext.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "gProxyService",
+                                   "@mozilla.org/network/protocol-proxy-service;1",
+                                   "nsIProtocolProxyService");
+
+let extension;
+add_task(async function setup() {
+  let extensionData = {
+    manifest: {
+      "permissions": ["proxy"],
+    },
+    background() {
+      browser.test.onMessage.addListener((message, data) => {
+        if (message === "api-call") {
+          browser.runtime.sendMessage(data, {toProxyScript: true}).then(response => {
+            browser.test.sendMessage("api-response", response);
+          }).catch(error => {
+            browser.test.sendMessage("api-response", error.toString());
+          });
+        }
+      });
+      browser.proxy.register("proxy.js").then(() => {
+        browser.test.sendMessage("ready");
+      });
+    },
+    files: {
+      "proxy.js": `"use strict"
+        function FindProxyForURL(url, host) {
+          return "DIRECT";
+        }
+        browser.runtime.onMessage.addListener((msg, sender, respond) => {
+          if (msg.api) {
+            return new Promise(resolve => {
+              if (msg.api == "myIpAddress") {
+                resolve(myIpAddress());
+              } else if (msg.api == "dnsResolve") {
+                resolve(dnsResolve(msg.args[0]));
+              } else {
+                resolve(this[msg.api].apply(null, msg.args));
+              }
+            });
+          }
+        });
+      `,
+    },
+  };
+  extension = ExtensionTestUtils.loadExtension(extensionData);
+  await extension.startup();
+  await extension.awaitMessage("ready");
+});
+
+async function testProxyAPI(test) {
+  let {call, expected} = test;
+  extension.sendMessage("api-call", call);
+  let result = await extension.awaitMessage("api-response");
+  deepEqual(result, expected, `got back proxy api result for ${JSON.stringify(call)}`);
+}
+
+add_task(async function test_pac_results() {
+  // These are very simplistic tests to show that at a basic level the PAC
+  // APIs are working.
+  let tests = [
+    {
+      call: {api: "dnsDomainIs", args: ["mozilla.org", "mozilla.com"]},
+      expected: false,
+    },
+    {
+      call: {api: "dnsDomainIs", args: ["www.mozilla.com", "mozilla.com"]},
+      expected: true,
+    },
+    {
+      call: {api: "dnsDomainIs", args: ["www.mozilla.com"]},
+      expected: "Error: domain is undefined",
+    },
+    {
+      call: {api: "myIpAddress", args: []},
+      expected: "127.0.0.1",
+    },
+    // OFFLINE requests fail if not cached.
+    {
+      call: {api: "dnsResolve", args: ["example.com"]},
+      expected: null,
+    },
+    {
+      call: {api: "dnsResolve", args: ["local.host.nonexistent"]},
+      expected: null,
+    },
+    {
+      call: {api: "dnsDomainLevels", args: ["local.host.nonexistent"]},
+      expected: 2,
+    },
+    {
+      call: {api: "isValidIpAddress", args: ["local.host.nonexistent"]},
+      expected: false,
+    },
+    {
+      call: {api: "isValidIpAddress", args: ["63.245.215.20"]},
+      expected: true,
+    },
+    {
+      call: {api: "isValidIpAddress", args: ["abc.897.bad"]},
+      expected: false,
+    },
+    {
+      call: {api: "convert_addr", args: ["63.245.215.20"]},
+      expected: 1073075988,
+    },
+    {
+      call: {api: "isInNet", args: ["63.245.215.20", "172.16.0.0", "255.240.0.0"]},
+      expected: false,
+    },
+    {
+      call: {api: "isInNet", args: ["172.16.215.20", "172.16.0.0", "255.240.0.0"]},
+      expected: true,
+    },
+    {
+      call: {api: "isPlainHostName", args: ["mozilla.org"]},
+      expected: false,
+    },
+    {
+      call: {api: "isPlainHostName", args: ["intranet"]},
+      expected: true,
+    },
+    {
+      call: {api: "isResolvable", args: ["local.host.nonexistent"]},
+      expected: false,
+    },
+    {
+      call: {api: "localHostOrDomainIs", args: ["www", "www.mozilla.org"]},
+      expected: true,
+    },
+    {
+      call: {api: "localHostOrDomainIs", args: ["foobar", "www.mozilla.org"]},
+      expected: false,
+    },
+    {
+      call: {api: "shExpMatch", args: ["test.localhost", "*.localhost"]},
+      expected: true,
+    },
+    {
+      call: {api: "shExpMatch", args: ["test.local", "*.localhost"]},
+      expected: false,
+    },
+  ];
+
+  let wdays = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
+  let now = new Date();
+  let tomorrow = new Date();
+  tomorrow.setDate(now.getDate() + 1);
+  tests.push({
+    call: {api: "weekdayRange", args: [wdays[now.getDay()]]},
+    expected: true,
+  });
+  tests.push({
+    call: {api: "weekdayRange", args: [wdays[tomorrow.getDay()]]},
+    expected: false,
+  });
+
+  tests.push({
+    call: {api: "dateRange", args: [now.getFullYear()]},
+    expected: true,
+  });
+  tests.push({
+    call: {api: "dateRange", args: [now.getFullYear() + 1]},
+    expected: false,
+  });
+
+  tests.push({
+    call: {api: "dateRange", args: [now.getFullYear()]},
+    expected: true,
+  });
+  tests.push({
+    call: {api: "dateRange", args: [now.getFullYear() + 1]},
+    expected: false,
+  });
+
+  tests.push({
+    call: {api: "timeRange", args: [now.getHours() - 1, now.getHours() + 1]},
+    expected: true,
+  });
+  tests.push({
+    call: {api: "timeRange", args: [now.getHours() + 1, now.getHours() + 2]},
+    expected: false,
+  });
+
+  for (let test of tests) {
+    await testProxyAPI(test);
+  }
+});
+
+add_task(async function shutdown() {
+  await extension.unload();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -67,11 +67,12 @@ skip-if = os == "android"
 [test_ext_storage_sync_crypto.js]
 skip-if = os == "android"
 [test_ext_storage_telemetry.js]
 skip-if = os == "android" # checking for telemetry needs to be updated: 1384923
 [test_ext_topSites.js]
 skip-if = os == "android"
 [test_native_messaging.js]
 skip-if = os == "android"
+[test_proxy_pac_api.js]
 [test_proxy_scripts.js]
 skip-if = os == "linux" # bug 1393940
 [test_proxy_scripts_results.js]