Bug 1409878 implement async proxy api for webextensions, r?rpl,kmag draft
authorShane Caraveo <scaraveo@mozilla.com>
Thu, 01 Mar 2018 19:09:54 -0600
changeset 762311 b327820531edd20a1d25a330c3848177cb015d2b
parent 762045 40ba247350dad945cccd29680ee53d578c006420
push id101133
push usermixedpuppy@gmail.com
push dateFri, 02 Mar 2018 01:10:38 +0000
reviewersrpl, kmag
bugs1409878
milestone60.0a1
Bug 1409878 implement async proxy api for webextensions, r?rpl,kmag MozReview-Commit-ID: 50xsccRy19A
toolkit/components/extensions/ProxyScriptContext.jsm
toolkit/components/extensions/ext-proxy.js
toolkit/components/extensions/schemas/proxy.json
toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js
toolkit/components/extensions/test/xpcshell/test_proxy_listener.js
toolkit/components/extensions/test/xpcshell/test_proxy_scripts.js
toolkit/components/extensions/test/xpcshell/test_proxy_scripts_results.js
toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
--- a/toolkit/components/extensions/ProxyScriptContext.jsm
+++ b/toolkit/components/extensions/ProxyScriptContext.jsm
@@ -1,32 +1,38 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 /* 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";
 
-var EXPORTED_SYMBOLS = ["ProxyScriptContext"];
+var EXPORTED_SYMBOLS = ["ProxyScriptContext", "ProxyChannelFilter"];
 
-/* exported ProxyScriptContext */
+/* exported ProxyScriptContext, ProxyChannelFilter */
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
 ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
 
 ChromeUtils.defineModuleGetter(this, "ExtensionChild",
                                "resource://gre/modules/ExtensionChild.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionParent",
+                               "resource://gre/modules/ExtensionParent.jsm");
 ChromeUtils.defineModuleGetter(this, "Schemas",
                                "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyServiceGetter(this, "ProxyService",
                                    "@mozilla.org/network/protocol-proxy-service;1",
                                    "nsIProtocolProxyService");
 
+XPCOMUtils.defineLazyGetter(this, "tabTracker", () => {
+  return ExtensionParent.apiManager.global.tabTracker;
+});
+
 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;
 
@@ -60,88 +66,92 @@ const ProxyInfoData = {
       this[prop](proxyData);
     }
     return proxyData;
   },
 
   type(proxyData) {
     let {type} = proxyData;
     if (typeof type !== "string" || !PROXY_TYPES.hasOwnProperty(type.toUpperCase())) {
-      throw new ExtensionError(`FindProxyForURL: Invalid proxy server type: "${type}"`);
+      throw new ExtensionError(`ProxyInfoData: Invalid proxy server type: "${type}"`);
     }
     proxyData.type = PROXY_TYPES[type.toUpperCase()];
   },
 
   host(proxyData) {
     let {host} = proxyData;
     if (typeof host !== "string" || host.includes(" ")) {
-      throw new ExtensionError(`FindProxyForURL: Invalid proxy server host: "${host}"`);
+      throw new ExtensionError(`ProxyInfoData: Invalid proxy server host: "${host}"`);
     }
     if (!host.length) {
-      throw new ExtensionError("FindProxyForURL: Proxy server host cannot be empty");
+      throw new ExtensionError("ProxyInfoData: Proxy server host cannot be empty");
     }
     proxyData.host = host;
   },
 
   port(proxyData) {
     let port = Number.parseInt(proxyData.port, 10);
     if (!Number.isInteger(port)) {
-      throw new ExtensionError(`FindProxyForURL: Invalid proxy server port: "${port}"`);
+      throw new ExtensionError(`ProxyInfoData: Invalid proxy server port: "${port}"`);
     }
 
     if (port < 1 || port > 0xffff) {
-      throw new ExtensionError(`FindProxyForURL: Proxy server port ${port} outside range 1 to 65535`);
+      throw new ExtensionError(`ProxyInfoData: Proxy server port ${port} outside range 1 to 65535`);
     }
     proxyData.port = port;
   },
 
   username(proxyData) {
     let {username} = proxyData;
     if (username !== undefined && typeof username !== "string") {
-      throw new ExtensionError(`FindProxyForURL: Invalid proxy server username: "${username}"`);
+      throw new ExtensionError(`ProxyInfoData: Invalid proxy server username: "${username}"`);
     }
   },
 
   password(proxyData) {
     let {password} = proxyData;
     if (password !== undefined && typeof password !== "string") {
-      throw new ExtensionError(`FindProxyForURL: Invalid proxy server password: "${password}"`);
+      throw new ExtensionError(`ProxyInfoData: Invalid proxy server password: "${password}"`);
     }
   },
 
   proxyDNS(proxyData) {
     let {proxyDNS, type} = proxyData;
     if (proxyDNS !== undefined) {
       if (typeof proxyDNS !== "boolean") {
-        throw new ExtensionError(`FindProxyForURL: Invalid proxyDNS value: "${proxyDNS}"`);
+        throw new ExtensionError(`ProxyInfoData: Invalid proxyDNS value: "${proxyDNS}"`);
       }
       if (proxyDNS && type !== PROXY_TYPES.SOCKS && type !== PROXY_TYPES.SOCKS4) {
-        throw new ExtensionError(`FindProxyForURL: proxyDNS can only be true for SOCKS proxy servers`);
+        throw new ExtensionError(`ProxyInfoData: proxyDNS can only be true for SOCKS proxy servers`);
       }
     }
   },
 
   failoverTimeout(proxyData) {
     let {failoverTimeout} = proxyData;
     if (failoverTimeout !== undefined && (!Number.isInteger(failoverTimeout) || failoverTimeout < 1)) {
-      throw new ExtensionError(`FindProxyForURL: Invalid failover timeout: "${failoverTimeout}"`);
+      throw new ExtensionError(`ProxyInfoData: Invalid failover timeout: "${failoverTimeout}"`);
     }
   },
 
   createProxyInfoFromData(proxyDataList, defaultProxyInfo, proxyDataListIndex = 0) {
     if (proxyDataListIndex >= proxyDataList.length) {
       return defaultProxyInfo;
     }
+    let proxyData = proxyDataList[proxyDataListIndex];
+    if (proxyData == null) {
+      return null;
+    }
     let {type, host, port, username, password, proxyDNS, failoverTimeout} =
-        ProxyInfoData.validate(proxyDataList[proxyDataListIndex]);
+        ProxyInfoData.validate(proxyData);
     if (type === PROXY_TYPES.DIRECT) {
       return defaultProxyInfo;
     }
     let failoverProxy = this.createProxyInfoFromData(proxyDataList, defaultProxyInfo, proxyDataListIndex + 1);
-    // TODO When Bug 1360404 is fixed use ProxyService.newProxyInfoWithAuth() for all types.
+
     if (type === PROXY_TYPES.SOCKS || type === PROXY_TYPES.SOCKS4) {
       return ProxyService.newProxyInfoWithAuth(
         type, host, port, username, password,
         proxyDNS ? TRANSPARENT_PROXY_RESOLVES_HOST : 0,
         failoverTimeout ? failoverTimeout : PROXY_TIMEOUT_SEC,
         failoverProxy);
     }
     return ProxyService.newProxyInfo(
@@ -155,47 +165,199 @@ const ProxyInfoData = {
    * Creates a new proxy info data object using the return value of FindProxyForURL.
    *
    * @param {Array<string>} rule A single proxy rule returned by FindProxyForURL.
    *    (e.g. "PROXY 1.2.3.4:8080", "SOCKS 1.1.1.1:9090" or "DIRECT")
    * @returns {nsIProxyInfo} The proxy info to apply for the given URI.
    */
   parseProxyInfoDataFromPAC(rule) {
     if (!rule) {
-      throw new ExtensionError("FindProxyForURL: Missing Proxy Rule");
+      throw new ExtensionError("ProxyInfoData: Missing Proxy Rule");
     }
 
     let parts = rule.toLowerCase().split(/\s+/);
     if (!parts[0] || parts.length > 2) {
-      throw new ExtensionError(`FindProxyForURL: Invalid arguments passed for proxy rule: "${rule}"`);
+      throw new ExtensionError(`ProxyInfoData: Invalid arguments passed for proxy rule: "${rule}"`);
     }
     let type = parts[0];
     let [host, port] = parts.length > 1 ? parts[1].split(":") : [];
 
     switch (PROXY_TYPES[type.toUpperCase()]) {
       case PROXY_TYPES.HTTP:
       case PROXY_TYPES.HTTPS:
       case PROXY_TYPES.SOCKS:
       case PROXY_TYPES.SOCKS4:
         if (!host || !port) {
-          throw new ExtensionError(`FindProxyForURL: Invalid host or port from proxy rule: "${rule}"`);
+          throw new ExtensionError(`ProxyInfoData: Invalid host or port from proxy rule: "${rule}"`);
         }
         return {type, host, port};
       case PROXY_TYPES.DIRECT:
         if (host || port) {
-          throw new ExtensionError(`FindProxyForURL: Invalid argument for proxy type: "${type}"`);
+          throw new ExtensionError(`ProxyInfoData: Invalid argument for proxy type: "${type}"`);
         }
         return {type};
       default:
-        throw new ExtensionError(`FindProxyForURL: Unrecognized proxy type: "${type}"`);
+        throw new ExtensionError(`ProxyInfoData: Unrecognized proxy type: "${type}"`);
     }
   },
 
+  proxyInfoFromProxyData(context, proxyData, defaultProxyInfo) {
+    switch (typeof proxyData) {
+      case "string":
+        let proxyRules = [];
+        try {
+          for (let result of proxyData.split(";")) {
+            proxyRules.push(ProxyInfoData.parseProxyInfoDataFromPAC(result.trim()));
+          }
+        } catch (e) {
+          // If we have valid proxies already, lets use them and just emit
+          // errors for the failovers.
+          if (proxyRules.length === 0) {
+            throw e;
+          }
+          let error = context.normalizeError(e);
+          context.extension.emit("proxy-error", {
+            message: error.message,
+            fileName: error.fileName,
+            lineNumber: error.lineNumber,
+            stack: error.stack,
+          });
+        }
+        proxyData = proxyRules;
+        // fall through
+      case "object":
+        if (Array.isArray(proxyData) && proxyData.length > 0) {
+          return ProxyInfoData.createProxyInfoFromData(proxyData, defaultProxyInfo);
+        }
+        // Not an array, fall through to error.
+      default:
+        throw new ExtensionError("ProxyInfoData: proxyData must be a string or array of objects");
+    }
+  },
 };
 
+function normalizeFilter(filter) {
+  if (!filter) {
+    filter = {};
+  }
+
+  return {urls: filter.urls || null, types: filter.types || null};
+}
+
+class ProxyChannelFilter {
+  constructor(context, listener, filter, extraInfoSpec) {
+    this.context = context;
+    this.filter = normalizeFilter(filter);
+    this.listener = listener;
+    this.extraInfoSpec = extraInfoSpec || [];
+
+    ProxyService.registerChannelFilter(
+      this /* nsIProtocolProxyChannelFilter aFilter */,
+      0 /* unsigned long aPosition */
+    );
+  }
+
+  // Copy from WebRequest.jsm with small changes.
+  getRequestData(channel, extraData) {
+    let data = {
+      requestId: String(channel.id),
+      url: channel.finalURL,
+      method: channel.method,
+      type: channel.type,
+      fromCache: !!channel.fromCache,
+
+      originUrl: channel.originURL || undefined,
+      documentUrl: channel.documentURL || undefined,
+
+      frameId: channel.windowId,
+      parentFrameId: channel.parentWindowId,
+
+      frameAncestors: channel.frameAncestors || undefined,
+
+      timeStamp: Date.now(),
+
+      ...extraData,
+    };
+    if (this.extraInfoSpec.includes("requestHeaders")) {
+      data.requestHeaders = channel.getRequestHeaders();
+    }
+    return data;
+  }
+
+  /**
+   * 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 {nsIProtocolProxyService} service A reference to the Protocol Proxy Service.
+   * @param {nsIChannel} channel The channel for which these proxy settings apply.
+   * @param {nsIProxyInfo} defaultProxyInfo The proxy (or list of proxies) that
+   *     would be used by default for the given URI. This may be null.
+   * @param {nsIProtocolProxyChannelFilter} proxyFilter
+   */
+  async applyFilter(service, channel, defaultProxyInfo, proxyFilter) {
+    let proxyInfo;
+    try {
+      let wrapper = ChannelWrapper.get(channel);
+
+      let browserData = {tabId: -1, windowId: -1};
+      if (wrapper.browserElement) {
+        browserData = tabTracker.getBrowserData(wrapper.browserElement);
+      }
+      let {filter} = this;
+      if (filter.tabId != null && browserData.tabId !== filter.tabId) {
+        return;
+      }
+      if (filter.windowId != null && browserData.windowId !== filter.windowId) {
+        return;
+      }
+
+      if (wrapper.matches(filter, this.context.extension.policy, {isProxy: true})) {
+        let data = this.getRequestData(wrapper, {tabId: browserData.tabId});
+
+        let ret = await this.listener(data);
+        if (ret == null) {
+          // If ret undefined or null, fall through to the `finally` block to apply the proxy result.
+          proxyInfo = ret;
+          return;
+        }
+        // We only accept proxyInfo objects, not the PAC strings. ProxyInfoData will
+        // accept either, so we want to enforce the limit here.
+        if (typeof ret !== "object") {
+          throw new ExtensionError("ProxyInfoData: proxyData must be an object or array of objects");
+        }
+        // We allow the call to return either a single proxyInfo or an array of proxyInfo.
+        if (!Array.isArray(ret)) {
+          ret = [ret];
+        }
+        proxyInfo = ProxyInfoData.createProxyInfoFromData(ret, defaultProxyInfo);
+      }
+    } catch (e) {
+      let error = this.context.normalizeError(e);
+      this.context.extension.emit("proxy-error", {
+        message: error.message,
+        fileName: error.fileName,
+        lineNumber: error.lineNumber,
+        stack: error.stack,
+      });
+    } finally {
+      // We must call onProxyFilterResult.  proxyInfo may be null or nsIProxyInfo.
+      // defaultProxyInfo will be null unless a prior proxy handler has set something.
+      // If proxyInfo is null, that removes any prior proxy config.  This allows a
+      // proxy extension to override higher level (e.g. prefs) config under certain
+      // circumstances.
+      proxyFilter.onProxyFilterResult(proxyInfo !== undefined ? proxyInfo : defaultProxyInfo);
+    }
+  }
+
+  destroy() {
+    ProxyService.unregisterFilter(this);
+  }
+}
+
 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: `Extension Proxy Script (${extension.policy.debugName}): ${url}`,
@@ -242,67 +404,33 @@ class ProxyScriptContext extends BaseCon
   get principal() {
     return this.extension.principal;
   }
 
   get cloneScope() {
     return this.sandbox;
   }
 
-  proxyInfoFromProxyData(proxyData, defaultProxyInfo) {
-    switch (typeof proxyData) {
-      case "string":
-        let proxyRules = [];
-        try {
-          for (let result of proxyData.split(";")) {
-            proxyRules.push(ProxyInfoData.parseProxyInfoDataFromPAC(result.trim()));
-          }
-        } catch (e) {
-          // If we have valid proxies already, lets use them and just emit
-          // errors for the failovers.
-          if (proxyRules.length === 0) {
-            throw e;
-          }
-          let error = this.normalizeError(e);
-          this.extension.emit("proxy-error", {
-            message: error.message,
-            fileName: error.fileName,
-            lineNumber: error.lineNumber,
-            stack: error.stack,
-          });
-        }
-        proxyData = proxyRules;
-        // fall through
-      case "object":
-        if (Array.isArray(proxyData) && proxyData.length > 0) {
-          return ProxyInfoData.createProxyInfoFromData(proxyData, defaultProxyInfo);
-        }
-        // Not an array, fall through to error.
-      default:
-        throw new ExtensionError("FindProxyForURL: Return type must be a string or array of objects");
-    }
-  }
-
   /**
    * 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.
    * @param {Object} callback nsIProxyProtocolFilterResult to call onProxyFilterResult
          on with the proxy info to apply for the given URI.
    */
   applyFilter(service, uri, defaultProxyInfo, callback) {
     try {
       // TODO Bug 1337001 - provide path and query components to non-https URLs.
       let ret = this.FindProxyForURL(uri.prePath, uri.host, this.contextInfo);
-      ret = this.proxyInfoFromProxyData(ret, defaultProxyInfo);
+      ret = ProxyInfoData.proxyInfoFromProxyData(this, ret, defaultProxyInfo);
       callback.onProxyFilterResult(ret);
     } catch (e) {
       let error = this.normalizeError(e);
       this.extension.emit("proxy-error", {
         message: error.message,
         fileName: error.fileName,
         lineNumber: error.lineNumber,
         stack: error.stack,
--- a/toolkit/components/extensions/ext-proxy.js
+++ b/toolkit/components/extensions/ext-proxy.js
@@ -7,33 +7,79 @@
 
 // The ext-* files are imported into the same scopes.
 /* import-globals-from ext-toolkit.js */
 
 "use strict";
 
 ChromeUtils.defineModuleGetter(this, "ProxyScriptContext",
                                "resource://gre/modules/ProxyScriptContext.jsm");
+ChromeUtils.defineModuleGetter(this, "ProxyChannelFilter",
+                               "resource://gre/modules/ProxyScriptContext.jsm");
 
 // WeakMap[Extension -> ProxyScriptContext]
-let proxyScriptContextMap = new WeakMap();
+const proxyScriptContextMap = new WeakMap();
+
+// EventManager-like class specifically for Proxy filters. Inherits from
+// EventManager. Takes care of converting |details| parameter
+// when invoking listeners.
+class ProxyFilterEventManager extends EventManager {
+  constructor(context, eventName) {
+    let name = `proxy.${eventName}`;
+    let register = (fire, filterProps, extraInfoSpec = []) => {
+      let listener = (data) => {
+        return fire.sync(data);
+      };
+
+      let filter = {...filterProps};
+      if (filter.urls) {
+        let perms = new MatchPatternSet([...context.extension.whiteListedHosts.patterns,
+                                         ...context.extension.optionalOrigins.patterns]);
+
+        filter.urls = new MatchPatternSet(filter.urls);
+
+        if (!perms.overlapsAll(filter.urls)) {
+          throw new context.cloneScope.Error("The proxy.addListener filter doesn't overlap with host permissions.");
+        }
+      }
+
+      let proxyFilter = new ProxyChannelFilter(context, listener, filter, extraInfoSpec);
+      return () => {
+        proxyFilter.destroy();
+      };
+    };
+
+    super(context, name, register);
+  }
+}
 
 this.proxy = class extends ExtensionAPI {
   onShutdown() {
     let {extension} = this;
 
     let proxyScriptContext = proxyScriptContextMap.get(extension);
     if (proxyScriptContext) {
       proxyScriptContext.unload();
       proxyScriptContextMap.delete(extension);
     }
   }
 
   getAPI(context) {
     let {extension} = context;
+
+    let onError = new EventManager(context, "proxy.onError", fire => {
+      let listener = (name, error) => {
+        fire.async(error);
+      };
+      extension.on("proxy-error", listener);
+      return () => {
+        extension.off("proxy-error", listener);
+      };
+    }).api();
+
     return {
       proxy: {
         register(url) {
           this.unregister();
 
           let proxyScriptContext = new ProxyScriptContext(extension, url);
           if (proxyScriptContext.load()) {
             proxyScriptContextMap.set(extension, proxyScriptContext);
@@ -47,21 +93,18 @@ this.proxy = class extends ExtensionAPI 
             proxyScriptContextMap.delete(extension);
           }
         },
 
         registerProxyScript(url) {
           this.register(url);
         },
 
-        onProxyError: new EventManager(context, "proxy.onProxyError", fire => {
-          let listener = (name, error) => {
-            fire.async(error);
-          };
-          extension.on("proxy-error", listener);
-          return () => {
-            extension.off("proxy-error", listener);
-          };
-        }).api(),
+        onRequest: new ProxyFilterEventManager(context, "onRequest").api(),
+
+        onError,
+
+        // TODO Bug 1388619 deprecate onProxyError.
+        onProxyError: onError,
       },
     };
   }
 };
--- a/toolkit/components/extensions/schemas/proxy.json
+++ b/toolkit/components/extensions/schemas/proxy.json
@@ -50,19 +50,73 @@
             "type": "string",
             "format": "strictRelativeUrl"
           }
         ]
       }
     ],
     "events": [
       {
+        "name": "onRequest",
+        "type": "function",
+        "description": "Fired when proxy data is needed for a request.",
+        "parameters": [
+          {
+            "type": "object",
+            "name": "details",
+            "properties": {
+              "requestId": {"type": "string", "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request."},
+              "url": {"type": "string"},
+              "method": {"type": "string", "description": "Standard HTTP method."},
+              "frameId": {"type": "integer", "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab."},
+              "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."},
+              "originUrl": {"type": "string", "optional": true, "description": "URL of the resource that triggered this request."},
+              "documentUrl": {"type": "string", "optional": true, "description": "URL of the page into which the requested resource will be loaded."},
+              "tabId": {"type": "integer", "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab."},
+              "type": {"$ref": "webRequest.ResourceType", "description": "How the requested resource will be used."},
+              "timeStamp": {"type": "number", "description": "The time when this signal is triggered, in milliseconds since the epoch."},
+              "ip": {"type": "string", "optional": true, "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address."},
+              "fromCache": {"type": "boolean", "description": "Indicates if this response was fetched from disk cache."},
+              "requestHeaders": {"$ref": "webRequest.HttpHeaders", "optional": true, "description": "The HTTP request headers that are going to be sent out with this request."}
+            }
+          }
+        ],
+        "extraParameters": [
+          {
+            "$ref": "webRequest.RequestFilter",
+            "name": "filter",
+            "description": "A set of filters that restricts the events that will be sent to this listener."
+          },
+          {
+            "type": "array",
+            "optional": true,
+            "name": "extraInfoSpec",
+            "description": "Array of extra information that should be passed to the listener function.",
+            "items": {
+              "type": "string",
+              "enum": ["requestHeaders"]
+            }
+          }
+        ]
+      },
+      {
+        "name": "onError",
+        "type": "function",
+        "description": "Notifies about proxy script errors.",
+        "parameters": [
+          {
+            "name": "error",
+            "type": "object"
+          }
+        ]
+      },
+      {
         "name": "onProxyError",
         "type": "function",
-        "description": "Notifies about proxy script errors.",
+        "description": "Please use $(ref:proxy.onError).",
         "parameters": [
           {
             "name": "error",
             "type": "object"
           }
         ]
       }
     ]
copy from toolkit/components/extensions/test/xpcshell/test_ext_proxy_auth.js
copy to toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js
--- a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_auth.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js
@@ -26,91 +26,98 @@ function getExtension(background) {
       permissions: [
         "proxy",
         "webRequest",
         "webRequestBlocking",
         "<all_urls>",
       ],
     },
     background: `(${background})(${proxy.identity.primaryPort})`,
-    files: {
-      "proxy.js": `
-        function FindProxyForURL(url, host) {
-          return "PROXY localhost:${proxy.identity.primaryPort}; DIRECT";
-        }`,
-    },
   });
 }
+
 add_task(async function test_webRequest_auth_proxy() {
   async function background(port) {
-    browser.webRequest.onBeforeRequest.addListener(details => {
-      browser.test.log(`details ${JSON.stringify(details)}\n`);
+    browser.webRequest.onBeforeSendHeaders.addListener(details => {
+      browser.test.log(`onBeforeSendHeaders ${JSON.stringify(details)}\n`);
       browser.test.assertEq("localhost", details.proxyInfo.host, "proxy host");
       browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
       browser.test.assertEq("http", details.proxyInfo.type, "proxy type");
       browser.test.assertEq("", details.proxyInfo.username, "proxy username not set");
     }, {urls: ["<all_urls>"]});
+
     browser.webRequest.onAuthRequired.addListener(details => {
+      browser.test.log(`onAuthRequired ${JSON.stringify(details)}\n`);
       browser.test.assertTrue(details.isProxy, "proxied request");
       browser.test.assertEq("localhost", details.proxyInfo.host, "proxy host");
       browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
       browser.test.assertEq("http", details.proxyInfo.type, "proxy type");
       browser.test.assertEq("localhost", details.challenger.host, "proxy host");
       browser.test.assertEq(port, details.challenger.port, "proxy port");
       return {authCredentials: {username: "puser", password: "ppass"}};
     }, {urls: ["<all_urls>"]}, ["blocking"]);
+
     browser.webRequest.onCompleted.addListener(details => {
-      browser.test.log(`details ${JSON.stringify(details)}\n`);
+      browser.test.log(`onCompleted ${JSON.stringify(details)}\n`);
       browser.test.assertEq("localhost", details.proxyInfo.host, "proxy host");
       browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
       browser.test.assertEq("http", details.proxyInfo.type, "proxy type");
       browser.test.assertEq("", details.proxyInfo.username, "proxy username not set by onAuthRequired");
       browser.test.assertEq(undefined, details.proxyInfo.password, "no proxy password");
       browser.test.sendMessage("done");
     }, {urls: ["<all_urls>"]});
 
-    await browser.proxy.register("proxy.js");
-    browser.test.sendMessage("pac-ready");
+    // Handle the proxy request.
+    browser.proxy.onRequest.addListener(details => {
+      browser.test.log(`onRequest ${JSON.stringify(details)}`);
+      return [{host: "localhost", port, type: "http"}];
+    }, {urls: ["<all_urls>"]}, ["requestHeaders"]);
+    browser.test.sendMessage("ready");
   }
 
   let handlingExt = getExtension(background);
 
   await handlingExt.startup();
-  await handlingExt.awaitMessage("pac-ready");
+  await handlingExt.awaitMessage("ready");
 
   let contentPage = await ExtensionTestUtils.loadContentPage(`http://mozilla.org/`);
 
   await handlingExt.awaitMessage("done");
   await contentPage.close();
   await handlingExt.unload();
 });
 
 add_task(async function test_webRequest_auth_proxy_system() {
   async function background(port) {
     browser.webRequest.onBeforeRequest.addListener(details => {
       browser.test.fail("onBeforeRequest");
     }, {urls: ["<all_urls>"]});
+
     browser.webRequest.onAuthRequired.addListener(details => {
       browser.test.sendMessage("onAuthRequired");
       // cancel is silently ignored, if it were not (e.g someone messes up in
       // WebRequest.jsm and allows cancel) this test would fail.
       return {
         cancel: true,
         authCredentials: {username: "puser", password: "ppass"},
       };
     }, {urls: ["<all_urls>"]}, ["blocking"]);
 
-    await browser.proxy.register("proxy.js");
-    browser.test.sendMessage("pac-ready");
+    // Handle the proxy request.
+    browser.proxy.onRequest.addListener(details => {
+      browser.test.log(`onRequest ${JSON.stringify(details)}`);
+      return {host: "localhost", port, type: "http"};
+    }, {urls: ["<all_urls>"]});
+    browser.test.sendMessage("ready");
   }
 
   let handlingExt = getExtension(background);
 
   await handlingExt.startup();
-  await handlingExt.awaitMessage("pac-ready");
+  await handlingExt.awaitMessage("ready");
 
   function fetch(url) {
     return new Promise((resolve, reject) => {
       let xhr = new XMLHttpRequest();
       xhr.mozBackgroundRequest = true;
       xhr.open("GET", url);
       xhr.onload = () => { resolve(xhr.responseText); };
       xhr.onerror = () => { reject(xhr.status); };
copy from toolkit/components/extensions/test/xpcshell/test_proxy_scripts.js
copy to toolkit/components/extensions/test/xpcshell/test_proxy_listener.js
--- a/toolkit/components/extensions/test/xpcshell/test_proxy_scripts.js
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js
@@ -1,165 +1,188 @@
 "use strict";
 
-/* eslint no-unused-vars: ["error", {"args": "none", "varsIgnorePattern": "^(FindProxyForURL)$"}] */
-
 ChromeUtils.import("resource://gre/modules/Extension.jsm");
 ChromeUtils.import("resource://gre/modules/ProxyScriptContext.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "gProxyService",
                                    "@mozilla.org/network/protocol-proxy-service;1",
                                    "nsIProtocolProxyService");
 
+const TRANSPARENT_PROXY_RESOLVES_HOST = Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST;
+
 function getProxyInfo() {
   return 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);
       },
     });
   });
 }
-async function testProxyScript(script, expected = {}) {
-  let scriptData = String(script).replace(/^.*?\{([^]*)\}$/, "$1");
+
+const testData = [
+  {
+    // An ExtensionError is thrown for this, but we are unable to catch it as we
+    // do with the PAC script api.  In this case, we expect null for proxyInfo.
+    proxyInfo: "not_defined",
+    expected: {
+      proxyInfo: null,
+    },
+  },
+  {
+    proxyInfo: 1,
+    expected: {
+      error: {
+        message: "ProxyInfoData: proxyData must be an object or array of objects",
+      },
+    },
+  },
+  {
+    proxyInfo: [{type: "socks", host: "foo.bar", port: 1080, username: "johnsmith", password: "pass123", proxyDNS: true, failoverTimeout: 3},
+                {type: "http", host: "192.168.1.1", port: 3128}, {type: "https", host: "192.168.1.2", port: 1121, failoverTimeout: 1},
+                {type: "socks", host: "192.168.1.3", port: 1999, proxyDNS: true, username: "mungosantamaria", password: "foobar"}],
+    expected: {
+      proxyInfo: {
+        type: "socks",
+        host: "foo.bar",
+        port: 1080,
+        proxyDNS: true,
+        username: "johnsmith",
+        password: "pass123",
+        failoverTimeout: 3,
+        failoverProxy: {
+          host: "192.168.1.1",
+          port: 3128,
+          type: "http",
+          failoverProxy: {
+            host: "192.168.1.2",
+            port: 1121,
+            type: "https",
+            failoverTimeout: 1,
+            failoverProxy: {
+              host: "192.168.1.3",
+              port: 1999,
+              type: "socks",
+              proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST,
+              username: "mungosantamaria",
+              password: "foobar",
+              failoverProxy: {
+                type: "direct",
+              },
+            },
+          },
+        },
+      },
+    },
+  },
+];
+
+add_task(async function test_proxy_listener() {
   let extensionData = {
     manifest: {
-      "permissions": ["proxy"],
+      "permissions": ["proxy", "<all_urls>"],
     },
     background() {
       // Some tests generate multiple errors, we'll just rely on the first.
       let seenError = false;
-      browser.proxy.onProxyError.addListener(error => {
+      let proxyInfo;
+      browser.proxy.onError.addListener(error => {
         if (!seenError) {
           browser.test.sendMessage("proxy-error-received", error);
           seenError = true;
         }
       });
 
-      browser.test.onMessage.addListener(msg => {
-        if (msg === "unregister-proxy-script") {
-          browser.proxy.unregister().then(() => {
-            browser.test.notifyPass("proxy");
-          });
+      browser.proxy.onRequest.addListener(details => {
+        browser.test.log(`onRequest ${JSON.stringify(details)}`);
+        if (proxyInfo == "not_defined") {
+          return not_defined; // eslint-disable-line no-undef
+        }
+        return proxyInfo;
+      }, {urls: ["<all_urls>"]});
+
+      browser.test.onMessage.addListener((message, data) => {
+        if (message === "set-proxy") {
+          seenError = false;
+          proxyInfo = data.proxyInfo;
         }
       });
 
-      browser.proxy.register("proxy.js").then(() => {
-        browser.test.sendMessage("ready");
-      });
-    },
-    files: {
-      "proxy.js": scriptData,
+      browser.test.sendMessage("ready");
     },
   };
 
   let extension = ExtensionTestUtils.loadExtension(extensionData);
   await extension.startup();
   await extension.awaitMessage("ready");
 
-  let errorWait = extension.awaitMessage("proxy-error-received");
-
-  let proxyInfo = await getProxyInfo();
+  for (let test of testData) {
+    extension.sendMessage("set-proxy", test);
+    let testError = test.expected.error;
+    let errorWait = testError && extension.awaitMessage("proxy-error-received");
 
-  let error = await errorWait;
-  equal(error.message, expected.message, "Correct error message received");
-  if (!expected.proxyInfo) {
-    equal(proxyInfo, null, "no proxyInfo received");
-  } else {
-    let {host, port, type} = expected.proxyInfo;
-    equal(proxyInfo.host, host, `Expected proxy host to be ${host}`);
-    equal(proxyInfo.port, port, `Expected proxy port to be ${port}`);
-    equal(proxyInfo.type, type, `Expected proxy type to be ${type}`);
-  }
-  if (expected.errorInfo) {
-    ok(error.fileName.includes("proxy.js"), "Error should include file name");
-    equal(error.lineNumber, 3, "Error should include line number");
-    ok(error.stack.includes("proxy.js:3:7"), "Error should include stack trace");
-  }
-  extension.sendMessage("unregister-proxy-script");
-  await extension.awaitFinish("proxy");
-  await extension.unload();
-}
-
-add_task(async function test_invalid_FindProxyForURL_function() {
-  await testProxyScript(() => { }, {
-    message: "The proxy script must define FindProxyForURL as a function",
-  });
+    let proxyInfo = await getProxyInfo();
+    let expectedProxyInfo = test.expected.proxyInfo;
 
-  await testProxyScript(() => {
-    var FindProxyForURL = 5; // eslint-disable-line mozilla/var-only-at-top-level
-  }, {
-    message: "The proxy script must define FindProxyForURL as a function",
-  });
-
-  await testProxyScript(() => {
-    function FindProxyForURL() {
-      return not_defined; // eslint-disable-line no-undef
+    if (testError) {
+      info("waiting for error data");
+      let error = await errorWait;
+      equal(error.message, testError.message, "Correct error message received");
+      equal(proxyInfo, null, "no proxyInfo received");
+    } else if (expectedProxyInfo === null) {
+      equal(proxyInfo, null, "no proxyInfo received");
+    } else {
+      for (let proxyUsed = proxyInfo; proxyUsed; proxyUsed = proxyUsed.failoverProxy) {
+        let {type, host, port, username, password, proxyDNS, failoverTimeout} = expectedProxyInfo;
+        equal(proxyUsed.host, host, `Expected proxy host to be ${host}`);
+        equal(proxyUsed.port, port, `Expected proxy port to be ${port}`);
+        equal(proxyUsed.type, type, `Expected proxy type to be ${type}`);
+        // May be null or undefined depending on use of newProxyInfoWithAuth or newProxyInfo
+        equal(proxyUsed.username || "", username || "", `Expected proxy username to be ${username}`);
+        equal(proxyUsed.password || "", password || "", `Expected proxy password to be ${password}`);
+        equal(proxyUsed.flags, proxyDNS == undefined ? 0 : proxyDNS, `Expected proxyDNS to be ${proxyDNS}`);
+        // Default timeout is 10
+        equal(proxyUsed.failoverTimeout, failoverTimeout || 10, `Expected failoverTimeout to be ${failoverTimeout}`);
+        expectedProxyInfo = expectedProxyInfo.failoverProxy;
+      }
     }
-  }, {
-    message: "not_defined is not defined",
-    errorInfo: true,
-  });
+  }
 
-  // The following tests will produce multiple errors.
-  await testProxyScript(() => {
-    function FindProxyForURL() {
-      return ";;;;;PROXY 1.2.3.4:8080";
-    }
-  }, {
-    message: "FindProxyForURL: Missing Proxy Rule",
-  });
-
-  // We take any valid proxy up to the error.
-  await testProxyScript(() => {
-    function FindProxyForURL() {
-      return "PROXY 1.2.3.4:8080; UNEXPECTED; SOCKS 1.2.3.4:8080";
-    }
-  }, {
-    message: "FindProxyForURL: Unrecognized proxy type: \"unexpected\"",
-    proxyInfo: {
-      host: "1.2.3.4",
-      port: "8080",
-      type: "http",
-      failoverProxy: null,
-    },
-  });
+  await extension.unload();
 });
 
-async function getExtension(proxyResult) {
+async function getExtension(expectedProxyInfo) {
+  function background(proxyInfo) {
+    browser.test.log(`testing proxy.onRequest with proxyInfo = ${JSON.stringify(proxyInfo)}`);
+    browser.proxy.onRequest.addListener(details => {
+      return proxyInfo;
+    }, {urls: ["<all_urls>"]});
+    browser.test.sendMessage("ready");
+  }
   let extensionData = {
     manifest: {
-      "permissions": ["proxy"],
-    },
-    background() {
-      browser.proxy.register("proxy.js").then(() => {
-        browser.test.sendMessage("ready");
-      });
+      "permissions": ["proxy", "<all_urls>"],
     },
-    files: {
-      "proxy.js": `
-        function FindProxyForURL(url, host) {
-          return ${proxyResult};
-        }`,
-    },
+    background: `(${background})(${JSON.stringify(expectedProxyInfo)})`,
   };
   let extension = ExtensionTestUtils.loadExtension(extensionData);
   await extension.startup();
   await extension.awaitMessage("ready");
   return extension;
 }
 
 add_task(async function test_passthrough() {
   let ext1 = await getExtension(null);
-  let ext2 = await getExtension("\"PROXY 1.2.3.4:8888\"");
+  let ext2 = await getExtension({host: "1.2.3.4", port: 8888, type: "http"});
 
   let proxyInfo = await getProxyInfo();
 
   equal(proxyInfo.host, "1.2.3.4", `second extension won`);
   equal(proxyInfo.port, "8888", `second extension won`);
   equal(proxyInfo.type, "http", `second extension won`);
 
   await ext2.unload();
--- a/toolkit/components/extensions/test/xpcshell/test_proxy_scripts.js
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_scripts.js
@@ -105,26 +105,26 @@ add_task(async function test_invalid_Fin
   });
 
   // The following tests will produce multiple errors.
   await testProxyScript(() => {
     function FindProxyForURL() {
       return ";;;;;PROXY 1.2.3.4:8080";
     }
   }, {
-    message: "FindProxyForURL: Missing Proxy Rule",
+    message: "ProxyInfoData: Missing Proxy Rule",
   });
 
   // We take any valid proxy up to the error.
   await testProxyScript(() => {
     function FindProxyForURL() {
       return "PROXY 1.2.3.4:8080; UNEXPECTED; SOCKS 1.2.3.4:8080";
     }
   }, {
-    message: "FindProxyForURL: Unrecognized proxy type: \"unexpected\"",
+    message: "ProxyInfoData: Unrecognized proxy type: \"unexpected\"",
     proxyInfo: {
       host: "1.2.3.4",
       port: "8080",
       type: "http",
       failoverProxy: null,
     },
   });
 });
--- a/toolkit/components/extensions/test/xpcshell/test_proxy_scripts_results.js
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_scripts_results.js
@@ -100,97 +100,97 @@ async function testProxyResolution(test)
   }
 }
 
 add_task(async function test_pac_results() {
   let tests = [
     {
       proxy: undefined,
       expected: {
-        error: "FindProxyForURL: Return type must be a string or array of objects",
+        error: "ProxyInfoData: proxyData must be a string or array of objects",
       },
     },
     {
       proxy: 5,
       expected: {
-        error: "FindProxyForURL: Return type must be a string or array of objects",
+        error: "ProxyInfoData: proxyData must be a string or array of objects",
       },
     },
     {
       proxy: "INVALID",
       expected: {
-        error: "FindProxyForURL: Unrecognized proxy type: \"invalid\"",
+        error: "ProxyInfoData: Unrecognized proxy type: \"invalid\"",
       },
     },
     {
       proxy: "SOCKS",
       expected: {
-        error: "FindProxyForURL: Invalid host or port from proxy rule: \"SOCKS\"",
+        error: "ProxyInfoData: Invalid host or port from proxy rule: \"SOCKS\"",
       },
     },
     {
       proxy: "PROXY 1.2.3.4:8080 EXTRA",
       expected: {
-        error: "FindProxyForURL: Invalid arguments passed for proxy rule: \"PROXY 1.2.3.4:8080 EXTRA\"",
+        error: "ProxyInfoData: Invalid arguments passed for proxy rule: \"PROXY 1.2.3.4:8080 EXTRA\"",
       },
     },
     {
       proxy: "PROXY :",
       expected: {
-        error: "FindProxyForURL: Invalid host or port from proxy rule: \"PROXY :\"",
+        error: "ProxyInfoData: Invalid host or port from proxy rule: \"PROXY :\"",
       },
     },
     {
       proxy: "PROXY :8080",
       expected: {
-        error: "FindProxyForURL: Invalid host or port from proxy rule: \"PROXY :8080\"",
+        error: "ProxyInfoData: Invalid host or port from proxy rule: \"PROXY :8080\"",
       },
     },
     {
       proxy: "PROXY ::",
       expected: {
-        error: "FindProxyForURL: Invalid host or port from proxy rule: \"PROXY ::\"",
+        error: "ProxyInfoData: Invalid host or port from proxy rule: \"PROXY ::\"",
       },
     },
     {
       proxy: "PROXY 1.2.3.4:",
       expected: {
-        error: "FindProxyForURL: Invalid host or port from proxy rule: \"PROXY 1.2.3.4:\"",
+        error: "ProxyInfoData: Invalid host or port from proxy rule: \"PROXY 1.2.3.4:\"",
       },
     },
     {
       proxy: "DIRECT 1.2.3.4:8080",
       expected: {
-        error: "FindProxyForURL: Invalid argument for proxy type: \"direct\"",
+        error: "ProxyInfoData: Invalid argument for proxy type: \"direct\"",
       },
     },
     {
       proxy: ["SOCKS foo.bar:1080", {type: "http", host: "foo.bar", port: 3128}],
       expected: {
-        error: "FindProxyForURL: Invalid proxy server type: \"undefined\"",
+        error: "ProxyInfoData: Invalid proxy server type: \"undefined\"",
       },
     },
     {
       proxy: {type: "socks", host: "foo.bar", port: 1080, username: "mungosantamaria", password: "pass123"},
       expected: {
-        error: "FindProxyForURL: Return type must be a string or array of objects",
+        error: "ProxyInfoData: proxyData must be a string or array of objects",
       },
     },
     {
       proxy: [{type: "pptp", host: "foo.bar", port: 1080, username: "mungosantamaria", password: "pass123", proxyDNS: true, failoverTimeout: 3},
               {type: "http", host: "192.168.1.1", port: 1128, username: "mungosantamaria", password: "word321"}],
       expected: {
-        error: "FindProxyForURL: Invalid proxy server type: \"pptp\"",
+        error: "ProxyInfoData: Invalid proxy server type: \"pptp\"",
       },
     },
     {
       proxy: [{type: "http", host: "foo.bar", port: 65536, username: "mungosantamaria", password: "pass123", proxyDNS: true, failoverTimeout: 3},
               {type: "http", host: "192.168.1.1", port: 3128, username: "mungosantamaria", password: "word321"}],
       expected: {
-        error: "FindProxyForURL: Proxy server port 65536 outside range 1 to 65535",
+        error: "ProxyInfoData: Proxy server port 65536 outside range 1 to 65535",
       },
     },
     {
       proxy: [{type: "direct"}],
       expected: {
         proxyInfo: null,
       },
     },
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -45,16 +45,17 @@ skip-if = os == "android" # checking for
 skip-if = (os == "win" && !debug) #Bug 1419183 disable on Windows
 [test_ext_management_uninstall_self.js]
 [test_ext_onmessage_removelistener.js]
 skip-if = true # This test no longer tests what it is meant to test.
 [test_ext_privacy.js]
 [test_ext_privacy_disable.js]
 [test_ext_privacy_update.js]
 [test_ext_proxy_auth.js]
+[test_ext_proxy_onauthrequired.js]
 [test_ext_proxy_socks.js]
 [test_ext_redirects.js]
 [test_ext_runtime_connect_no_receiver.js]
 [test_ext_runtime_getBrowserInfo.js]
 [test_ext_runtime_getPlatformInfo.js]
 [test_ext_runtime_onInstalled_and_onStartup.js]
 skip-if = true # bug 1315829
 [test_ext_runtime_sendMessage.js]
@@ -79,11 +80,12 @@ skip-if = os == "android" # checking for
 [test_ext_trustworthy_origin.js]
 [test_ext_topSites.js]
 skip-if = os == "android"
 [test_native_manifests.js]
 subprocess = true
 skip-if = os == "android"
 [test_ext_permissions.js]
 skip-if = os == "android" # Bug 1350559
+[test_proxy_listener.js]
 [test_proxy_scripts.js]
 [test_proxy_scripts_results.js]
 [test_ext_brokenlinks.js]