diff options
Diffstat (limited to 'toolkit/components/webextensions/ext-cookies.js')
-rw-r--r-- | toolkit/components/webextensions/ext-cookies.js | 484 |
1 files changed, 484 insertions, 0 deletions
diff --git a/toolkit/components/webextensions/ext-cookies.js b/toolkit/components/webextensions/ext-cookies.js new file mode 100644 index 000000000..d0a703421 --- /dev/null +++ b/toolkit/components/webextensions/ext-cookies.js @@ -0,0 +1,484 @@ +"use strict"; + +const {interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ContextualIdentityService", + "resource://gre/modules/ContextualIdentityService.jsm"); + +var { + EventManager, +} = ExtensionUtils; + +var DEFAULT_STORE = "firefox-default"; +var PRIVATE_STORE = "firefox-private"; +var CONTAINER_STORE = "firefox-container-"; + +global.getCookieStoreIdForTab = function(data, tab) { + if (data.incognito) { + return PRIVATE_STORE; + } + + if (tab.userContextId) { + return CONTAINER_STORE + tab.userContextId; + } + + return DEFAULT_STORE; +}; + +global.isPrivateCookieStoreId = function(storeId) { + return storeId == PRIVATE_STORE; +}; + +global.isDefaultCookieStoreId = function(storeId) { + return storeId == DEFAULT_STORE; +}; + +global.isContainerCookieStoreId = function(storeId) { + return storeId !== null && storeId.startsWith(CONTAINER_STORE); +}; + +global.getContainerForCookieStoreId = function(storeId) { + if (!global.isContainerCookieStoreId(storeId)) { + return null; + } + + let containerId = storeId.substring(CONTAINER_STORE.length); + if (ContextualIdentityService.getIdentityFromId(containerId)) { + return parseInt(containerId, 10); + } + + return null; +}; + +global.isValidCookieStoreId = function(storeId) { + return global.isDefaultCookieStoreId(storeId) || + global.isPrivateCookieStoreId(storeId) || + global.isContainerCookieStoreId(storeId); +}; + +function convert({cookie, isPrivate}) { + let result = { + name: cookie.name, + value: cookie.value, + domain: cookie.host, + hostOnly: !cookie.isDomain, + path: cookie.path, + secure: cookie.isSecure, + httpOnly: cookie.isHttpOnly, + session: cookie.isSession, + }; + + if (!cookie.isSession) { + result.expirationDate = cookie.expiry; + } + + if (cookie.originAttributes.userContextId) { + result.storeId = CONTAINER_STORE + cookie.originAttributes.userContextId; + } else if (cookie.originAttributes.privateBrowsingId || isPrivate) { + result.storeId = PRIVATE_STORE; + } else { + result.storeId = DEFAULT_STORE; + } + + return result; +} + +function isSubdomain(otherDomain, baseDomain) { + return otherDomain == baseDomain || otherDomain.endsWith("." + baseDomain); +} + +// Checks that the given extension has permission to set the given cookie for +// the given URI. +function checkSetCookiePermissions(extension, uri, cookie) { + // Permission checks: + // + // - If the extension does not have permissions for the specified + // URL, it cannot set cookies for it. + // + // - If the specified URL could not set the given cookie, neither can + // the extension. + // + // Ideally, we would just have the cookie service make the latter + // determination, but that turns out to be quite complicated. At the + // moment, it requires constructing a cookie string and creating a + // dummy channel, both of which can be problematic. It also triggers + // a whole set of additional permission and preference checks, which + // may or may not be desirable. + // + // So instead, we do a similar set of checks here. Exactly what + // cookies a given URL should be able to set is not well-documented, + // and is not standardized in any standard that anyone actually + // follows. So instead, we follow the rules used by the cookie + // service. + // + // See source/netwerk/cookie/nsCookieService.cpp, in particular + // CheckDomain() and SetCookieInternal(). + + if (uri.scheme != "http" && uri.scheme != "https") { + return false; + } + + if (!extension.whiteListedHosts.matchesIgnoringPath(uri)) { + return false; + } + + if (!cookie.host) { + // If no explicit host is specified, this becomes a host-only cookie. + cookie.host = uri.host; + return true; + } + + // A leading "." is not expected, but is tolerated if it's not the only + // character in the host. If there is one, start by stripping it off. We'll + // add a new one on success. + if (cookie.host.length > 1) { + cookie.host = cookie.host.replace(/^\./, ""); + } + cookie.host = cookie.host.toLowerCase(); + + if (cookie.host != uri.host) { + // Not an exact match, so check for a valid subdomain. + let baseDomain; + try { + baseDomain = Services.eTLD.getBaseDomain(uri); + } catch (e) { + if (e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS || + e.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS) { + // The cookie service uses these to determine whether the domain + // requires an exact match. We already know we don't have an exact + // match, so return false. In all other cases, re-raise the error. + return false; + } + throw e; + } + + // The cookie domain must be a subdomain of the base domain. This prevents + // us from setting cookies for domains like ".co.uk". + // The domain of the requesting URL must likewise be a subdomain of the + // cookie domain. This prevents us from setting cookies for entirely + // unrelated domains. + if (!isSubdomain(cookie.host, baseDomain) || + !isSubdomain(uri.host, cookie.host)) { + return false; + } + + // RFC2109 suggests that we may only add cookies for sub-domains 1-level + // below us, but enforcing that would break the web, so we don't. + } + + // An explicit domain was passed, so add a leading "." to make this a + // domain cookie. + cookie.host = "." + cookie.host; + + // We don't do any significant checking of path permissions. RFC2109 + // suggests we only allow sites to add cookies for sub-paths, similar to + // same origin policy enforcement, but no-one implements this. + + return true; +} + +function* query(detailsIn, props, context) { + // Different callers want to filter on different properties. |props| + // tells us which ones they're interested in. + let details = {}; + props.forEach(property => { + if (detailsIn[property] !== null) { + details[property] = detailsIn[property]; + } + }); + + if ("domain" in details) { + details.domain = details.domain.toLowerCase().replace(/^\./, ""); + } + + let userContextId = 0; + let isPrivate = context.incognito; + if (details.storeId) { + if (!global.isValidCookieStoreId(details.storeId)) { + return; + } + + if (global.isDefaultCookieStoreId(details.storeId)) { + isPrivate = false; + } else if (global.isPrivateCookieStoreId(details.storeId)) { + isPrivate = true; + } else if (global.isContainerCookieStoreId(details.storeId)) { + isPrivate = false; + userContextId = global.getContainerForCookieStoreId(details.storeId); + if (!userContextId) { + return; + } + } + } + + let storeId = DEFAULT_STORE; + if (isPrivate) { + storeId = PRIVATE_STORE; + } else if ("storeId" in details) { + storeId = details.storeId; + } + + // We can use getCookiesFromHost for faster searching. + let enumerator; + let uri; + if ("url" in details) { + try { + uri = NetUtil.newURI(details.url).QueryInterface(Ci.nsIURL); + Services.cookies.usePrivateMode(isPrivate, () => { + enumerator = Services.cookies.getCookiesFromHost(uri.host, {userContextId}); + }); + } catch (ex) { + // This often happens for about: URLs + return; + } + } else if ("domain" in details) { + Services.cookies.usePrivateMode(isPrivate, () => { + enumerator = Services.cookies.getCookiesFromHost(details.domain, {userContextId}); + }); + } else { + Services.cookies.usePrivateMode(isPrivate, () => { + enumerator = Services.cookies.enumerator; + }); + } + + // Based on nsCookieService::GetCookieStringInternal + function matches(cookie) { + function domainMatches(host) { + return cookie.rawHost == host || (cookie.isDomain && host.endsWith(cookie.host)); + } + + function pathMatches(path) { + let cookiePath = cookie.path.replace(/\/$/, ""); + + if (!path.startsWith(cookiePath)) { + return false; + } + + // path == cookiePath, but without the redundant string compare. + if (path.length == cookiePath.length) { + return true; + } + + // URL path is a substring of the cookie path, so it matches if, and + // only if, the next character is a path delimiter. + let pathDelimiters = ["/", "?", "#", ";"]; + return pathDelimiters.includes(path[cookiePath.length]); + } + + // "Restricts the retrieved cookies to those that would match the given URL." + if (uri) { + if (!domainMatches(uri.host)) { + return false; + } + + if (cookie.isSecure && uri.scheme != "https") { + return false; + } + + if (!pathMatches(uri.path)) { + return false; + } + } + + if ("name" in details && details.name != cookie.name) { + return false; + } + + if (userContextId != cookie.originAttributes.userContextId) { + return false; + } + + // "Restricts the retrieved cookies to those whose domains match or are subdomains of this one." + if ("domain" in details && !isSubdomain(cookie.rawHost, details.domain)) { + return false; + } + + // "Restricts the retrieved cookies to those whose path exactly matches this string."" + if ("path" in details && details.path != cookie.path) { + return false; + } + + if ("secure" in details && details.secure != cookie.isSecure) { + return false; + } + + if ("session" in details && details.session != cookie.isSession) { + return false; + } + + // Check that the extension has permissions for this host. + if (!context.extension.whiteListedHosts.matchesCookie(cookie)) { + return false; + } + + return true; + } + + while (enumerator.hasMoreElements()) { + let cookie = enumerator.getNext().QueryInterface(Ci.nsICookie2); + if (matches(cookie)) { + yield {cookie, isPrivate, storeId}; + } + } +} + +extensions.registerSchemaAPI("cookies", "addon_parent", context => { + let {extension} = context; + let self = { + cookies: { + get: function(details) { + // FIXME: We don't sort by length of path and creation time. + for (let cookie of query(details, ["url", "name", "storeId"], context)) { + return Promise.resolve(convert(cookie)); + } + + // Found no match. + return Promise.resolve(null); + }, + + getAll: function(details) { + let allowed = ["url", "name", "domain", "path", "secure", "session", "storeId"]; + let result = Array.from(query(details, allowed, context), convert); + + return Promise.resolve(result); + }, + + set: function(details) { + let uri = NetUtil.newURI(details.url).QueryInterface(Ci.nsIURL); + + let path; + if (details.path !== null) { + path = details.path; + } else { + // This interface essentially emulates the behavior of the + // Set-Cookie header. In the case of an omitted path, the cookie + // service uses the directory path of the requesting URL, ignoring + // any filename or query parameters. + path = uri.directory; + } + + let name = details.name !== null ? details.name : ""; + let value = details.value !== null ? details.value : ""; + let secure = details.secure !== null ? details.secure : false; + let httpOnly = details.httpOnly !== null ? details.httpOnly : false; + let isSession = details.expirationDate === null; + let expiry = isSession ? Number.MAX_SAFE_INTEGER : details.expirationDate; + let isPrivate = context.incognito; + let userContextId = 0; + if (global.isDefaultCookieStoreId(details.storeId)) { + isPrivate = false; + } else if (global.isPrivateCookieStoreId(details.storeId)) { + isPrivate = true; + } else if (global.isContainerCookieStoreId(details.storeId)) { + let containerId = global.getContainerForCookieStoreId(details.storeId); + if (containerId === null) { + return Promise.reject({message: `Illegal storeId: ${details.storeId}`}); + } + isPrivate = false; + userContextId = containerId; + } else if (details.storeId !== null) { + return Promise.reject({message: "Unknown storeId"}); + } + + let cookieAttrs = {host: details.domain, path: path, isSecure: secure}; + if (!checkSetCookiePermissions(extension, uri, cookieAttrs)) { + return Promise.reject({message: `Permission denied to set cookie ${JSON.stringify(details)}`}); + } + + // The permission check may have modified the domain, so use + // the new value instead. + Services.cookies.usePrivateMode(isPrivate, () => { + Services.cookies.add(cookieAttrs.host, path, name, value, + secure, httpOnly, isSession, expiry, {userContextId}); + }); + + return self.cookies.get(details); + }, + + remove: function(details) { + for (let {cookie, isPrivate, storeId} of query(details, ["url", "name", "storeId"], context)) { + Services.cookies.usePrivateMode(isPrivate, () => { + Services.cookies.remove(cookie.host, cookie.name, cookie.path, false, cookie.originAttributes); + }); + + // Todo: could there be multiple per subdomain? + return Promise.resolve({ + url: details.url, + name: details.name, + storeId, + }); + } + + return Promise.resolve(null); + }, + + getAllCookieStores: function() { + let data = {}; + for (let window of WindowListManager.browserWindows()) { + let tabs = TabManager.for(extension).getTabs(window); + for (let tab of tabs) { + if (!(tab.cookieStoreId in data)) { + data[tab.cookieStoreId] = []; + } + data[tab.cookieStoreId].push(tab); + } + } + + let result = []; + for (let key in data) { + result.push({id: key, tabIds: data[key], incognito: key == PRIVATE_STORE}); + } + return Promise.resolve(result); + }, + + onChanged: new EventManager(context, "cookies.onChanged", fire => { + let observer = (subject, topic, data) => { + let notify = (removed, cookie, cause) => { + cookie.QueryInterface(Ci.nsICookie2); + + if (extension.whiteListedHosts.matchesCookie(cookie)) { + fire({removed, cookie: convert({cookie, isPrivate: topic == "private-cookie-changed"}), cause}); + } + }; + + // We do our best effort here to map the incompatible states. + switch (data) { + case "deleted": + notify(true, subject, "explicit"); + break; + case "added": + notify(false, subject, "explicit"); + break; + case "changed": + notify(true, subject, "overwrite"); + notify(false, subject, "explicit"); + break; + case "batch-deleted": + subject.QueryInterface(Ci.nsIArray); + for (let i = 0; i < subject.length; i++) { + let cookie = subject.queryElementAt(i, Ci.nsICookie2); + if (!cookie.isSession && cookie.expiry * 1000 <= Date.now()) { + notify(true, cookie, "expired"); + } else { + notify(true, cookie, "evicted"); + } + } + break; + } + }; + + Services.obs.addObserver(observer, "cookie-changed", false); + Services.obs.addObserver(observer, "private-cookie-changed", false); + return () => { + Services.obs.removeObserver(observer, "cookie-changed"); + Services.obs.removeObserver(observer, "private-cookie-changed"); + }; + }).api(), + }, + }; + + return self; +}); |