Part 1: Bug 1295807 - Add a Javascript Module for registering proxy scripts. r?kmag,robwu draft
authorMatthew Wein <mwein@mozilla.com>
Mon, 06 Mar 2017 17:04:46 -0500
changeset 494239 9dfc0d929b739eb44761842a2aac1ed19c810ef8
parent 494079 517c553ad64746c479456653ce11b04ab8e4977f
child 494240 64e30dcdfbdde052aa5f7bc5b118a4308001f39b
push id47986
push usermwein@mozilla.com
push dateMon, 06 Mar 2017 22:07:05 +0000
reviewerskmag, robwu
bugs1295807
milestone54.0a1
Part 1: Bug 1295807 - Add a Javascript Module for registering proxy scripts. r?kmag,robwu MozReview-Commit-ID: HLokEivMpmp
toolkit/components/extensions/ExtensionCommon.jsm
toolkit/components/extensions/MessageChannel.jsm
toolkit/components/extensions/ProxyScriptContext.jsm
toolkit/components/extensions/ext-c-runtime.js
toolkit/components/extensions/moz.build
toolkit/components/extensions/schemas/runtime.json
toolkit/components/extensions/test/xpcshell/test_proxy_scripts.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
--- a/toolkit/components/extensions/ExtensionCommon.jsm
+++ b/toolkit/components/extensions/ExtensionCommon.jsm
@@ -537,29 +537,31 @@ class LocalAPIImplementation extends Sch
  */
 class SchemaAPIManager extends EventEmitter {
   /**
    * @param {string} processType
    *     "main" - The main, one and only chrome browser process.
    *     "addon" - An addon process.
    *     "content" - A content process.
    *     "devtools" - A devtools process.
+   *     "proxy" - A proxy script process.
    */
   constructor(processType) {
     super();
     this.processType = processType;
     this.global = this._createExtGlobal();
     this._scriptScopes = [];
     this._schemaApis = {
       addon_parent: [],
       addon_child: [],
       content_parent: [],
       content_child: [],
       devtools_parent: [],
       devtools_child: [],
+      proxy_script: [],
     };
   }
 
   /**
    * Create a global object that is used as the shared global for all ext-*.js
    * scripts that are loaded via `loadScript`.
    *
    * @returns {object} A sandbox that is used as the global by `loadScript`.
@@ -601,22 +603,23 @@ class SchemaAPIManager extends EventEmit
   /**
    * Called by an ext-*.js script to register an API.
    *
    * @param {string} namespace The API namespace.
    *     Intended to match the namespace of the generated API, but not used at
    *     the moment - see bugzil.la/1295774.
    * @param {string} envType Restricts the API to contexts that run in the
    *    given environment. Must be one of the following:
-   *     - "addon_parent" - addon APIs that runs in the main process.
-   *     - "addon_child" - addon APIs that runs in an addon process.
-   *     - "content_parent" - content script APIs that runs in the main process.
-   *     - "content_child" - content script APIs that runs in a content process.
-   *     - "devtools_parent" - devtools APIs that runs in the main process.
-   *     - "devtools_child" - devtools APIs that runs in a devtools process.
+   *     - "addon_parent" - addon APIs that run in the main process.
+   *     - "addon_child" - addon APIs that run in an addon process.
+   *     - "content_parent" - content script APIs that run in the main process.
+   *     - "content_child" - content script APIs that run in a content process.
+   *     - "devtools_parent" - devtools APIs that run in the main process.
+   *     - "devtools_child" - devtools APIs that run in a devtools process.
+   *     - "proxy_script" - proxy script APIs that run in the main process.
    * @param {function(BaseContext)} getAPI A function that returns an object
    *     that will be merged with |chrome| and |browser|. The next example adds
    *     the create, update and remove methods to the tabs API.
    *
    *     registerSchemaAPI("tabs", "addon_parent", (context) => ({
    *       tabs: { create, update },
    *     }));
    *     registerSchemaAPI("tabs", "addon_parent", (context) => ({
--- a/toolkit/components/extensions/MessageChannel.jsm
+++ b/toolkit/components/extensions/MessageChannel.jsm
@@ -374,17 +374,17 @@ this.MessageChannel = {
    * the `filter` object. Matching is done on a strict equality basis,
    * and the behavior varies depending on the value of the `strict`
    * parameter.
    *
    * @param {object} filter
    *    The filter object to match against.
    * @param {object} data
    *    The data object being matched.
-   * @param {boolean} [strict=false]
+   * @param {boolean} [strict=true]
    *    If true, all properties in the `filter` object have a
    *    corresponding property in `data` with the same value. If
    *    false, properties present in both objects must have the same
    *    value.
    * @returns {boolean} True if the objects match.
    */
   matchesFilter(filter, data, strict = true) {
     if (strict) {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ProxyScriptContext.jsm
@@ -0,0 +1,298 @@
+/* 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/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["ProxyScriptContext"];
+
+/* exported ProxyScriptContext */
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/ExtensionChild.jsm");
+Cu.import("resource://gre/modules/ExtensionCommon.jsm");
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
+                                  "resource://gre/modules/Schemas.jsm");
+XPCOMUtils.defineLazyServiceGetter(this, "ProxyService",
+                                   "@mozilla.org/network/protocol-proxy-service;1",
+                                   "nsIProtocolProxyService");
+
+const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content";
+
+// The length of time (seconds) to wait for a proxy to resolve before ignoring it.
+const PROXY_TIMEOUT_SEC = 10;
+
+const {
+  defineLazyGetter,
+} = ExtensionUtils;
+
+const {
+  BaseContext,
+  LocalAPIImplementation,
+  SchemaAPIManager,
+} = ExtensionCommon;
+
+const {
+  Messenger,
+} = ExtensionChild;
+
+const PROXY_TYPES = Object.freeze({
+  DIRECT: "direct",
+  HTTPS: "https",
+  PROXY: "proxy",
+  SOCKS: "socks",
+});
+
+class ProxyScriptContext extends BaseContext {
+  constructor(extension, url, contextInfo = {}) {
+    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},
+    });
+    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.
+   *
+   * @returns {boolean} true if load succeeded; false otherwise.
+   */
+  load() {
+    Schemas.exportLazyGetter(this.sandbox, "browser", () => this.browserObj);
+
+    try {
+      Services.scriptloader.loadSubScript(this.url, this.sandbox, "UTF-8");
+    } catch (error) {
+      this.extension.emit("proxy-error", {
+        message: this.normalizeError(error).message,
+      });
+      return false;
+    }
+
+    this.FindProxyForURL = Cu.unwaiveXrays(this.sandbox.FindProxyForURL);
+    if (typeof this.FindProxyForURL !== "function") {
+      this.extension.emit("proxy-error", {
+        message: "The proxy script must define FindProxyForURL as a function",
+      });
+      return false;
+    }
+
+    ProxyService.registerFilter(
+      this /* nsIProtocolProxyFilter aFilter */,
+      0 /* unsigned long aPosition */
+    );
+
+    return true;
+  }
+
+  get principal() {
+    return this.extension.principal;
+  }
+
+  get cloneScope() {
+    return this.sandbox;
+  }
+
+  /**
+   * This method (which is required by the nsIProtocolProxyService interface)
+   * is called to apply proxy filter rules for the given URI and proxy object
+   * (or list of proxy objects).
+   *
+   * @param {Object} service A reference to the Protocol Proxy Service.
+   * @param {Object} uri The URI for which these proxy settings apply.
+   * @param {Object} defaultProxyInfo The proxy (or list of proxies) that
+   *     would be used by default for the given URI. This may be null.
+   * @returns {Object} The proxy info to apply for the given URI.
+   */
+  applyFilter(service, uri, defaultProxyInfo) {
+    let ret;
+    try {
+      // Bug 1337001 - provide path and query components to non-https URLs.
+      ret = this.FindProxyForURL(uri.prePath, uri.host, this.contextInfo);
+    } catch (e) {
+      let error = this.normalizeError(e);
+      this.extension.emit("proxy-error", {
+        message: error.message,
+        fileName: error.fileName,
+        lineNumber: error.lineNumber,
+        stack: error.stack,
+      });
+      return defaultProxyInfo;
+    }
+
+    if (!ret || typeof ret !== "string") {
+      this.extension.emit("proxy-error", {
+        message: "FindProxyForURL: Return type must be a string",
+      });
+      return defaultProxyInfo;
+    }
+
+    let rules = ret.split(";");
+    let proxyInfo = this.createProxyInfo(rules);
+
+    return proxyInfo || defaultProxyInfo;
+  }
+
+  /**
+   * Creates a new proxy info object using the return value of FindProxyForURL.
+   *
+   * @param {Array<string>} rules The list of proxy rules returned by FindProxyForURL.
+   *    (e.g. ["PROXY 1.2.3.4:8080", "SOCKS 1.1.1.1:9090", "DIRECT"])
+   * @returns {nsIProxyInfo} The proxy info to apply for the given URI.
+   */
+  createProxyInfo(rules) {
+    if (!rules.length) {
+      return null;
+    }
+
+    let rule = rules[0].trim();
+
+    if (!rule) {
+      this.extension.emit("proxy-error", {
+        message: "FindProxyForURL: Expected Proxy Rule",
+      });
+      return null;
+    }
+
+    let parts = rule.split(/\s+/);
+    if (!parts[0] || parts.length !== 2) {
+      this.extension.emit("proxy-error", {
+        message: `FindProxyForURL: Invalid Proxy Rule: ${rule}`,
+      });
+      return null;
+    }
+
+    parts[0] = parts[0].toLowerCase();
+
+    switch (parts[0]) {
+      case PROXY_TYPES.PROXY:
+      case PROXY_TYPES.SOCKS:
+        if (!parts[1]) {
+          this.extension.emit("proxy-error", {
+            message: `FindProxyForURL: Missing argument for "${parts[0]}"`,
+          });
+          return null;
+        }
+
+        let [host, port] = parts[1].split(":");
+        if (!host || !port) {
+          this.extension.emit("proxy-error", {
+            message: `FindProxyForURL: Unable to parse argument for ${rule}`,
+          });
+          return null;
+        }
+
+        let type = PROXY_TYPES.SOCKS;
+        if (parts[0] == PROXY_TYPES.PROXY) {
+          type = PROXY_TYPES.HTTPS;
+        }
+
+        let failoverProxy = this.createProxyInfo(rules.slice(1));
+        return ProxyService.newProxyInfo(type, host, port, 0,
+          PROXY_TIMEOUT_SEC, failoverProxy);
+      case PROXY_TYPES.DIRECT:
+        return null;
+      default:
+        this.extension.emit("proxy-error", {
+          message: `FindProxyForURL: Unrecognized proxy type: "${parts[0]}"`,
+        });
+        return null;
+    }
+  }
+
+  /**
+   * Unloads the proxy filter and shuts down the sandbox.
+   */
+  unload() {
+    super.unload();
+    ProxyService.unregisterFilter(this);
+    Cu.nukeSandbox(this.sandbox);
+    this.sandbox = null;
+  }
+}
+
+class ProxyScriptAPIManager extends SchemaAPIManager {
+  constructor() {
+    super("proxy");
+    this.initialized = false;
+  }
+
+  generateAPIs(...args) {
+    if (!this.initialized) {
+      for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(
+          CATEGORY_EXTENSION_SCRIPTS_CONTENT)) {
+        this.loadScript(value);
+      }
+      this.initialized = true;
+    }
+    return super.generateAPIs(...args);
+  }
+
+  registerSchemaAPI(namespace, envType, getAPI) {
+    if (envType == "proxy_script") {
+      super.registerSchemaAPI(namespace, envType, getAPI);
+    }
+  }
+}
+
+class ProxyScriptInjectionContext {
+  constructor(context, localAPIs) {
+    this.context = context;
+    this.localAPIs = localAPIs;
+  }
+
+  shouldInject(namespace, name, allowedContexts) {
+    if (this.context.envType !== "proxy_script") {
+      throw new Error(`Unexpected context type "${this.context.envType}"`);
+    }
+
+    // Do not generate proxy script APIs unless explicitly allowed.
+    return allowedContexts.includes("proxy");
+  }
+
+  getImplementation(namespace, name) {
+    let obj = namespace.split(".").reduce(
+      (object, prop) => object && object[prop],
+      this.localAPIs);
+    if (obj && name in obj) {
+      return new LocalAPIImplementation(obj, name, this.context);
+    }
+  }
+
+  get cloneScope() {
+    return this.context.cloneScope;
+  }
+
+  get principal() {
+    return this.context.principal;
+  }
+}
+
+defineLazyGetter(ProxyScriptContext.prototype, "messenger", function() {
+  let sender = {id: this.extension.id, frameId: this.frameId, url: this.url};
+  let filter = {extensionId: this.extension.id, toProxyScript: true};
+  return new Messenger(this, [this.messageManager], sender, filter);
+});
+
+let proxyScriptAPIManager = new ProxyScriptAPIManager();
+
+defineLazyGetter(ProxyScriptContext.prototype, "browserObj", function() {
+  let localAPIs = {};
+  proxyScriptAPIManager.generateAPIs(this, localAPIs);
+
+  let browserObj = Cu.createObjectIn(this.sandbox);
+  let injectionContext = new ProxyScriptInjectionContext(this, localAPIs);
+  Schemas.inject(browserObj, injectionContext);
+  return browserObj;
+});
--- a/toolkit/components/extensions/ext-c-runtime.js
+++ b/toolkit/components/extensions/ext-c-runtime.js
@@ -19,47 +19,55 @@ function runtimeApiFactory(context) {
         let recipient = {extensionId};
 
         return context.messenger.connect(context.messageManager, name, recipient);
       },
 
       sendMessage: function(...args) {
         let options; // eslint-disable-line no-unused-vars
         let extensionId, message, responseCallback;
-        if (typeof args[args.length - 1] == "function") {
+        if (typeof args[args.length - 1] === "function") {
           responseCallback = args.pop();
         }
         if (!args.length) {
           return Promise.reject({message: "runtime.sendMessage's message argument is missing"});
-        } else if (args.length == 1) {
+        } else if (args.length === 1) {
           message = args[0];
-        } else if (args.length == 2) {
-          if (typeof args[0] == "string" && args[0]) {
+        } else if (args.length === 2) {
+          if (typeof args[0] === "string" && args[0]) {
             [extensionId, message] = args;
           } else {
             [message, options] = args;
           }
-        } else if (args.length == 3) {
+        } else if (args.length === 3) {
           [extensionId, message, options] = args;
-        } else if (args.length == 4 && !responseCallback) {
+        } else if (args.length === 4 && !responseCallback) {
           return Promise.reject({message: "runtime.sendMessage's last argument is not a function"});
         } else {
           return Promise.reject({message: "runtime.sendMessage received too many arguments"});
         }
 
-        if (extensionId != null && typeof extensionId != "string") {
+        if (extensionId != null && typeof extensionId !== "string") {
           return Promise.reject({message: "runtime.sendMessage's extensionId argument is invalid"});
         }
-        if (options != null && typeof options != "object") {
-          return Promise.reject({message: "runtime.sendMessage's options argument is invalid"});
-        }
 
         extensionId = extensionId || extension.id;
         let recipient = {extensionId};
 
+        if (options != null) {
+          if (typeof options !== "object") {
+            return Promise.reject({message: "runtime.sendMessage's options argument is invalid"});
+          }
+          if (typeof options.toProxyScript === "boolean") {
+            recipient.toProxyScript = options.toProxyScript;
+          } else {
+            return Promise.reject({message: "runtime.sendMessage's options.toProxyScript argument is invalid"});
+          }
+        }
+
         return context.messenger.sendMessage(context.messageManager, message, recipient, responseCallback);
       },
 
       connectNative(application) {
         let recipient = {
           childId: context.childManager.id,
           toNativeApp: application,
         };
@@ -90,8 +98,9 @@ function runtimeApiFactory(context) {
       },
     },
   };
 }
 
 extensions.registerSchemaAPI("runtime", "addon_child", runtimeApiFactory);
 extensions.registerSchemaAPI("runtime", "content_child", runtimeApiFactory);
 extensions.registerSchemaAPI("runtime", "devtools_child", runtimeApiFactory);
+extensions.registerSchemaAPI("runtime", "proxy_script", runtimeApiFactory);
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -16,16 +16,17 @@ EXTRA_JS_MODULES += [
     'ExtensionSettingsStore.jsm',
     'ExtensionStorage.jsm',
     'ExtensionStorageSync.jsm',
     'ExtensionTabs.jsm',
     'ExtensionUtils.jsm',
     'LegacyExtensionsUtils.jsm',
     'MessageChannel.jsm',
     'NativeMessaging.jsm',
+    'ProxyScriptContext.jsm',
     'Schemas.jsm',
 ]
 
 EXTRA_PP_COMPONENTS += [
     'extensions-toolkit.manifest',
 ]
 
 TESTING_JS_MODULES += [
--- a/toolkit/components/extensions/schemas/runtime.json
+++ b/toolkit/components/extensions/schemas/runtime.json
@@ -14,17 +14,17 @@
             "nativeMessaging"
           ]
         }]
       }
     ]
   },
   {
     "namespace": "runtime",
-    "allowedContexts": ["content", "devtools"],
+    "allowedContexts": ["content", "devtools", "proxy"],
     "description": "Use the <code>browser.runtime</code> API to retrieve the background page, return details about the manifest, and listen for and respond to events in the app or extension lifecycle. You can also use this API to convert the relative path of URLs to fully-qualified URLs.",
     "types": [
       {
         "id": "Port",
         "type": "object",
         "allowedContexts": ["content", "devtools"],
         "description": "An object which allows two way communication with other pages.",
         "properties": {
@@ -326,27 +326,28 @@
           "$ref": "Port",
           "description": "Port through which messages can be sent and received with the application"
         }
       },
       {
         "name": "sendMessage",
         "type": "function",
         "allowAmbiguousOptionalArguments": true,
-        "allowedContexts": ["content", "devtools"],
+        "allowedContexts": ["content", "devtools", "proxy"],
         "description": "Sends a single message to event listeners within your extension/app or a different extension/app. Similar to $(ref:runtime.connect) but only sends a single message, with an optional response. If sending to your extension, the $(ref:runtime.onMessage) event will be fired in each page, or $(ref:runtime.onMessageExternal), if a different extension. Note that extensions cannot send messages to content scripts using this method. To send messages to content scripts, use $(ref:tabs.sendMessage).",
         "async": "responseCallback",
         "parameters": [
           {"type": "string", "name": "extensionId", "optional": true, "description": "The ID of the extension/app to send the message to. If omitted, the message will be sent to your own extension/app. Required if sending messages from a web page for $(topic:manifest/externally_connectable)[web messaging]."},
           { "type": "any", "name": "message" },
           {
             "type": "object",
             "name": "options",
             "properties": {
-              "includeTlsChannelId": { "type": "boolean", "optional": true, "description": "Whether the TLS channel ID will be passed into onMessageExternal for processes that are listening for the connection event." }
+              "includeTlsChannelId": { "type": "boolean", "optional": true, "description": "Whether the TLS channel ID will be passed into onMessageExternal for processes that are listening for the connection event." },
+              "toProxyScript": { "type": "boolean", "optional": true, "description": "If true, the message will be directed to the extension's proxy sandbox."}
             },
             "optional": true
           },
           {
             "type": "function",
             "name": "responseCallback",
             "optional": true,
             "parameters": [
@@ -539,17 +540,17 @@
         "description": "Fired when a connection is made from another extension.",
         "parameters": [
           {"$ref": "Port", "name": "port"}
         ]
       },
       {
         "name": "onMessage",
         "type": "function",
-        "allowedContexts": ["content", "devtools"],
+        "allowedContexts": ["content", "devtools", "proxy"],
         "description": "Fired when a message is sent from either an extension process or a content script.",
         "parameters": [
           {"name": "message", "type": "any", "optional": true, "description": "The message sent by the calling script."},
           {"name": "sender", "$ref": "MessageSender" },
           {"name": "sendResponse", "type": "function", "description": "Function to call (at most once) when you have a response. The argument should be any JSON-ifiable object. If you have more than one <code>onMessage</code> listener in the same document, then only one may send a response. This function becomes invalid when the event listener returns, unless you return true from the event listener to indicate you wish to send a response asynchronously (this will keep the message channel open to the other end until <code>sendResponse</code> is called)." }
         ],
         "returns": {
           "type": "boolean",
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_scripts.js
@@ -0,0 +1,321 @@
+"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");
+
+function* testProxyScript(options, expected = {}) {
+  let scriptData = String(options.scriptData).replace(/^.*?\{([^]*)\}$/, "$1");
+  let extensionData = {
+    background() {
+      browser.test.onMessage.addListener((message, data) => {
+        if (message === "runtime-message") {
+          browser.runtime.onMessage.addListener((msg, sender, respond) => {
+            if (msg === "finish-from-pac-script") {
+              browser.test.notifyPass("proxy");
+              return Promise.resolve(msg);
+            }
+          });
+          browser.runtime.sendMessage(data, {toProxyScript: true}).then(response => {
+            browser.test.sendMessage("runtime-message-sent");
+          });
+        } else if (message === "finish-from-xpcshell-test") {
+          browser.test.notifyPass("proxy");
+        }
+      });
+    },
+    files: {
+      "proxy.js": scriptData,
+    },
+  };
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+  let extension_internal = extension.extension;
+
+  yield extension.startup();
+
+  let script = new ProxyScriptContext(extension_internal, extension_internal.getURL("proxy.js"));
+
+  try {
+    yield script.load();
+  } catch (error) {
+    equal(error, expected.error, "Expected error received");
+    script.unload();
+    yield extension.unload();
+    return;
+  }
+
+  if (options.runtimeMessage) {
+    extension.sendMessage("runtime-message", options.runtimeMessage);
+    yield extension.awaitMessage("runtime-message-sent");
+  } else {
+    extension.sendMessage("finish-from-xpcshell-test");
+  }
+
+  yield extension.awaitFinish("proxy");
+
+  let proxyInfo = yield new Promise((resolve, reject) => {
+    let channel = NetUtil.newChannel({
+      uri: "http://www.mozilla.org/",
+      loadUsingSystemPrincipal: true,
+    });
+
+    gProxyService.asyncResolve(channel, 0, {
+      onProxyAvailable(req, uri, pi, status) {
+        resolve(pi);
+      },
+    });
+  });
+
+  if (!proxyInfo) {
+    equal(proxyInfo, expected.proxyInfo, "Expected proxyInfo to be null");
+  } else {
+    let expectedProxyInfo = expected.proxyInfo;
+    for (let proxy = proxyInfo; proxy; proxy = proxy.failoverProxy) {
+      equal(proxy.host, expectedProxyInfo.host, `Expected proxy host to be ${expectedProxyInfo.host}`);
+      equal(proxy.port, expectedProxyInfo.port, `Expected proxy port to be ${expectedProxyInfo.port}`);
+      equal(proxy.type, expectedProxyInfo.type, `Expected proxy type to be ${expectedProxyInfo.type}`);
+      expectedProxyInfo = expectedProxyInfo.failoverProxy;
+    }
+  }
+
+  yield extension.unload();
+  script.unload();
+}
+
+add_task(function* testUndefinedFindProxyForURL() {
+  yield testProxyScript({
+    scriptData() { },
+  }, {
+    proxyInfo: null,
+  });
+});
+
+add_task(function* testWrongTypeForFindProxyForURL() {
+  yield testProxyScript({
+    scriptData() {
+      let FindProxyForURL = "foo";
+    },
+  }, {
+    proxyInfo: null,
+  });
+});
+
+add_task(function* testInvalidReturnTypeForFindProxyForURL() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        return -1;
+      }
+    },
+  }, {
+    proxyInfo: null,
+  });
+});
+
+add_task(function* testSimpleProxyScript() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        if (host === "www.mozilla.org") {
+          return "DIRECT";
+        }
+      }
+    },
+  }, {
+    proxyInfo: null,
+  });
+});
+
+add_task(function* testRuntimeErrorInProxyScript() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        return RUNTIME_ERROR; // eslint-disable-line no-undef
+      }
+    },
+  }, {
+    proxyInfo: null,
+  });
+});
+
+add_task(function* testProxyScriptWithUnexpectedReturnType() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        return "UNEXPECTED 1.2.3.4:8080";
+      }
+    },
+  }, {
+    proxyInfo: null,
+  });
+});
+
+add_task(function* testSocksReturnType() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        if (host === "www.mozilla.org") {
+          return "SOCKS 4.4.4.4:9002";
+        }
+      }
+    },
+  }, {
+    proxyInfo: {
+      host: "4.4.4.4",
+      port: "9002",
+      type: "socks",
+      failoverProxy: null,
+    },
+  });
+});
+
+add_task(function* testProxyReturnType() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        return "PROXY 1.2.3.4:8080";
+      }
+    },
+  }, {
+    proxyInfo: {
+      host: "1.2.3.4",
+      port: "8080",
+      type: "https",
+      failoverProxy: null,
+    },
+  });
+});
+
+add_task(function* testUnusualWhitespaceForFindProxyForURL() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        return "   PROXY    1.2.3.4:8080      ";
+      }
+    },
+  }, {
+    proxyInfo: {
+      host: "1.2.3.4",
+      port: "8080",
+      type: "https",
+      failoverProxy: null,
+    },
+  });
+});
+
+add_task(function* testInvalidProxyScriptIgnoresFailover() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        return "PROXY 1.2.3.4:8080; UNEXPECTED; SOCKS 1.2.3.4:8080";
+      }
+    },
+  }, {
+    proxyInfo: {
+      host: "1.2.3.4",
+      port: "8080",
+      type: "https",
+      failoverProxy: null,
+    },
+  });
+});
+
+add_task(function* testProxyScriptWithValidFailovers() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        return "PROXY 1.2.3.4:8080; SOCKS 4.4.4.4:9000; DIRECT";
+      }
+    },
+  }, {
+    proxyInfo: {
+      host: "1.2.3.4",
+      port: "8080",
+      type: "https",
+      failoverProxy: {
+        host: "4.4.4.4",
+        port: "9000",
+        type: "socks",
+        failoverProxy: null,
+      },
+    },
+  });
+});
+
+add_task(function* testProxyScriptWithAnInvalidFailover() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        return "PROXY 1.2.3.4:8080; INVALID 1.2.3.4:9090; SOCKS 4.4.4.4:9000; DIRECT";
+      }
+    },
+  }, {
+    proxyInfo: {
+      host: "1.2.3.4",
+      port: "8080",
+      type: "https",
+      failoverProxy: null,
+    },
+  });
+});
+
+add_task(function* testProxyScriptWithEmptyFailovers() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        return ";;;;;PROXY 1.2.3.4:8080";
+      }
+    },
+  }, {
+    proxyInfo: null,
+  });
+});
+
+add_task(function* testProxyScriptWithInvalidReturn() {
+  yield testProxyScript({
+    scriptData() {
+      function FindProxyForURL(url, host) {
+        return "SOCKS :8080;";
+      }
+    },
+  }, {
+    proxyInfo: null,
+  });
+});
+
+add_task(function* testProxyScriptWithRuntimeUpdate() {
+  yield testProxyScript({
+    scriptData() {
+      let settings = {};
+      function FindProxyForURL(url, host) {
+        if (settings.host === "www.mozilla.org") {
+          return "PROXY 1.2.3.4:8080;";
+        }
+        return "DIRECT";
+      }
+      browser.runtime.onMessage.addListener((msg, sender, respond) => {
+        if (msg.host) {
+          settings.host = msg.host;
+          browser.runtime.sendMessage("finish-from-pac-script");
+          return Promise.resolve(msg);
+        }
+      });
+    },
+    runtimeMessage: {
+      host: "www.mozilla.org",
+    },
+  }, {
+    proxyInfo: {
+      host: "1.2.3.4",
+      port: "8080",
+      type: "https",
+      failoverProxy: null,
+    },
+  });
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -75,8 +75,9 @@ skip-if = os == "android"
 skip-if = os == "android"
 [test_getAPILevelForWindow.js]
 [test_ext_legacy_extension_context.js]
 [test_ext_legacy_extension_embedding.js]
 [test_locale_converter.js]
 [test_locale_data.js]
 [test_native_messaging.js]
 skip-if = os == "android"
+[test_proxy_scripts.js]
\ No newline at end of file