From 4d5eecd33ff6c5dc5cb23a8e8d896f2947aa6322 Mon Sep 17 00:00:00 2001 From: Karim Rahal Date: Fri, 23 Jul 2021 15:13:37 +0000 Subject: [PATCH] Bug 1669566 - Add cookieStoreId functionality to browser.download.download, browser.download.search, and browser.download.erase; add unit tests. r=robwu,geckoview-reviewers,agi Differential Revision: https://phabricator.services.mozilla.com/D119967 --- .../components/extensions/ext-downloads.js | 5 + toolkit/components/downloads/DownloadCore.jsm | 9 + .../extensions/parent/ext-downloads.js | 24 + .../extensions/schemas/downloads.json | 15 + .../test_ext_downloads_cookieStoreId.js | 468 ++++++++++++++++++ .../test/xpcshell/xpcshell-common.ini | 2 + 6 files changed, 523 insertions(+) create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js diff --git a/mobile/android/components/extensions/ext-downloads.js b/mobile/android/components/extensions/ext-downloads.js index 5a5cc05f5fc8..e5e03449c546 100644 --- a/mobile/android/components/extensions/ext-downloads.js +++ b/mobile/android/components/extensions/ext-downloads.js @@ -193,6 +193,11 @@ this.downloads = class extends ExtensionAPI { }); } + if (options.cookieStoreId != null) { + // https://bugzilla.mozilla.org/show_bug.cgi?id=1721460 + throw new ExtensionError("Not implemented"); + } + if (options.headers) { for (const { name } of options.headers) { if ( diff --git a/toolkit/components/downloads/DownloadCore.jsm b/toolkit/components/downloads/DownloadCore.jsm index a1f38f7a600c..0b49980baaea 100644 --- a/toolkit/components/downloads/DownloadCore.jsm +++ b/toolkit/components/downloads/DownloadCore.jsm @@ -2361,6 +2361,15 @@ DownloadCopySaver.prototype = { download.source.cookieJarSettings; } + if (download.source.userContextId) { + // Getters and setters only exist on originAttributes, + // so it has to be cloned, changed, and re-set + channel.loadInfo.originAttributes = { + ...channel.loadInfo.originAttributes, + userContextId: download.source.userContextId, + }; + } + // This makes the channel be corretly throttled during page loads // and also prevents its caching. if (channel instanceof Ci.nsIHttpChannelInternal) { diff --git a/toolkit/components/extensions/parent/ext-downloads.js b/toolkit/components/extensions/parent/ext-downloads.js index 3d9d737b4d5b..d6550ddcc94c 100644 --- a/toolkit/components/extensions/parent/ext-downloads.js +++ b/toolkit/components/extensions/parent/ext-downloads.js @@ -34,6 +34,7 @@ const DOWNLOAD_ITEM_FIELDS = [ "referrer", "filename", "incognito", + "cookieStoreId", "danger", "mime", "startTime", @@ -187,6 +188,15 @@ class DownloadItem { get incognito() { return this.download.source.isPrivate; } + get cookieStoreId() { + if (this.download.source.isPrivate) { + return PRIVATE_STORE; + } + if (this.download.source.userContextId) { + return getCookieStoreIdForContainer(this.download.source.userContextId); + } + return DEFAULT_STORE; + } get danger() { return "safe"; } // TODO @@ -526,6 +536,7 @@ const downloadQuery = query => { "paused", "error", "incognito", + "cookieStoreId", "bytesReceived", "totalBytes", "fileSize", @@ -688,6 +699,15 @@ this.downloads = class extends ExtensionAPI { } } + let userContextId = null; + if (options.cookieStoreId != null) { + userContextId = getUserContextIdForCookieStoreId( + extension, + options.cookieStoreId, + options.incognito + ); + } + // Handle method, headers and body options. function adjustChannel(channel) { if (channel instanceof Ci.nsIHttpChannel) { @@ -931,6 +951,10 @@ this.downloads = class extends ExtensionAPI { cookieJarSettings, }; + if (userContextId) { + source.userContextId = userContextId; + } + // blob:-URLs can only be loaded by the principal with which they // are associated. This principal may have origin attributes. // `context.principal` does sometimes not have these attributes diff --git a/toolkit/components/extensions/schemas/downloads.json b/toolkit/components/extensions/schemas/downloads.json index ae1e0356a983..15d314d8bebb 100644 --- a/toolkit/components/extensions/schemas/downloads.json +++ b/toolkit/components/extensions/schemas/downloads.json @@ -106,6 +106,11 @@ "description": "False if this download is recorded in the history, true if it is not recorded.", "type": "boolean" }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, "danger": { "$ref": "DangerType", "description": "Indication of whether this download is thought to be safe or known to be suspicious." @@ -300,6 +305,11 @@ "optional": true, "type": "string" }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, "danger": { "$ref": "DangerType", "description": "Indication of whether this download is thought to be safe or known to be suspicious.", @@ -383,6 +393,11 @@ "default": false, "type": "boolean" }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity; requires \"cookies\" permission." + }, "conflictAction": { "$ref": "FilenameConflictAction", "optional": true diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js new file mode 100644 index 000000000000..5db25588216f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js @@ -0,0 +1,468 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function cookiesToMime(cookies) { + return `dummy/${encodeURIComponent(cookies)}`.toLowerCase(); +} + +function mimeToCookies(mime) { + return decodeURIComponent(mime.replace("dummy/", "")); +} + +const server = createHttpServer({ hosts: ["example.net"] }); + +server.registerPathHandler("/download", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + let cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; + // Assign the result through the MIME-type, to make it easier to read the + // result via the downloads API. + response.setHeader("Content-Type", cookiesToMime(cookies)); + // Response of length 7. + response.write("1234567"); +}); + +const DOWNLOAD_URL = "http://example.net/download"; + +async function setUpCookies() { + Services.cookies.removeAll(); + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["cookies", "http://example.net/download"], + }, + async background() { + let url = "http://example.net/download"; + // Add default cookie + await browser.cookies.set({ + url, + name: "cookie_normal", + value: "1", + }); + + // Add private cookie + await browser.cookies.set({ + url, + storeId: "firefox-private", + name: "cookie_private", + value: "1", + }); + + // Add container cookie + await browser.cookies.set({ + url, + storeId: "firefox-container-1", + name: "cookie_container", + value: "1", + }); + browser.test.sendMessage("cookies set"); + }, + }); + await extension.startup(); + await extension.awaitMessage("cookies set"); + await extension.unload(); +} + +function createDownloadTestExtension(extraPermissions = [], incognito = false) { + let extensionOptions = { + manifest: { + permissions: ["downloads", ...extraPermissions], + }, + background() { + browser.test.onMessage.addListener(async (method, data) => { + async function getDownload(data) { + let donePromise = new Promise(resolve => { + browser.downloads.onChanged.addListener(async delta => { + if (delta.state?.current === "complete") { + resolve(delta.id); + } + }); + }); + let downloadId = await browser.downloads.download(data); + browser.test.assertEq(await donePromise, downloadId, "got download"); + let [download] = await browser.downloads.search({ id: downloadId }); + browser.test.log(`Download results: ${JSON.stringify(download)}`); + // Delete the file since we aren't interested in it. + // TODO bug 1654819: On Windows the file may be recreated. + await browser.downloads.removeFile(download.id); + // Sanity check to verify that we got the result from /download. + browser.test.assertEq(7, download.fileSize, "download succeeded"); + return download; + } + function checkDownloadError(data) { + return browser.test.assertRejects( + browser.downloads.download(data.downloadData), + data.exceptionRe + ); + } + function search(data) { + return browser.downloads.search(data); + } + function erase(data) { + return browser.downloads.erase(data); + } + switch (method) { + case "getDownload": + return browser.test.sendMessage(method, await getDownload(data)); + case "checkDownloadError": + return browser.test.sendMessage( + method, + await checkDownloadError(data) + ); + case "search": + return browser.test.sendMessage(method, await search(data)); + case "erase": + return browser.test.sendMessage(method, await erase(data)); + } + }); + }, + }; + if (incognito) { + extensionOptions.incognitoOverride = "spanning"; + } + return ExtensionTestUtils.loadExtension(extensionOptions); +} + +function getResult(extension, method, data) { + extension.sendMessage(method, data); + return extension.awaitMessage(method); +} + +async function getCookies(extension, data) { + let download = await getResult(extension, "getDownload", data); + // The "/download" endpoint mirrors received cookies via Content-Type. + let cookies = mimeToCookies(download.mime); + return cookies; +} + +async function runTests(extension, containerDownloadAllowed, privateAllowed) { + let forcedIncognitoException = null; + if (!privateAllowed) { + forcedIncognitoException = /private browsing access not allowed/; + } else if (!containerDownloadAllowed) { + forcedIncognitoException = /No permission for cookieStoreId/; + } + + // Test default container download + if (containerDownloadAllowed) { + equal( + await getCookies(extension, { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-default", + }), + "cookie_normal=1", + "Default container cookies for downloads.download" + ); + } else { + await getResult(extension, "checkDownloadError", { + exceptionRe: /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-default", + }, + }); + } + + // Test private container download + if (privateAllowed && containerDownloadAllowed) { + equal( + await getCookies(extension, { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-private", + incognito: true, + }), + "cookie_private=1", + "Private container cookies for downloads.download" + ); + } else { + await getResult(extension, "checkDownloadError", { + exceptionRe: forcedIncognitoException, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-private", + incognito: true, + }, + }); + } + + // Test firefox-container-1 download + if (containerDownloadAllowed) { + equal( + await getCookies(extension, { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-container-1", + }), + "cookie_container=1", + "firefox-container-1 cookies for downloads.download" + ); + } else { + await getResult(extension, "checkDownloadError", { + exceptionRe: /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-container-1", + }, + }); + } + + // Test mismatched incognito and cookieStoreId download + await getResult(extension, "checkDownloadError", { + exceptionRe: forcedIncognitoException + ? forcedIncognitoException + : /Illegal to set non-private cookieStoreId in a private window/, + downloadData: { + url: DOWNLOAD_URL, + incognito: true, + cookieStoreId: "firefox-container-1", + }, + }); + await getResult(extension, "checkDownloadError", { + exceptionRe: containerDownloadAllowed + ? /Illegal to set private cookieStoreId in a non-private window/ + : /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + incognito: false, + cookieStoreId: "firefox-private", + }, + }); + + // Test invalid cookieStoreId download + await getResult(extension, "checkDownloadError", { + exceptionRe: containerDownloadAllowed + ? /Illegal cookieStoreId/ + : /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "invalid-invalid-invalid", + }, + }); + + let searchRes, searchResDownload; + // Test default container search + searchRes = await getResult(extension, "search", { + cookieStoreId: "firefox-default", + }); + equal( + searchRes.length, + 1, + "Default container results length for downloads.search" + ); + [searchResDownload] = searchRes; + equal( + mimeToCookies(searchResDownload.mime), + "cookie_normal=1", + "Default container cookies for downloads.search" + ); + // Test default container search with mismatched container + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_normal=1"), + cookieStoreId: "firefox-container-1", + }); + equal( + searchRes.length, + 0, + "Default container results length for downloads.search when container mismatched" + ); + + // Test private container search + searchRes = await getResult(extension, "search", { + cookieStoreId: "firefox-private", + }); + if (privateAllowed) { + equal( + searchRes.length, + 1, + "Private container results length for downloads.search" + ); + [searchResDownload] = searchRes; + equal( + mimeToCookies(searchResDownload.mime), + "cookie_private=1", + "Private container cookies for downloads.search" + ); + // Test private container search with mismatched container + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + cookieStoreId: "firefox-container-1", + }); + equal( + searchRes.length, + 0, + "Private container results length for downloads.search when container mismatched" + ); + } else { + equal( + searchRes.length, + 0, + "Private container results length for downloads.search when private disallowed" + ); + } + + // Test firefox-container-1 search + searchRes = await getResult(extension, "search", { + cookieStoreId: "firefox-container-1", + }); + equal( + searchRes.length, + 1, + "firefox-container-1 results length for downloads.search" + ); + [searchResDownload] = searchRes; + equal( + mimeToCookies(searchResDownload.mime), + "cookie_container=1", + "firefox-container-1 cookies for downloads.search" + ); + // Test firefox-container-1 search with mismatched container + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_container=1"), + cookieStoreId: "firefox-default", + }); + equal( + searchRes.length, + 0, + "firefox-container-1 container results length for downloads.search when container mismatched" + ); + + // Test default container erase with mismatched container + await getResult(extension, "erase", { + mime: cookiesToMime("cookie_normal=1"), + cookieStoreId: "firefox-container-1", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_normal=1"), + }); + equal( + searchRes.length, + 1, + "Default container results length for downloads.search after erase with mismatched container" + ); + + // Test private container erase with mismatched container + await getResult(extension, "erase", { + mime: cookiesToMime("cookie_private=1"), + cookieStoreId: "firefox-container-1", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + }); + equal( + searchRes.length, + privateAllowed ? 1 : 0, + "Private container results length for downloads.search after erase with mismatched container" + ); + + // Test firefox-container-1 erase with mismatched container + await getResult(extension, "erase", { + mime: cookiesToMime("cookie_container=1"), + cookieStoreId: "firefox-default", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_container=1"), + }); + equal( + searchRes.length, + 1, + "firefox-container-1 results length for downloads.search after erase with mismatched container" + ); + + // Test default container erase + await getResult(extension, "erase", { + cookieStoreId: "firefox-default", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_normal=1"), + }); + equal( + searchRes.length, + 0, + "Default container results length for downloads.search after erase" + ); + + // Test private container erase + await getResult(extension, "erase", { + cookieStoreId: "firefox-private", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + }); + // The following will also pass when incognito disabled + equal( + searchRes.length, + 0, + "Private container results length for downloads.search after erase" + ); + + // Test firefox-container-1 erase + await getResult(extension, "erase", { + cookieStoreId: "firefox-container-1", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_container=1"), + }); + equal( + searchRes.length, + 0, + "firefox-container-1 results length for downloads.search after erase" + ); +} + +async function populateDownloads(extension) { + await getResult(extension, "erase", {}); + await getResult(extension, "getDownload", { + url: DOWNLOAD_URL, + }); + await getResult(extension, "getDownload", { + url: DOWNLOAD_URL, + incognito: true, + }); + await getResult(extension, "getDownload", { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-container-1", + }); +} + +add_task(async function setup() { + const nsIFile = Ci.nsIFile; + const downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + await setUpCookies(); + registerCleanupFunction(() => { + Services.cookies.removeAll(); + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + downloadDir.remove(false); + }); +}); + +add_task(async function download_cookieStoreId() { + // Test extension with cookies permission and incognito enabled + let extension = createDownloadTestExtension(["cookies"], true); + await extension.startup(); + await runTests(extension, true, true); + + // Test extension with incognito enabled and no cookies permission + await populateDownloads(extension); + let noCookiesExtension = createDownloadTestExtension([], true); + await noCookiesExtension.startup(); + await runTests(noCookiesExtension, false, true); + await noCookiesExtension.unload(); + + // Test extension with incognito disabled and no cookies permission + await populateDownloads(extension); + let noCookiesAndPrivateExtension = createDownloadTestExtension([], false); + await noCookiesAndPrivateExtension.startup(); + await runTests(noCookiesAndPrivateExtension, false, false); + await noCookiesAndPrivateExtension.unload(); + + // Verify that incognito disabled test did not delete private download + let searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + }); + ok(searchRes.length, "Incognito disabled does not delete private download"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini index fac4dc9a4fd4..b3fae3f4627e 100644 --- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini @@ -79,6 +79,8 @@ skip-if = socketprocess_networking || os == "android" # Android: Bug 1680132 [test_ext_downloads.js] [test_ext_downloads_cookies.js] skip-if = os == "android" # downloads API needs to be implemented in GeckoView - bug 1538348 +[test_ext_downloads_cookieStoreId.js] +skip-if = os == "android" [test_ext_downloads_download.js] skip-if = appname == "thunderbird" || os == "android" || tsan # tsan: bug 1612707 [test_ext_downloads_misc.js]