Bug 1547140 add classification data to webRequest API r=zombie,kmag,Fallen
authorShane Caraveo <scaraveo@mozilla.com>
Wed, 14 Aug 2019 16:10:51 +0000
changeset 488105 59db91645f2e4c7edf66d8250c2d102fe8c6f2c9
parent 488104 c9f306364c7d79c316cedb484da364944909a1a4
child 488106 2758d131787315018cf4092f6dd0bd5952e47996
push id113900
push usercbrindusan@mozilla.com
push dateThu, 15 Aug 2019 09:53:50 +0000
treeherdermozilla-inbound@0db07ff50ab5 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerszombie, kmag, Fallen
bugs1547140
milestone70.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1547140 add classification data to webRequest API r=zombie,kmag,Fallen Differential Revision: https://phabricator.services.mozilla.com/D35911
dom/chrome-webidl/ChannelWrapper.webidl
toolkit/components/extensions/schemas/web_request.json
toolkit/components/extensions/test/mochitest/file_third_party.html
toolkit/components/extensions/test/mochitest/mochitest-common.ini
toolkit/components/extensions/test/mochitest/test_ext_webrequest_urlClassification.html
toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
toolkit/components/extensions/webrequest/ChannelWrapper.cpp
toolkit/components/extensions/webrequest/ChannelWrapper.h
toolkit/components/extensions/webrequest/WebRequest.jsm
--- a/dom/chrome-webidl/ChannelWrapper.webidl
+++ b/dom/chrome-webidl/ChannelWrapper.webidl
@@ -33,16 +33,38 @@ enum MozContentPolicyType {
   "csp_report",
   "imageset",
   "web_manifest",
   "speculative",
   "other"
 };
 
 /**
+ * String versions of CLASSIFIED_* tracking flags from nsHttpChannel.idl
+ */
+enum MozUrlClassificationFlags {
+  "fingerprinting",
+  "fingerprinting_content",
+  "cryptomining",
+  "cryptomining_content",
+  "tracking",
+  "tracking_ad",
+  "tracking_analytics",
+  "tracking_social",
+  "tracking_content",
+  "socialtracking",
+  "socialtracking_facebook",
+  "socialtracking_linkedin",
+  "socialtracking_twitter",
+  "any_basic_tracking",
+  "any_strict_tracking",
+  "any_social_tracking"
+};
+
+/**
  * A thin wrapper around nsIChannel and nsIHttpChannel that allows JS
  * callers to access them without XPConnect overhead.
  */
 [ChromeOnly, Exposed=Window]
 interface ChannelWrapper : EventTarget {
   /**
    * Returns the wrapper instance for the given channel. The same wrapper is
    * always returned for a given channel.
@@ -377,16 +399,30 @@ interface ChannelWrapper : EventTarget {
    *
    * Note: The content type header is handled specially by this function. See
    * getResponseHeaders() for details.
    */
   [Throws]
   void setResponseHeader(ByteString header,
                          ByteString value,
                          optional boolean merge = false);
+
+  /**
+   * Provides the tracking classification data when it is available.
+   */
+  [Cached, Frozen, GetterThrows, Pure]
+  readonly attribute MozUrlClassification? urlClassification;
+};
+
+/**
+ * Wrapper for first and third party tracking classification data.
+ */
+dictionary MozUrlClassification {
+  required sequence<MozUrlClassificationFlags> firstParty;
+  required sequence<MozUrlClassificationFlags> thirdParty;
 };
 
 /**
  * Information about the proxy server handing a request. This is approximately
  * equivalent to nsIProxyInfo.
  */
 dictionary MozProxyInfo {
   /**
--- a/toolkit/components/extensions/schemas/web_request.json
+++ b/toolkit/components/extensions/schemas/web_request.json
@@ -342,16 +342,38 @@
           },
           "file": {
             "type": "string",
             "optional": true,
             "description": "A string with the file's path and name."
           }
         },
         "description": "Contains data uploaded in a URL request."
+      },
+      {
+        "id": "UrlClassificationFlags",
+        "type": "string",
+        "enum": ["fingerprinting", "fingerprinting_content", "cryptomining", "cryptomining_content",
+                 "tracking", "tracking_ad", "tracking_analytics", "tracking_social", "tracking_content",
+                 "any_basic_tracking", "any_strict_tracking", "any_social_tracking"],
+        "description": "Tracking flags that match our internal tracking classification"
+      },
+      {
+        "id": "UrlClassificationParty",
+        "type": "array",
+        "items": {"$ref": "UrlClassificationFlags"},
+        "description": "If the request has been classified this is an array of $(ref:UrlClassificationFlags)."
+      },
+      {
+        "id": "UrlClassification",
+        "type": "object",
+        "properties": {
+          "firstParty": {"$ref": "UrlClassificationParty", "description": "First party classification flags if the request has been classified."},
+          "thirdParty": {"$ref": "UrlClassificationParty", "description": "Third party classification flags if the request has been classified."}
+        }
       }
     ],
     "functions": [
       {
         "name": "handlerBehaviorChanged",
         "type": "function",
         "description": "Needs to be called when the behavior of the webRequest handlers has changed to prevent incorrect handling due to caching. This function call is expensive. Don't call it often.",
         "async": "callback",
@@ -451,17 +473,18 @@
                     "optional": true,
                     "items": {"$ref": "UploadData"},
                     "description": "If the request method is PUT or POST, and the body is not already parsed in formData, then the unparsed request body elements are contained in this array."
                   }
                 }
               },
               "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": "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."}
+              "timeStamp": {"type": "number", "description": "The time when this signal is triggered, in milliseconds since the epoch."},
+              "urlClassification": {"$ref": "UrlClassification", "optional": true, "description": "Tracking classification if the request has been classified."}
             }
           }
         ],
         "extraParameters": [
           {
             "$ref": "RequestFilter",
             "name": "filter",
             "description": "A set of filters that restricts the events that will be sent to this listener."
@@ -498,17 +521,18 @@
               "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."},
               "incognito": {"type": "boolean", "optional": true, "description": "True for private browsing requests."},
               "cookieStoreId": {"type": "string", "optional": true, "description": "The cookie store ID of the contextual identity."},
               "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": "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."},
-              "requestHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP request headers that are going to be sent out with this request."}
+              "requestHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP request headers that are going to be sent out with this request."},
+              "urlClassification": {"$ref": "UrlClassification", "optional": true, "description": "Tracking classification if the request has been classified."}
             }
           }
         ],
         "extraParameters": [
           {
             "$ref": "RequestFilter",
             "name": "filter",
             "description": "A set of filters that restricts the events that will be sent to this listener."
@@ -545,17 +569,18 @@
               "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."},
               "incognito": {"type": "boolean", "optional": true, "description": "True for private browsing requests."},
               "cookieStoreId": {"type": "string", "optional": true, "description": "The cookie store ID of the contextual identity."},
               "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": "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."},
-              "requestHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP request headers that have been sent out with this request."}
+              "requestHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP request headers that have been sent out with this request."},
+              "urlClassification": {"$ref": "UrlClassification", "optional": true, "description": "Tracking classification if the request has been classified."}
             }
           }
         ],
         "extraParameters": [
           {
             "$ref": "RequestFilter",
             "name": "filter",
             "description": "A set of filters that restricts the events that will be sent to this listener."
@@ -589,17 +614,18 @@
               "cookieStoreId": {"type": "string", "optional": true, "description": "The cookie store ID of the contextual identity."},
               "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": "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."},
               "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line)."},
               "responseHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP response headers that have been received with this response."},
-              "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."}
+              "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."},
+              "urlClassification": {"$ref": "UrlClassification", "optional": true, "description": "Tracking classification if the request has been classified."}
              }
           }
         ],
         "extraParameters": [
           {
             "$ref": "RequestFilter",
             "name": "filter",
             "description": "A set of filters that restricts the events that will be sent to this listener."
@@ -642,17 +668,18 @@
               "type": {"$ref": "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."},
               "scheme": {"type": "string", "description": "The authentication scheme, e.g. Basic or Digest."},
               "realm": {"type": "string", "description": "The authentication realm provided by the server, if there is one.", "optional": true},
               "challenger": {"type": "object", "description": "The server requesting authentication.", "properties": {"host": {"type": "string"}, "port": {"type": "integer"}}},
               "isProxy": {"type": "boolean", "description": "True for Proxy-Authenticate, false for WWW-Authenticate."},
               "responseHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP response headers that were received along with this response."},
               "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers."},
-              "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."}
+              "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."},
+              "urlClassification": {"$ref": "UrlClassification", "optional": true, "description": "Tracking classification if the request has been classified."}
             }
           },
           {
             "type": "function",
             "optional": true,
             "name": "callback",
             "parameters": [
               {"name": "response", "$ref": "BlockingResponse"}
@@ -701,17 +728,18 @@
               "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": "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."},
               "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."},
               "responseHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP response headers that were received along with this response."},
-              "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers."}
+              "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers."},
+              "urlClassification": {"$ref": "UrlClassification", "optional": true, "description": "Tracking classification if the request has been classified."}
             }
           }
         ],
         "extraParameters": [
           {
             "$ref": "RequestFilter",
             "name": "filter",
             "description": "A set of filters that restricts the events that will be sent to this listener."
@@ -748,17 +776,18 @@
               "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": "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."},
               "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."},
               "redirectUrl": {"type": "string", "description": "The new URL."},
               "responseHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP response headers that were received along with this redirect."},
-              "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers."}
+              "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers."},
+              "urlClassification": {"$ref": "UrlClassification", "optional": true, "description": "Tracking classification if the request has been classified."}
             }
           }
         ],
         "extraParameters": [
           {
             "$ref": "RequestFilter",
             "name": "filter",
             "description": "A set of filters that restricts the events that will be sent to this listener."
@@ -794,17 +823,18 @@
               "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": "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."},
               "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."},
               "responseHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP response headers that were received along with this response."},
-              "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers."}
+              "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers."},
+              "urlClassification": {"$ref": "UrlClassification","description": "Tracking classification if the request has been classified."}
             }
           }
         ],
         "extraParameters": [
           {
             "$ref": "RequestFilter",
             "name": "filter",
             "description": "A set of filters that restricts the events that will be sent to this listener."
@@ -838,17 +868,18 @@
               "cookieStoreId": {"type": "string", "optional": true, "description": "The cookie store ID of the contextual identity."},
               "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": "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."},
-              "error": {"type": "string", "description": "The error description. This string is <em>not</em> guaranteed to remain backwards compatible between releases. You must not parse and act based upon its content."}
+              "error": {"type": "string", "description": "The error description. This string is <em>not</em> guaranteed to remain backwards compatible between releases. You must not parse and act based upon its content."},
+              "urlClassification": {"$ref": "UrlClassification", "optional": true, "description": "Tracking classification if the request has been classified."}
             }
           }
         ],
         "extraParameters": [
           {
             "$ref": "RequestFilter",
             "name": "filter",
             "description": "A set of filters that restricts the events that will be sent to this listener."
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_third_party.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<img id="tracking-image" src="http://tracking.example.org/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png" />
+
+</body>
+</html>
--- a/toolkit/components/extensions/test/mochitest/mochitest-common.ini
+++ b/toolkit/components/extensions/test/mochitest/mochitest-common.ini
@@ -23,16 +23,17 @@ support-files =
   file_simple_sandboxed_frame.html
   file_simple_sandboxed_subframe.html
   file_simple_xhr.html
   file_simple_xhr_frame.html
   file_simple_xhr_frame2.html
   file_style_bad.css
   file_style_good.css
   file_style_redirect.css
+  file_third_party.html
   file_to_drawWindow.html
   file_webNavigation_clientRedirect.html
   file_webNavigation_clientRedirect_httpHeaders.html
   file_webNavigation_clientRedirect_httpHeaders.html^headers^
   file_webNavigation_frameClientRedirect.html
   file_webNavigation_frameRedirect.html
   file_webNavigation_manualSubframe.html
   file_webNavigation_manualSubframe_page1.html
@@ -153,10 +154,11 @@ skip-if = os == 'android' && debug # bug
 skip-if = (webrender && os == 'linux') # Bug 1482983 caused by Bug 1480951
 [test_ext_webrequest_hsts.html]
 skip-if = fission || os == 'android' || os == 'linux' # linux, bug 1398120
 [test_ext_webrequest_upgrade.html]
 skip-if = fission
 [test_ext_webrequest_upload.html]
 skip-if = os == 'android' # Currently fails in emulator tests
 [test_ext_webrequest_redirect_data_uri.html]
+[test_ext_webrequest_urlClassification.html]
 [test_ext_window_postMessage.html]
 [test_ext_webrequest_redirect_bypass_cors.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_urlClassification.html
@@ -0,0 +1,99 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>Test for WebRequest urlClassification</title>
+  <script src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["privacy.trackingprotection.enabled", true]],
+  });
+
+  let chromeScript = SpecialPowers.loadChromeScript(async _ => {
+    /* global sendAsyncMessage */
+    const {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm");
+    await UrlClassifierTestUtils.addTestTrackers();
+    sendAsyncMessage("trackersLoaded");
+  });
+  await chromeScript.promiseOneMessage("trackersLoaded");
+  chromeScript.destroy();
+});
+
+add_task(async function test_webrequest_tracking() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      applications: {gecko: {id: "classification@mochi.test"}},
+      permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+    },
+    isPrivileged: true,
+    background() {
+      let expected = {
+        "http://tracking.example.org/": {first: "tracking"},
+        "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html": {},
+        "http://tracking.example.org/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png": {third: "tracking"},
+      };
+      browser.webRequest.onBeforeRequest.addListener(async (details) => {
+        browser.test.log(`request ${JSON.stringify(details)}`);
+        let expect = expected[details.url];
+        if (expect) {
+          if (expect.first) {
+            browser.test.assertTrue(details.urlClassification.firstParty.includes("tracking"), "tracking firstParty");
+          } else {
+            browser.test.assertEq(details.urlClassification.firstParty.length, 0, "not tracking firstParty");
+          }
+          if (expect.third) {
+            browser.test.assertTrue(details.urlClassification.thirdParty.includes("tracking"), "tracking thirdParty");
+          } else {
+            browser.test.assertEq(details.urlClassification.thirdParty.length, 0, "not tracking thirdParty");
+          }
+          browser.test.sendMessage("classification", details.url);
+        }
+      }, {urls: ["<all_urls>"]}, ["blocking"]);
+    },
+  });
+  await extension.startup();
+
+  // Test first party tracking classification.
+  let url = "http://tracking.example.org/";
+  let win = window.open(url);
+  is(await extension.awaitMessage("classification"), url);
+  win.close();
+  // Test third party tracking classification, expecting two results.
+  url = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html";
+  win = window.open(url);
+  is(await extension.awaitMessage("classification"), url);
+  is(await extension.awaitMessage("classification"), "http://tracking.example.org/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png");
+  win.close();
+
+  await extension.unload();
+});
+
+add_task(async function teardown() {
+  let chromeScript = SpecialPowers.loadChromeScript(async _ => {
+    // Cleanup cache
+    await new Promise(resolve => {
+      const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+      Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, value => resolve());
+    });
+
+    /* global sendAsyncMessage */
+    const {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm");
+    await UrlClassifierTestUtils.cleanupTestTrackers();
+    sendAsyncMessage("trackersUnloaded");
+  });
+  await chromeScript.promiseOneMessage("trackersUnloaded");
+  chromeScript.destroy();
+});
+
+</script>
+
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js
@@ -0,0 +1,33 @@
+"use strict";
+
+const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm");
+
+/**
+ * If this test fails, likely nsIHttpChannel has added or changed a
+ * CLASSIFIED_* flag.  Those changes must be in sync with
+ * ChannelWrapper.webidl/cpp and the web_request.json schema file.
+ */
+add_task(async function test_webrequest_url_classification_enum() {
+  // use normalizeManifest to get the schema loaded.
+  await ExtensionTestUtils.normalizeManifest({ permissions: ["webRequest"] });
+
+  let ns = Schemas.getNamespace("webRequest");
+  let schema_enum = ns.get("UrlClassificationFlags").enumeration;
+  ok(
+    schema_enum.length > 0,
+    `UrlClassificationFlags: ${JSON.stringify(schema_enum)}`
+  );
+
+  let prefix = /^(?:CLASSIFIED_)/;
+  let entries = 0;
+  for (let c of Object.keys(Ci.nsIHttpChannel).filter(name =>
+    prefix.test(name)
+  )) {
+    let entry = c.replace(prefix, "").toLowerCase();
+    if (!entry.startsWith("socialtracking")) {
+      ok(schema_enum.includes(entry), `schema ${entry} is in IDL`);
+      entries++;
+    }
+  }
+  equal(schema_enum.length, entries, "same number of entries");
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -53,16 +53,17 @@ skip-if = os == 'android' && processor =
 skip-if = os == 'android' && processor == 'x86_64'
 [test_ext_schemas_privileged.js]
 skip-if = os == 'android' && processor == 'x86_64'
 [test_ext_schemas_revoke.js]
 [test_ext_test_mock.js]
 skip-if = os == 'android' && processor == 'x86_64'
 [test_ext_unknown_permissions.js]
 skip-if = os == 'android' && processor == 'x86_64'
+[test_ext_webRequest_urlclassification.js]
 [test_load_all_api_modules.js]
 [test_locale_converter.js]
 [test_locale_data.js]
 skip-if = os == 'android' && processor == 'x86_64'
 [test_ext_ipcBlob.js]
 skip-if = os == 'android' && processor == 'x86_64'
 
 [test_ext_runtime_sendMessage_args.js]
--- a/toolkit/components/extensions/webrequest/ChannelWrapper.cpp
+++ b/toolkit/components/extensions/webrequest/ChannelWrapper.cpp
@@ -43,16 +43,41 @@ using namespace mozilla::dom;
 using namespace JS;
 
 namespace mozilla {
 namespace extensions {
 
 #define CHANNELWRAPPER_PROP_KEY \
   NS_LITERAL_STRING("ChannelWrapper::CachedInstance")
 
+using CF = nsIHttpChannel::ClassificationFlags;
+using MUC = MozUrlClassificationFlags;
+
+struct ClassificationStruct {
+  uint32_t mFlag;
+  MozUrlClassificationFlags mValue;
+};
+static const ClassificationStruct classificationArray[] = {
+    {CF::CLASSIFIED_FINGERPRINTING, MUC::Fingerprinting},
+    {CF::CLASSIFIED_FINGERPRINTING_CONTENT, MUC::Fingerprinting_content},
+    {CF::CLASSIFIED_CRYPTOMINING, MUC::Cryptomining},
+    {CF::CLASSIFIED_CRYPTOMINING_CONTENT, MUC::Cryptomining_content},
+    {CF::CLASSIFIED_TRACKING, MUC::Tracking},
+    {CF::CLASSIFIED_TRACKING_AD, MUC::Tracking_ad},
+    {CF::CLASSIFIED_TRACKING_ANALYTICS, MUC::Tracking_analytics},
+    {CF::CLASSIFIED_TRACKING_SOCIAL, MUC::Tracking_social},
+    {CF::CLASSIFIED_TRACKING_CONTENT, MUC::Tracking_content},
+    {CF::CLASSIFIED_SOCIALTRACKING, MUC::Socialtracking},
+    {CF::CLASSIFIED_SOCIALTRACKING_FACEBOOK, MUC::Socialtracking_facebook},
+    {CF::CLASSIFIED_SOCIALTRACKING_LINKEDIN, MUC::Socialtracking_linkedin},
+    {CF::CLASSIFIED_SOCIALTRACKING_TWITTER, MUC::Socialtracking_twitter},
+    {CF::CLASSIFIED_ANY_BASIC_TRACKING, MUC::Any_basic_tracking},
+    {CF::CLASSIFIED_ANY_STRICT_TRACKING, MUC::Any_strict_tracking},
+    {CF::CLASSIFIED_ANY_SOCIAL_TRACKING, MUC::Any_social_tracking}};
+
 /*****************************************************************************
  * Lifetimes
  *****************************************************************************/
 
 namespace {
 class ChannelListHolder : public LinkedList<ChannelWrapper> {
  public:
   ChannelListHolder() : LinkedList<ChannelWrapper>() {}
@@ -165,16 +190,17 @@ void ChannelWrapper::SetChannel(nsIChann
   mFinalURLInfo.reset();
   ChannelWrapper_Binding::ClearCachedProxyInfoValue(this);
 }
 
 void ChannelWrapper::ClearCachedAttributes() {
   ChannelWrapper_Binding::ClearCachedRemoteAddressValue(this);
   ChannelWrapper_Binding::ClearCachedStatusCodeValue(this);
   ChannelWrapper_Binding::ClearCachedStatusLineValue(this);
+  ChannelWrapper_Binding::ClearCachedUrlClassificationValue(this);
   if (!mFiredErrorEvent) {
     ChannelWrapper_Binding::ClearCachedErrorStringValue(this);
   }
 }
 
 /*****************************************************************************
  * ...
  *****************************************************************************/
@@ -856,16 +882,48 @@ void ChannelWrapper::GetProxyInfo(dom::N
 
 void ChannelWrapper::GetRemoteAddress(nsCString& aRetVal) const {
   aRetVal.SetIsVoid(true);
   if (nsCOMPtr<nsIHttpChannelInternal> internal = QueryChannel()) {
     Unused << internal->GetRemoteAddress(aRetVal);
   }
 }
 
+void FillClassification(
+    Sequence<mozilla::dom::MozUrlClassificationFlags>& classifications,
+    uint32_t classificationFlags, ErrorResult& aRv) {
+  if (classificationFlags == 0) {
+    return;
+  }
+  for (const auto& entry : classificationArray) {
+    if (classificationFlags & entry.mFlag) {
+      if (!classifications.AppendElement(entry.mValue, mozilla::fallible)) {
+        aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+        return;
+      }
+    }
+  }
+}
+
+void ChannelWrapper::GetUrlClassification(
+    dom::Nullable<dom::MozUrlClassification>& aRetVal, ErrorResult& aRv) const {
+  MozUrlClassification classification;
+  if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+    uint32_t classificationFlags;
+    chan->GetFirstPartyClassificationFlags(&classificationFlags);
+    FillClassification(classification.mFirstParty, classificationFlags, aRv);
+    if (aRv.Failed()) {
+      return;
+    }
+    chan->GetThirdPartyClassificationFlags(&classificationFlags);
+    FillClassification(classification.mThirdParty, classificationFlags, aRv);
+  }
+  aRetVal.SetValue(std::move(classification));
+}
+
 /*****************************************************************************
  * Error handling
  *****************************************************************************/
 
 void ChannelWrapper::GetErrorString(nsString& aRetVal) const {
   if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) {
     nsCOMPtr<nsISupports> securityInfo;
     Unused << chan->GetSecurityInfo(getter_AddRefs(securityInfo));
--- a/toolkit/components/extensions/webrequest/ChannelWrapper.h
+++ b/toolkit/components/extensions/webrequest/ChannelWrapper.h
@@ -222,16 +222,19 @@ class ChannelWrapper final : public DOME
                           ErrorResult& aRv) const;
 
   void SetRequestHeader(const nsCString& header, const nsCString& value,
                         bool merge, ErrorResult& aRv);
 
   void SetResponseHeader(const nsCString& header, const nsCString& value,
                          bool merge, ErrorResult& aRv);
 
+  void GetUrlClassification(dom::Nullable<dom::MozUrlClassification>& aRetVal,
+                            ErrorResult& aRv) const;
+
   using EventTarget::EventListenerAdded;
   using EventTarget::EventListenerRemoved;
   virtual void EventListenerAdded(nsAtom* aType) override;
   virtual void EventListenerRemoved(nsAtom* aType) override;
 
   nsISupports* GetParentObject() const { return mParent; }
 
   JSObject* WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) override;
--- a/toolkit/components/extensions/webrequest/WebRequest.jsm
+++ b/toolkit/components/extensions/webrequest/WebRequest.jsm
@@ -212,16 +212,17 @@ const OPTIONAL_PROPERTIES = [
   "requestBody",
   "scheme",
   "realm",
   "isProxy",
   "challenger",
   "proxyInfo",
   "ip",
   "frameAncestors",
+  "urlClassification",
 ];
 
 function serializeRequestData(eventName) {
   let data = {
     requestId: this.requestId,
     url: this.url,
     originUrl: this.originUrl,
     documentUrl: this.documentUrl,
@@ -236,16 +237,28 @@ function serializeRequestData(eventName)
     data.fromCache = !!this.fromCache;
   }
 
   for (let opt of OPTIONAL_PROPERTIES) {
     if (typeof this[opt] !== "undefined") {
       data[opt] = this[opt];
     }
   }
+
+  if (this.urlClassification) {
+    data.urlClassification = {
+      firstParty: this.urlClassification.firstParty.filter(
+        c => !c.startsWith("socialtracking_")
+      ),
+      thirdParty: this.urlClassification.thirdParty.filter(
+        c => !c.startsWith("socialtracking_")
+      ),
+    };
+  }
+
   return data;
 }
 
 var HttpObserverManager;
 
 var nextFakeRequestId = 1;
 
 var ContentPolicyManager = {
@@ -739,17 +752,17 @@ HttpObserverManager = {
       lastActivity !== this.GOOD_LAST_ACTIVITY &&
       lastActivity !==
         nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE
     ) {
       channel.lastActivity = activitySubtype;
     }
   },
 
-  getRequestData(channel, extraData) {
+  getRequestData(channel, extraData, policy) {
     let originAttributes =
       channel.loadInfo && channel.loadInfo.originAttributes;
     let data = {
       requestId: String(channel.id),
       url: channel.finalURL,
       method: channel.method,
       browser: channel.browserElement,
       type: channel.type,
@@ -766,16 +779,22 @@ HttpObserverManager = {
 
       ip: channel.remoteAddress,
 
       proxyInfo: channel.proxyInfo,
 
       serialize: serializeRequestData,
     };
 
+    // We're limiting access to
+    // urlClassification while the feature is further fleshed out.
+    if (policy && policy.extension.isPrivileged) {
+      data.urlClassification = channel.urlClassification;
+    }
+
     return Object.assign(data, extraData);
   },
 
   handleEvent(event) {
     let channel = event.currentTarget;
     switch (event.type) {
       case "error":
         this.runChannelListener(channel, "onError", {
@@ -821,17 +840,17 @@ HttpObserverManager = {
       let commonData = null;
       let requestBody;
       this.listeners[kind].forEach((opts, callback) => {
         if (!channel.matches(opts.filter, opts.extension, extraData)) {
           return;
         }
 
         if (!commonData) {
-          commonData = this.getRequestData(channel, extraData);
+          commonData = this.getRequestData(channel, extraData, opts.extension);
           if (this.STATUS_TYPES.has(kind)) {
             commonData.statusCode = channel.statusCode;
             commonData.statusLine = channel.statusLine;
           }
         }
         let data = Object.create(commonData);
 
         if (registerFilter && opts.blocking && opts.extension) {