diff --git a/.eslintrc.js b/.eslintrc.js index 1273c43..467a3a6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,6 @@ module.exports = { "parserOptions": { - "ecmaVersion": 8 + "ecmaVersion": 2018 }, "env": { "browser": true, @@ -18,7 +18,8 @@ module.exports = { "XPCOMUtils": true, "OS": true, "ADDON_UNINSTALL": true, - "ADDON_DISABLE": true + "ADDON_DISABLE": true, + "proxifiedContainers": true }, "plugins": [ "promise", diff --git a/src/css/popup.css b/src/css/popup.css index 4dfb8dc..ff7e083 100644 --- a/src/css/popup.css +++ b/src/css/popup.css @@ -586,7 +586,7 @@ manage things like container crud */ } .edit-container-panel fieldset:last-of-type { - margin-block-end: 0; + margin-block-start: 16px; } .edit-container-panel input[type="text"] { @@ -886,12 +886,11 @@ input { .site-isolation { inset-block-end: auto; - position: fixed; } .options-label { cursor: pointer; - padding-inline-start: 25px; + padding-inline-start: 4px; } .manage-assigned-sites-list { diff --git a/src/js/background/assignManager.js b/src/js/background/assignManager.js index 02aa274..41a787c 100644 --- a/src/js/background/assignManager.js +++ b/src/js/background/assignManager.js @@ -109,10 +109,10 @@ window.assignManager = { const siteConfigs = await this.area.get(); for(const urlKey of Object.keys(siteConfigs)) { if (urlKey.includes("siteContainerMap@@_")) { - // For some reason this is stored as string... lets check + // For some reason this is stored as string... lets check // them both as that - if (!!userContextId && - String(siteConfigs[urlKey].userContextId) + if (!!userContextId && + String(siteConfigs[urlKey].userContextId) !== String(userContextId)) { continue; } @@ -127,7 +127,7 @@ window.assignManager = { }, /* - * Looks for abandoned site assignments. If there is no identity with + * Looks for abandoned site assignments. If there is no identity with * the site assignment's userContextId (cookieStoreId), then the assignment * is removed. */ @@ -136,8 +136,8 @@ window.assignManager = { const macConfigs = await this.area.get(); for(const configKey of Object.keys(macConfigs)) { if (configKey.includes("siteContainerMap@@_")) { - const cookieStoreId = - "firefox-container-" + macConfigs[configKey].userContextId; + const cookieStoreId = + "firefox-container-" + macConfigs[configKey].userContextId; const match = identitiesList.find( localIdentity => localIdentity.cookieStoreId === cookieStoreId ); @@ -146,7 +146,7 @@ window.assignManager = { continue; } const updatedSiteAssignment = macConfigs[configKey]; - updatedSiteAssignment.identityMacAddonUUID = + updatedSiteAssignment.identityMacAddonUUID = await identityState.lookupMACaddonUUID(match.cookieStoreId); await this.set( configKey, @@ -164,7 +164,7 @@ window.assignManager = { _neverAsk(m) { const pageUrl = m.pageUrl; if (m.neverAsk === true) { - // If we have existing data and for some reason it hasn't been + // If we have existing data and for some reason it hasn't been // deleted etc lets update it this.storageArea.get(pageUrl).then((siteSettings) => { if (siteSettings) { @@ -184,6 +184,17 @@ window.assignManager = { return true; }, + async handleProxifiedRequest(requestInfo) { + // The following blocks potentially dangerous requests for privacy that come without a tabId + if(requestInfo.tabId === -1) + return Utils.getBogusProxy(); + + const tab = await browser.tabs.get(requestInfo.tabId); + const proxy = await proxifiedContainers.retrieveFromBackground(tab.cookieStoreId); + + return proxy; + }, + // Before a request is handled by the browser we decide if we should // route through a different container async onBeforeRequest(options) { @@ -212,7 +223,7 @@ window.assignManager = { const userContextId = this.getUserContextIdFromCookieStore(tab); // https://github.com/mozilla/multi-account-containers/issues/847 - // + // // Handle the case where this request's URL is not assigned to any particular // container. We must do the following check: // @@ -228,7 +239,7 @@ window.assignManager = { // - the current tab's container is locked and only allows "www.google.com" // - the incoming request is for "www.amazon.com", which has no specific container assignment // - in this case, we must re-open "www.amazon.com" in a new tab in the default container - const siteIsolatedReloadInDefault = + const siteIsolatedReloadInDefault = await this._maybeSiteIsolatedReloadInDefault(siteSettings, tab); if (!siteIsolatedReloadInDefault) { @@ -246,7 +257,7 @@ window.assignManager = { const openTabId = removeTab ? tab.openerTabId : tab.id; if (!this.canceledRequests[tab.id]) { - // we decided to cancel the request at this point, register + // we decided to cancel the request at this point, register // canceled request this.canceledRequests[tab.id] = { requestIds: { @@ -313,7 +324,7 @@ window.assignManager = { - As the history won't span from one container to another it seems most sane to not try and reopen a tab on history.back() - When users open a new tab themselves we want to make sure we - don't end up with three tabs as per: + don't end up with three tabs as per: https://github.com/mozilla/testpilot-containers/issues/421 If we are coming from an internal url that are used for the new tab page (NEW_TAB_PAGES), we can safely close as user is unlikely @@ -348,7 +359,7 @@ window.assignManager = { // I.e. it will be opened in that container anyway, so we don't need to check if the // current tab's container is locked or not. if (siteSettings) { - return false; + return false; } //tab is alredy reopening in the default container @@ -363,11 +374,14 @@ window.assignManager = { init() { browser.contextMenus.onClicked.addListener((info, tab) => { - info.bookmarkId ? - this._onClickedBookmark(info) : + info.bookmarkId ? + this._onClickedBookmark(info) : this._onClickedHandler(info, tab); }); + // Before anything happens we decide if the request should be proxified + browser.proxy.onRequest.addListener(this.handleProxifiedRequest, {urls: [""]}); + // Before a request is handled by the browser we decide if we should // route through a different container this.canceledRequests = {}; @@ -479,7 +493,7 @@ window.assignManager = { async _onClickedBookmark(info) { async function _getBookmarksFromInfo(info) { - const [bookmarkTreeNode] = + const [bookmarkTreeNode] = await browser.bookmarks.get(info.bookmarkId); if (bookmarkTreeNode.type === "folder") { return browser.bookmarks.getChildren(bookmarkTreeNode.id); @@ -489,9 +503,9 @@ window.assignManager = { const bookmarks = await _getBookmarksFromInfo(info); for (const bookmark of bookmarks) { - // Some checks on the urls from + // Some checks on the urls from // https://github.com/Rob--W/bookmark-container-tab/ thanks! - if ( !/^(javascript|place):/i.test(bookmark.url) && + if ( !/^(javascript|place):/i.test(bookmark.url) && bookmark.type !== "folder") { const openInReaderMode = bookmark.url.startsWith("about:reader"); if(openInReaderMode) { @@ -569,12 +583,12 @@ window.assignManager = { actionName = "removed from assigned sites list"; // remove site isolation if now empty - await this._maybeRemoveSiteIsolation(userContextId); + await this._maybeRemoveSiteIsolation(userContextId); } if (tabId) { const tab = await browser.tabs.get(tabId); - setTimeout(function(){ + setTimeout(function(){ browser.tabs.sendMessage(tabId, { text: `Successfully ${actionName}` }); @@ -677,17 +691,17 @@ window.assignManager = { reloadPageInDefaultContainer(url, index, active, openerTabId) { // To create a new tab in the default container, it is easiest just to omit the // cookieStoreId entirely. - // + // // Unfortunately, if you create a new tab WITHOUT a cookieStoreId but WITH an openerTabId, // then the new tab automatically inherits the opener tab's cookieStoreId. // I.e. it opens in the wrong container! - // + // // So we have to explicitly pass in a cookieStoreId when creating the tab, since we // are specifying the openerTabId. There doesn't seem to be any way // to look up the default container's cookieStoreId programatically, so sadly // we have to hardcode it here as "firefox-default". This is potentially // not cross-browser compatible. - // + // // Note that we could have just omitted BOTH cookieStoreId and openerTabId. But the // drawback then is that if the user later closes the newly-created tab, the browser // does not automatically return to the original opener tab. To get this desired behaviour, diff --git a/src/js/background/backgroundLogic.js b/src/js/background/backgroundLogic.js index 4c55306..e33ee6a 100644 --- a/src/js/background/backgroundLogic.js +++ b/src/js/background/backgroundLogic.js @@ -45,6 +45,10 @@ const backgroundLogic = { await browser.contextualIdentities.remove(this.cookieStoreId(userContextId)); } assignManager.deleteContainer(userContextId); + + // Now remove the identity->proxy association in proxifiedContainers also + proxifiedContainers.delete(this.cookieStoreId(userContextId)); + return {done: true, userContextId}; }, @@ -55,8 +59,17 @@ const backgroundLogic = { this.cookieStoreId(options.userContextId), options.params ); + + proxifiedContainers.set(this.cookieStoreId(options.userContextId), options.proxy); } else { donePromise = browser.contextualIdentities.create(options.params); + + // We cannot yet access the new cookieStoreId via this.cookieStoreId(...), so we take this from the resolved promise + donePromise.then((identity) => { + proxifiedContainers.set(identity.cookieStoreId, options.proxy); + }).catch(() => { + // Empty because this should never happen theoretically. + }); } await donePromise; }, @@ -183,7 +196,7 @@ const backgroundLogic = { index: -1 }); } else { - //As we get a blank tab here we will need to await the tabs creation + // As we get a blank tab here we will need to await the tabs creation newWindowObj = await browser.windows.create({ }); hiddenDefaultTabToClose = true; diff --git a/src/js/background/index.html b/src/js/background/index.html index da380ba..b29b062 100644 --- a/src/js/background/index.html +++ b/src/js/background/index.html @@ -13,6 +13,8 @@ "js/background/messageHandler.js", ] --> + + diff --git a/src/js/popup.js b/src/js/popup.js index c3d2c3f..ce91c91 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -307,6 +307,10 @@ const Logic = { return Utils.userContextId(identity.cookieStoreId); }, + cookieStoreId(userContextId) { + return `firefox-container-${userContextId}`; + }, + currentCookieStoreId() { const identity = Logic.currentIdentity(); return identity.cookieStoreId; @@ -1348,8 +1352,9 @@ Logic.registerPanel(P_CONTAINER_EDIT, { params: { name: document.getElementById("edit-container-panel-name-input").value || Logic.generateIdentityName(), icon: formValues.get("container-icon") || DEFAULT_ICON, - color: formValues.get("container-color") || DEFAULT_COLOR, - } + color: formValues.get("container-color") || DEFAULT_COLOR + }, + proxy: proxifiedContainers.parseProxy(document.getElementById("edit-container-panel-proxy").value) || Utils.DEFAULT_PROXY } }); await Logic.refreshIdentities(); @@ -1423,6 +1428,37 @@ Logic.registerPanel(P_CONTAINER_EDIT, { iconInput.checked = iconInput.value === identity.icon; }); + // Clear the proxy field before doing the retrieval requests below + document.querySelector("#edit-container-panel-proxy").value = ""; + + const edit_proxy_dom = function(result) { + const proxyInput = document.querySelector("#edit-container-panel-proxy"); + if (result.type === "direct" || typeof result.type === "undefined") { + proxyInput.value = ""; + return; + } + proxyInput.value = `${result.type}://${result.host}:${result.port}`; + }; + + proxifiedContainers.retrieve(identity.cookieStoreId).then((result) => { + edit_proxy_dom(result.proxy); + }, (error) => { + if(error.error === "uninitialized" || error.error === "doesnotexist") { + proxifiedContainers.set(identity.cookieStoreId, Utils.DEFAULT_PROXY, error.error === "uninitialized").then((result) => { + edit_proxy_dom(result); + }, (error) => { + proxifiedContainers.report_proxy_error(error, "popup.js: unexpected set(...) error"); + }).catch((error) => { + proxifiedContainers.report_proxy_error(error, "popup.js: unexpected set(...) exception"); + }); + } + else { + proxifiedContainers.report_proxy_error(error, "popup.js: unknown error"); + } + }).catch((err) => { + proxifiedContainers.report_proxy_error(err, "popup.js: unexpected retrieve error"); + }); + const deleteButton = document.getElementById("delete-container-button"); Utils.addEnterHandler(deleteButton, () => { Logic.showPanel(P_CONTAINER_DELETE, identity); diff --git a/src/js/proxified-containers.js b/src/js/proxified-containers.js new file mode 100644 index 0000000..4008df2 --- /dev/null +++ b/src/js/proxified-containers.js @@ -0,0 +1,147 @@ +// This object allows other scripts to access the list mapping containers to their proxies +proxifiedContainers = { + + // Slightly modified version of 'retrieve' which returns a direct proxy whenever an error is met. + retrieveFromBackground(cookieStoreId = null) { + return new Promise((resolve, reject) => { + proxifiedContainers.retrieve(cookieStoreId).then((success) => { + resolve(success.proxy); + }, function() { + resolve(Utils.DEFAULT_PROXY); + }).catch((error) => { + reject(error); + }); + }); + }, + + report_proxy_error(error, identifier = null) { + // Currently I print to console but this is inefficient + const relevant_id_str = identifier === null ? "" : ` call supplied with id: ${identifier.toString()}`; + browser.extension.getBackgroundPage().console.log(`proxifiedContainers error occured ${relevant_id_str}: ${JSON.stringify(error)}`); + }, + + // Resolves to a proxy object which can be used in the return of the listener required for browser.proxy.onRequest.addListener + retrieve(cookieStoreId = null) { + return new Promise((resolve, reject) => { + browser.storage.local.get("proxifiedContainersKey").then((results) => { + // Steps to test: + // 1. Is result empty? If so we must inform the caller to intialize proxifiedContainersStore with some initial info. + // 2. Is cookieStoreId null? This means the caller probably wants everything currently in the proxifiedContainersStore object store + // 3. If there doesn't exist an entry for the associated cookieStoreId, inform the caller of this + // 4. Normal operation - if the cookieStoreId exists in the map, we can simply resolve with the correct proxy value + + const results_array = results["proxifiedContainersKey"]; + + if (Object.getOwnPropertyNames(results).length === 0) { + reject({ + error: "uninitialized", + message: "" + }); + } else if (cookieStoreId === null) { + resolve(results_array); + } else { + const val = results_array.find(o => o.cookieStoreId === cookieStoreId); + + if (typeof val !== "object" || val === null) { + reject({ + error: "doesnotexist", + message: "" + }); + } else { + resolve(val); + } + } + + }, (error) => { + reject({ + error: "internal", + message: error + }); + }).catch((error) => { + proxifiedContainers.report_proxy_error(error, "proxified-containers.js: error 1"); + }); + }); + }, + + set(cookieStoreId, proxy, initialize = false) { + return new Promise((resolve, reject) => { + if (initialize === true) { + const proxifiedContainersStore = []; + proxifiedContainersStore.push({ + cookieStoreId: cookieStoreId, + proxy: proxy + }); + + browser.storage.local.set({ + proxifiedContainersKey: proxifiedContainersStore + }); + + resolve(proxy); + } + + // Assumes proxy is a properly formatted object + proxifiedContainers.retrieve().then((proxifiedContainersStore) => { + let index = proxifiedContainersStore.findIndex(i => i.cookieStoreId === cookieStoreId); + + if (index === -1) { + proxifiedContainersStore.push({ + cookieStoreId: cookieStoreId, + proxy: proxy + }); + index = proxifiedContainersStore.length - 1; + } else { + proxifiedContainersStore[index] = { + cookieStoreId: cookieStoreId, + proxy: proxy + }; + } + + browser.storage.local.set({ + proxifiedContainersKey: proxifiedContainersStore + }); + + resolve(proxifiedContainersStore[index]); + }, (errorObj) => { + reject(errorObj); + }).catch((error) => { + throw error; + }); + }); + }, + + //Parses a proxy description string of the format type://host[:port] or type://username:password@host[:port] (port is optional) + parseProxy(proxy_str) { + const proxyRegexp = /(?(https?)|(socks4?)):\/\/(\b(?\w+):(?\w+)@)?(?((?:\d{1,3}\.){3}\d{1,3}\b)|(\b([\w.-]+)(\.([\w.-]+))+))(:(?\d+))?/; + if (proxyRegexp.test(proxy_str) !== true) { + return false; + } + const matches = proxyRegexp.exec(proxy_str); + return matches.groups; + }, + + // Deletes the proxy information object for a specified cookieStoreId [useful for cleaning] + delete(cookieStoreId) { + return new Promise((resolve, reject) => { + // Assumes proxy is a properly formatted object + proxifiedContainers.retrieve().then((proxifiedContainersStore) => { + const index = proxifiedContainersStore.findIndex(i => i.cookieStoreId === cookieStoreId); + + if (index === -1) { + reject({error: "not-found", message: `Container '${cookieStoreId}' not found.`}); + } else { + proxifiedContainersStore.splice(index, 1); + } + + browser.storage.local.set({ + proxifiedContainersKey: proxifiedContainersStore + }); + + resolve(); + }, (errorObj) => { + reject(errorObj); + }).catch((error) => { + throw error; + }); + }); + } +}; diff --git a/src/js/utils.js b/src/js/utils.js index c639b37..0ea538f 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -1,3 +1,5 @@ +/*global getBogusProxy */ + const DEFAULT_FAVICON = "/img/blank-favicon.svg"; // TODO use export here instead of globals @@ -19,6 +21,40 @@ const Utils = { imageElement.addEventListener("load", loadListener); return imageElement; }, + + // See comment in PR #313 - so far the (hacky) method being used to block proxies is to produce a sufficiently long random address + getBogusProxy() { + const bogusFailover = 1; + const bogusType = "socks4"; + const bogusPort = 9999; + const bogusUsername = "foo"; + if(typeof window.Utils.pregeneratedString !== "undefined") + { + return {type:bogusType, host:`w.${window.Utils.pregeneratedString}.coo`, port:bogusPort, username:bogusUsername, failoverTimeout:bogusFailover}; + } + else + { + // Initialize Utils.pregeneratedString + window.Utils.pregeneratedString = ""; + + // We generate a cryptographically random string (of length specified in bogusLength), but we only do so once - thus negating any time delay caused + const bogusLength = 8; + const array = new Uint8Array(bogusLength); + window.crypto.getRandomValues(array); + for(let i = 0; i < bogusLength; i++) + { + const s = array[i].toString(16); + if(s.length === 1) + window.Utils.pregeneratedString += `0${s}`; + else + window.Utils.pregeneratedString += s; + } + + // The only issue with this approach is that if (for some unknown reason) pregeneratedString is not saved, it will result in an infinite loop - but better than a privacy leak! + return getBogusProxy(); + } + }, + /** * Escapes any occurances of &, ", <, > or / with XML entities. * @@ -62,7 +98,7 @@ const Utils = { } return false; }, - + addEnterHandler(element, handler) { element.addEventListener("click", (e) => { handler(e); @@ -82,7 +118,7 @@ const Utils = { handler(e); } }); - }, + }, userContextId(cookieStoreId = "") { const userContextId = cookieStoreId.replace("firefox-container-", ""); @@ -102,10 +138,10 @@ const Utils = { async reloadInContainer(url, currentUserContextId, newUserContextId, tabIndex, active) { return await browser.runtime.sendMessage({ method: "reloadInContainer", - url, - currentUserContextId, - newUserContextId, - tabIndex, + url, + currentUserContextId, + newUserContextId, + tabIndex, active }); }, @@ -116,22 +152,30 @@ const Utils = { if (currentTab.cookieStoreId !== identity.cookieStoreId) { return await browser.runtime.sendMessage({ method: "assignAndReloadInContainer", - url: currentTab.url, - currentUserContextId: false, - newUserContextId: assignedUserContextId, - tabIndex: currentTab.index +1, + url: currentTab.url, + currentUserContextId: false, + newUserContextId: assignedUserContextId, + tabIndex: currentTab.index +1, active:currentTab.active }); } await Utils.setOrRemoveAssignment( - currentTab.id, - currentTab.url, - assignedUserContextId, + currentTab.id, + currentTab.url, + assignedUserContextId, false ); } - }; +window.Utils = Utils; -window.Utils = Utils; \ No newline at end of file +// The following creates a fake (but convincing) constant Utils.DEFAULT_PROXY +Object.defineProperty(window.Utils, "DEFAULT_PROXY", { + value: Object.freeze({type: "direct"}), + writable: false, + enumerable: true, + + // Setting configurable to false avoids deletion of Utils.DEFAULT_PROXY + configurable: false +}); diff --git a/src/manifest.json b/src/manifest.json index 2fc68fd..08f43b8 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -28,7 +28,8 @@ "unlimitedStorage", "tabs", "webRequestBlocking", - "webRequest" + "webRequest", + "proxy" ], "optional_permissions": [ "bookmarks" diff --git a/src/popup.html b/src/popup.html index 50e762b..f50a2ce 100644 --- a/src/popup.html +++ b/src/popup.html @@ -229,7 +229,7 @@ www.mozillllllllllllllllllllllllllllllllllllla.org - + @@ -277,6 +277,10 @@
+
+ Proxy (Optional) + +
@@ -332,6 +336,7 @@
+ diff --git a/test/.eslintrc.js b/test/.eslintrc.js index 6d733f0..6c7a326 100644 --- a/test/.eslintrc.js +++ b/test/.eslintrc.js @@ -1,12 +1,12 @@ module.exports = { - env: { - "node": true, - "mocha": true - }, - "parserOptions": { - "ecmaVersion": 2018 - }, - "rules": { - "no-restricted-globals": ["error", "browser"] - } -} + env: { + "node": true, + "mocha": true + }, + "parserOptions": { + "ecmaVersion": 2018 + }, + "rules": { + "no-restricted-globals": ["error", "browser"] + } +};