Bug 1376991 - Extend browsingData to restrict removing cookies to a give list of hostnames; r?mixedpuppy draft
authorThomas Wisniewski <wisniewskit@gmail.com>
Fri, 30 Jun 2017 09:11:18 -0400
changeset 605650 3250dc44a5b9f2905a7864b580859ab553c7e694
parent 602051 f3483af8ecf997453064201c49c48a682c7f3c29
child 636568 0bd2084afd575520e91ee2085ecb19103f3b8385
push id67490
push userwisniewskit@gmail.com
push dateSat, 08 Jul 2017 18:12:19 +0000
reviewersmixedpuppy
bugs1376991
milestone56.0a1
Bug 1376991 - Extend browsingData to restrict removing cookies to a give list of hostnames; r?mixedpuppy MozReview-Commit-ID: 4Tfneh5s1Q8 *** Fixes for try run failures MozReview-Commit-ID: 2BAT1GUcvH3
browser/components/extensions/ext-browsingData.js
browser/components/extensions/schemas/browsing_data.json
browser/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
--- a/browser/components/extensions/ext-browsingData.js
+++ b/browser/components/extensions/ext-browsingData.js
@@ -43,23 +43,24 @@ function clearCache() {
   return sanitizer.items.cache.clear();
 }
 
 let clearCookies = async function(options) {
   let cookieMgr = Services.cookies;
   // This code has been borrowed from sanitize.js.
   let yieldCounter = 0;
 
-  if (options.since) {
+  if (options.since || options.hostnames) {
     // Iterate through the cookies and delete any created after our cutoff.
     let cookiesEnum = cookieMgr.enumerator;
     while (cookiesEnum.hasMoreElements()) {
       let cookie = cookiesEnum.getNext().QueryInterface(Ci.nsICookie2);
 
-      if (cookie.creationTime >= PlacesUtils.toPRTime(options.since)) {
+      if ((!options.since || cookie.creationTime >= PlacesUtils.toPRTime(options.since)) &&
+          (!options.hostnames || options.hostnames.includes(cookie.host.replace(/^\./, "")))) {
         // This cookie was created after our cutoff, clear it.
         cookieMgr.remove(cookie.host, cookie.name, cookie.path,
                          false, cookie.originAttributes);
 
         if (++yieldCounter % YIELD_PERIOD == 0) {
           await new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long.
         }
       }
--- a/browser/components/extensions/schemas/browsing_data.json
+++ b/browser/components/extensions/schemas/browsing_data.json
@@ -27,16 +27,22 @@
         "type": "object",
         "description": "Options that determine exactly what data will be removed.",
         "properties": {
           "since": {
             "$ref": "extensionTypes.Date",
             "optional": true,
             "description": "Remove data accumulated on or after this date, represented in milliseconds since the epoch (accessible via the <code>getTime</code> method of the JavaScript <code>Date</code> object). If absent, defaults to 0 (which would remove all browsing data)."
           },
+          "hostnames": {
+            "type": "array",
+            "items": {"type": "string", "format": "hostname"},
+            "optional": true,
+            "description": "Only remove data associated with these hostnames (only applies to cookies)."
+          },
           "originTypes": {
             "type": "object",
             "optional": true,
             "description": "An object whose properties specify which origin types ought to be cleared. If this object isn't specified, it defaults to clearing only \"unprotected\" origins. Please ensure that you <em>really</em> want to remove application data before adding 'protectedWeb' or 'extensions'.",
             "properties": {
               "unprotectedWeb": {
                 "type": "boolean",
                 "optional": true,
--- a/browser/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js
@@ -5,33 +5,49 @@
 XPCOMUtils.defineLazyModuleGetter(this, "setTimeout",
                                   "resource://gre/modules/Timer.jsm");
 
 const COOKIE = {
   host: "example.com",
   name: "test_cookie",
   path: "/",
 };
+const COOKIE_NET = {
+  host: "example.net",
+  name: "test_cookie",
+  path: "/",
+};
+const COOKIE_ORG = {
+  host: "example.org",
+  name: "test_cookie",
+  path: "/",
+};
 let since, oldCookie;
 
 function addCookie(cookie) {
   Services.cookies.add(cookie.host, cookie.path, cookie.name, "test", false, false, false, Date.now() / 1000 + 10000);
   ok(Services.cookies.cookieExists(cookie), `Cookie ${cookie.name} was created.`);
 }
 
 async function setUpCookies() {
+  Services.cookies.removeAll();
+
   // Add a cookie which will end up with an older creationTime.
   oldCookie = Object.assign({}, COOKIE, {name: Date.now()});
   addCookie(oldCookie);
   await new Promise(resolve => setTimeout(resolve, 10));
   since = Date.now();
   await new Promise(resolve => setTimeout(resolve, 10));
 
   // Add a cookie which will end up with a more recent creationTime.
   addCookie(COOKIE);
+
+  // Add cookies for different domains.
+  addCookie(COOKIE_NET);
+  addCookie(COOKIE_ORG);
 }
 
 add_task(async function testCache() {
   function background() {
     browser.test.onMessage.addListener(async msg => {
       if (msg == "removeCache") {
         await browser.browsingData.removeCache({});
       } else {
@@ -153,20 +169,56 @@ add_task(async function testCacheAndCook
   awaitNotification = TestUtils.topicObserved("cacheservice:empty-cache");
   extension.sendMessage({since: since - 100000});
   await awaitNotification;
   await extension.awaitMessage("cacheAndCookiesRemoved");
 
   ok(!Services.cookies.cookieExists(oldCookie), "Old cookie was removed.");
   ok(!Services.cookies.cookieExists(COOKIE), "Recent cookie was removed.");
 
-  // Clear cache and cookies with no since value.
+  // Clear cache and cookies with hostnames value.
+  await setUpCookies();
+  awaitNotification = TestUtils.topicObserved("cacheservice:empty-cache");
+  extension.sendMessage({hostnames: ["example.net", "example.org", "unknown.com"]});
+  await awaitNotification;
+  await extension.awaitMessage("cacheAndCookiesRemoved");
+
+  ok(Services.cookies.cookieExists(COOKIE), `Cookie ${COOKIE.name}  was not removed.`);
+  ok(!Services.cookies.cookieExists(COOKIE_NET), `Cookie ${COOKIE_NET.name}  was removed.`);
+  ok(!Services.cookies.cookieExists(COOKIE_ORG), `Cookie ${COOKIE_ORG.name}  was removed.`);
+
+  // Clear cache and cookies with (empty) hostnames value.
+  await setUpCookies();
+  awaitNotification = TestUtils.topicObserved("cacheservice:empty-cache");
+  extension.sendMessage({hostnames: []});
+  await awaitNotification;
+  await extension.awaitMessage("cacheAndCookiesRemoved");
+
+  ok(Services.cookies.cookieExists(COOKIE), `Cookie ${COOKIE.name}  was not removed.`);
+  ok(Services.cookies.cookieExists(COOKIE_NET), `Cookie ${COOKIE_NET.name}  was not removed.`);
+  ok(Services.cookies.cookieExists(COOKIE_ORG), `Cookie ${COOKIE_ORG.name}  was not removed.`);
+
+  // Clear cache and cookies with both hostnames and since values.
+  await setUpCookies();
+  awaitNotification = TestUtils.topicObserved("cacheservice:empty-cache");
+  extension.sendMessage({hostnames: ["example.com"], since});
+  await awaitNotification;
+  await extension.awaitMessage("cacheAndCookiesRemoved");
+
+  ok(Services.cookies.cookieExists(oldCookie), "Old cookie was not removed.");
+  ok(!Services.cookies.cookieExists(COOKIE), "Recent cookie was removed.");
+  ok(Services.cookies.cookieExists(COOKIE_NET), "Cookie with different hostname was not removed");
+  ok(Services.cookies.cookieExists(COOKIE_ORG), "Cookie with different hostname was not removed");
+
+  // Clear cache and cookies with no since or hostnames value.
   await setUpCookies();
   awaitNotification = TestUtils.topicObserved("cacheservice:empty-cache");
   extension.sendMessage({});
   await awaitNotification;
   await extension.awaitMessage("cacheAndCookiesRemoved");
 
   ok(!Services.cookies.cookieExists(COOKIE), `Cookie ${COOKIE.name}  was removed.`);
   ok(!Services.cookies.cookieExists(oldCookie), `Cookie ${oldCookie.name}  was removed.`);
+  ok(!Services.cookies.cookieExists(COOKIE_NET), `Cookie ${COOKIE_NET.name}  was removed.`);
+  ok(!Services.cookies.cookieExists(COOKIE_ORG), `Cookie ${COOKIE_ORG.name}  was removed.`);
 
   await extension.unload();
 });
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -806,16 +806,32 @@ class InjectionContext extends Context {
  * The methods in this singleton represent the "format" specifier for
  * JSON Schema string types.
  *
  * Each method either returns a normalized version of the original
  * value, or throws an error if the value is not valid for the given
  * format.
  */
 const FORMATS = {
+  hostname(string, context) {
+    let valid = true;
+
+    try {
+      valid = new URL(`http://${string}`).host === string;
+    } catch (e) {
+      valid = false;
+    }
+
+    if (!valid) {
+      throw new Error(`Invalid hostname ${string}`);
+    }
+
+    return string;
+  },
+
   url(string, context) {
     let url = new URL(string).href;
 
     if (!context.checkLoadURL(url)) {
       throw new Error(`Access denied for URL ${url}`);
     }
     return url;
   },
--- a/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
@@ -208,16 +208,17 @@ let json = [
      {
        name: "format",
        type: "function",
        parameters: [
          {
            name: "arg",
            type: "object",
            properties: {
+             hostname: {type: "string", "format": "hostname", "optional": true},
              url: {type: "string", "format": "url", "optional": true},
              relativeUrl: {type: "string", "format": "relativeUrl", "optional": true},
              strictRelativeUrl: {type: "string", "format": "strictRelativeUrl", "optional": true},
            },
          },
        ],
      },
 
@@ -617,25 +618,40 @@ add_task(async function() {
   root.testing.pattern("DEADbeef");
   verify("call", "testing", "pattern", ["DEADbeef"]);
   tallied = null;
 
   Assert.throws(() => root.testing.pattern("DEADcow"),
                 /String "DEADcow" must match \/\^\[0-9a-f\]\+\$\/i/,
                 "should throw for non-match");
 
+  root.testing.format({hostname: "foo"});
+  verify("call", "testing", "format", [{hostname: "foo",
+                                        url: null,
+                                        relativeUrl: null,
+                                        strictRelativeUrl: null}]);
+  tallied = null;
+
+  for (let invalid of ["", " ", "http://foo", "foo/bar", "foo.com/", "foo?"]) {
+    Assert.throws(() => root.testing.format({hostname: invalid}),
+                  /Invalid hostname/,
+                  "should throw for invalid hostname");
+  }
+
   root.testing.format({url: "http://foo/bar",
                        relativeUrl: "http://foo/bar"});
-  verify("call", "testing", "format", [{url: "http://foo/bar",
+  verify("call", "testing", "format", [{hostname: null,
+                                        url: "http://foo/bar",
                                         relativeUrl: "http://foo/bar",
                                         strictRelativeUrl: null}]);
   tallied = null;
 
   root.testing.format({relativeUrl: "foo.html", strictRelativeUrl: "foo.html"});
-  verify("call", "testing", "format", [{url: null,
+  verify("call", "testing", "format", [{hostname: null,
+                                        url: null,
                                         relativeUrl: `${wrapper.url}foo.html`,
                                         strictRelativeUrl: `${wrapper.url}foo.html`}]);
   tallied = null;
 
   for (let format of ["url", "relativeUrl"]) {
     Assert.throws(() => root.testing.format({[format]: "chrome://foo/content/"}),
                   /Access denied/,
                   "should throw for access denied");