diff options
Diffstat (limited to 'toolkit/components/extensions/parent/ext-cookies.js')
-rw-r--r-- | toolkit/components/extensions/parent/ext-cookies.js | 696 |
1 files changed, 696 insertions, 0 deletions
diff --git a/toolkit/components/extensions/parent/ext-cookies.js b/toolkit/components/extensions/parent/ext-cookies.js new file mode 100644 index 0000000000..9308a56cfd --- /dev/null +++ b/toolkit/components/extensions/parent/ext-cookies.js @@ -0,0 +1,696 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals DEFAULT_STORE, PRIVATE_STORE */ + +var { ExtensionError } = ExtensionUtils; + +const SAME_SITE_STATUSES = [ + "no_restriction", // Index 0 = Ci.nsICookie.SAMESITE_NONE + "lax", // Index 1 = Ci.nsICookie.SAMESITE_LAX + "strict", // Index 2 = Ci.nsICookie.SAMESITE_STRICT +]; + +const isIPv4 = host => { + let match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(host); + + if (match) { + return match[1] < 256 && match[2] < 256 && match[3] < 256 && match[4] < 256; + } + return false; +}; +const isIPv6 = host => host.includes(":"); +const addBracketIfIPv6 = host => + isIPv6(host) && !host.startsWith("[") ? `[${host}]` : host; +const dropBracketIfIPv6 = host => + isIPv6(host) && host.startsWith("[") && host.endsWith("]") + ? host.slice(1, -1) + : host; + +// Converts the partitionKey format of the extension API (i.e. PartitionKey) to +// a valid format for the "partitionKey" member of OriginAttributes. +function fromExtPartitionKey(extPartitionKey) { + if (!extPartitionKey) { + // Unpartitioned by default. + return ""; + } + const { topLevelSite } = extPartitionKey; + // TODO: Expand API to force the generation of a partitionKey that differs + // from the default that's specified by privacy.dynamic_firstparty.use_site. + if (topLevelSite) { + // If topLevelSite is set and a non-empty string (a site in a URL format). + try { + return ChromeUtils.getPartitionKeyFromURL(topLevelSite); + } catch (e) { + throw new ExtensionError("Invalid value for 'partitionKey' attribute"); + } + } + // Unpartitioned. + return ""; +} +// Converts an internal partitionKey (format used by OriginAttributes) to the +// string value as exposed through the extension API. +function toExtPartitionKey(partitionKey) { + if (!partitionKey) { + // Canonical representation of an empty partitionKey is null. + // In theory {topLevelSite: ""} also works, but alas. + return null; + } + // Parse partitionKey in order to generate the desired return type (URL). + // OriginAttributes::ParsePartitionKey cannot be used because it assumes that + // the input matches the format of the privacy.dynamic_firstparty.use_site + // pref, which is not necessarily the case for cookies before the pref flip. + if (!partitionKey.startsWith("(")) { + // A partitionKey generated with privacy.dynamic_firstparty.use_site=false. + return { topLevelSite: `https://${partitionKey}` }; + } + // partitionKey starts with "(" and ends with ")". + let [scheme, domain, port] = partitionKey.slice(1, -1).split(","); + let topLevelSite = `${scheme}://${domain}`; + if (port) { + topLevelSite += `:${port}`; + } + return { topLevelSite }; +} + +const convertCookie = ({ cookie, isPrivate }) => { + let result = { + name: cookie.name, + value: cookie.value, + domain: addBracketIfIPv6(cookie.host), + hostOnly: !cookie.isDomain, + path: cookie.path, + secure: cookie.isSecure, + httpOnly: cookie.isHttpOnly, + sameSite: SAME_SITE_STATUSES[cookie.sameSite], + session: cookie.isSession, + firstPartyDomain: cookie.originAttributes.firstPartyDomain || "", + partitionKey: toExtPartitionKey(cookie.originAttributes.partitionKey), + }; + + if (!cookie.isSession) { + result.expirationDate = cookie.expiry; + } + + if (cookie.originAttributes.userContextId) { + result.storeId = getCookieStoreIdForContainer( + cookie.originAttributes.userContextId + ); + } else if (cookie.originAttributes.privateBrowsingId || isPrivate) { + result.storeId = PRIVATE_STORE; + } else { + result.storeId = DEFAULT_STORE; + } + + return result; +}; + +const 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. +const 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/CookieService.cpp, in particular + // CheckDomain() and SetCookieInternal(). + + if (uri.scheme != "http" && uri.scheme != "https") { + return false; + } + + if (!extension.allowedOrigins.matches(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(); + cookie.host = dropBracketIfIPv6(cookie.host); + + 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. + } + + // If the host is an IP address, avoid adding a leading ".". + // An IP address is not a domain name, and only supports host-only cookies. + if (isIPv6(cookie.host) || isIPv4(cookie.host)) { + return true; + } + + // 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; +}; + +/** + * Converts the details received from the cookies API to the OriginAttributes + * format, using default values when needed (firstPartyDomain/partitionKey). + * + * If allowPattern is true, an OriginAttributesPattern may be returned instead. + * + * @param {object} details + * The details received from the extension. + * @param {BaseContext} context + * @param {boolean} allowPattern + * Whether to potentially return an OriginAttributesPattern instead of + * OriginAttributes. The get/set/remove cookie methods operate on exact + * OriginAttributes, the getAll method allows a partial pattern and may + * potentially match cookies with distinct origin attributes. + * @returns {object} An object with the following properties: + * - originAttributes {OriginAttributes|OriginAttributesPattern} + * - isPattern {boolean} Whether originAttributes is a pattern. + * - isPrivate {boolean} Whether the cookie belongs to private browsing mode. + * - storeId {string} The storeId of the cookie. + */ +const oaFromDetails = (details, context, allowPattern) => { + // Default values, may be filled in based on details. + let originAttributes = { + userContextId: 0, + privateBrowsingId: 0, + // The following two keys may be deleted if allowPattern=true + firstPartyDomain: details.firstPartyDomain ?? "", + partitionKey: fromExtPartitionKey(details.partitionKey), + }; + + let isPrivate = context.incognito; + let storeId = isPrivate ? PRIVATE_STORE : DEFAULT_STORE; + if (details.storeId) { + storeId = details.storeId; + if (isDefaultCookieStoreId(storeId)) { + isPrivate = false; + } else if (isPrivateCookieStoreId(storeId)) { + isPrivate = true; + } else { + isPrivate = false; + let userContextId = getContainerForCookieStoreId(storeId); + if (!userContextId) { + throw new ExtensionError(`Invalid cookie store id: "${storeId}"`); + } + originAttributes.userContextId = userContextId; + } + } + + if (isPrivate) { + originAttributes.privateBrowsingId = 1; + if (!context.privateBrowsingAllowed) { + throw new ExtensionError( + "Extension disallowed access to the private cookies storeId." + ); + } + } + + // If any of the originAttributes's keys are deleted, this becomes true. + let isPattern = false; + if (allowPattern) { + // firstPartyDomain is unset / void / string. + // If unset, then we default to non-FPI cookies (or if FPI is enabled, + // an error is thrown by validateFirstPartyDomain). We are able to detect + // whether the property is set due to "omit-key-if-missing" in cookies.json. + // If set to a string, we keep the filter. + // If set to void (undefined / null), we drop the FPI filter: + if ("firstPartyDomain" in details && details.firstPartyDomain == null) { + delete originAttributes.firstPartyDomain; + isPattern = true; + } + + // partitionKey is an object or null. + // null implies the default (unpartitioned cookies). + // An object is a filter for partitionKey; currently we require topLevelSite + // to be set to determine the exact partitionKey. Without it, we drop the + // dFPI filter: + if (details.partitionKey && details.partitionKey.topLevelSite == null) { + delete originAttributes.partitionKey; + isPattern = true; + } + } + return { originAttributes, isPattern, isPrivate, storeId }; +}; + +/** + * Query the cookie store for matching cookies. + * + * @param {object} detailsIn + * @param {Array} props Properties the extension is interested in matching against. + * The firstPartyDomain / partitionKey / storeId + * props are always accounted for. + * @param {BaseContext} context The context making the query. + * @param {boolean} allowPattern Whether to allow the query to match distinct + * origin attributes instead of falling back to + * default values. See the oaFromDetails method. + */ +const query = function* (detailsIn, props, context, allowPattern) { + let details = {}; + props.forEach(property => { + if (detailsIn[property] !== null) { + details[property] = detailsIn[property]; + } + }); + + let parsedOA; + try { + parsedOA = oaFromDetails(detailsIn, context, allowPattern); + } catch (e) { + if (e.message.startsWith("Invalid cookie store id")) { + // For backwards-compatibility with previous versions of Firefox, fail + // silently (by not returning any results) instead of throwing an error. + return; + } + throw e; + } + let { originAttributes, isPattern, isPrivate, storeId } = parsedOA; + + if ("domain" in details) { + details.domain = details.domain.toLowerCase().replace(/^\./, ""); + details.domain = dropBracketIfIPv6(details.domain); + } + + // We can use getCookiesFromHost for faster searching. + let cookies; + let host; + let url; + if ("url" in details) { + try { + url = new URL(details.url); + host = dropBracketIfIPv6(url.hostname); + } catch (ex) { + // This often happens for about: URLs + return; + } + } else if ("domain" in details) { + host = details.domain; + } + + if (host && !isPattern) { + // getCookiesFromHost is more efficient than getCookiesWithOriginAttributes + // if the host and all origin attributes are known. + cookies = Services.cookies.getCookiesFromHost(host, originAttributes); + } else { + cookies = Services.cookies.getCookiesWithOriginAttributes( + JSON.stringify(originAttributes), + host + ); + } + + // Based on CookieService::GetCookieStringFromHttp + 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. + return path[cookiePath.length] === "/"; + } + + // "Restricts the retrieved cookies to those that would match the given URL." + if (url) { + if (!domainMatches(host)) { + return false; + } + + if (cookie.isSecure && url.protocol != "https:") { + return false; + } + + if (!pathMatches(url.pathname)) { + return false; + } + } + + if ("name" in details && details.name != cookie.name) { + 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.allowedOrigins.matchesCookie(cookie)) { + return false; + } + + return true; + } + + for (const cookie of cookies) { + if (matches(cookie)) { + yield { cookie, isPrivate, storeId }; + } + } +}; + +const validateFirstPartyDomain = details => { + if (details.firstPartyDomain != null) { + return; + } + if (Services.prefs.getBoolPref("privacy.firstparty.isolate")) { + throw new ExtensionError( + "First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set." + ); + } +}; + +this.cookies = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onChanged({ fire }) { + let observer = (subject, topic) => { + let notify = (removed, cookie, cause) => { + cookie.QueryInterface(Ci.nsICookie); + + if (this.extension.allowedOrigins.matchesCookie(cookie)) { + fire.async({ + removed, + cookie: convertCookie({ + cookie, + isPrivate: topic == "private-cookie-changed", + }), + cause, + }); + } + }; + + let notification = subject.QueryInterface(Ci.nsICookieNotification); + let { cookie } = notification; + + let { + COOKIE_DELETED, + COOKIE_ADDED, + COOKIE_CHANGED, + COOKIES_BATCH_DELETED, + } = Ci.nsICookieNotification; + + // We do our best effort here to map the incompatible states. + switch (notification.action) { + case COOKIE_DELETED: + notify(true, cookie, "explicit"); + break; + case COOKIE_ADDED: + notify(false, cookie, "explicit"); + break; + case COOKIE_CHANGED: + notify(true, cookie, "overwrite"); + notify(false, cookie, "explicit"); + break; + case COOKIES_BATCH_DELETED: + let cookieArray = notification.batchDeletedCookies.QueryInterface( + Ci.nsIArray + ); + for (let i = 0; i < cookieArray.length; i++) { + let cookie = cookieArray.queryElementAt(i, Ci.nsICookie); + if (!cookie.isSession && cookie.expiry * 1000 <= Date.now()) { + notify(true, cookie, "expired"); + } else { + notify(true, cookie, "evicted"); + } + } + break; + } + }; + + const { privateBrowsingAllowed } = this.extension; + Services.obs.addObserver(observer, "cookie-changed"); + if (privateBrowsingAllowed) { + Services.obs.addObserver(observer, "private-cookie-changed"); + } + return { + unregister() { + Services.obs.removeObserver(observer, "cookie-changed"); + if (privateBrowsingAllowed) { + Services.obs.removeObserver(observer, "private-cookie-changed"); + } + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + getAPI(context) { + let { extension } = context; + let self = { + cookies: { + get: function (details) { + validateFirstPartyDomain(details); + + // TODO bug 1818968: We don't sort by length of path and creation time. + let allowed = ["url", "name"]; + for (let cookie of query(details, allowed, context)) { + return Promise.resolve(convertCookie(cookie)); + } + + // Found no match. + return Promise.resolve(null); + }, + + getAll: function (details) { + if (!("firstPartyDomain" in details)) { + // Check and throw an error if firstPartyDomain is required. + validateFirstPartyDomain(details); + } + + let allowed = ["url", "name", "domain", "path", "secure", "session"]; + let result = Array.from( + query(details, allowed, context, /* allowPattern = */ true), + convertCookie + ); + + return Promise.resolve(result); + }, + + set: function (details) { + validateFirstPartyDomain(details); + if (details.firstPartyDomain && details.partitionKey) { + // FPI and dFPI are mutually exclusive, so it does not make sense + // to accept non-empty (i.e. non-default) values for both. + throw new ExtensionError( + "Partitioned cookies cannot have a 'firstPartyDomain' attribute." + ); + } + + let uri = Services.io.newURI(details.url); + + 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.QueryInterface(Ci.nsIURL).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 { originAttributes } = oaFromDetails(details, context); + + 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 + )}`, + }); + } + + let sameSite = SAME_SITE_STATUSES.indexOf(details.sameSite); + + let schemeType = Ci.nsICookie.SCHEME_UNSET; + if (uri.scheme === "https") { + schemeType = Ci.nsICookie.SCHEME_HTTPS; + } else if (uri.scheme === "http") { + schemeType = Ci.nsICookie.SCHEME_HTTP; + } else if (uri.scheme === "file") { + schemeType = Ci.nsICookie.SCHEME_FILE; + } + + // The permission check may have modified the domain, so use + // the new value instead. + Services.cookies.add( + cookieAttrs.host, + path, + name, + value, + secure, + httpOnly, + isSession, + expiry, + originAttributes, + sameSite, + schemeType + ); + + return self.cookies.get(details); + }, + + remove: function (details) { + validateFirstPartyDomain(details); + + let allowed = ["url", "name"]; + for (let { cookie, storeId } of query(details, allowed, context)) { + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes + ); + + // TODO Bug 1387957: could there be multiple per subdomain? + return Promise.resolve({ + url: details.url, + name: details.name, + storeId, + firstPartyDomain: cookie.originAttributes.firstPartyDomain, + partitionKey: toExtPartitionKey( + cookie.originAttributes.partitionKey + ), + }); + } + + return Promise.resolve(null); + }, + + getAllCookieStores: function () { + let data = {}; + for (let tab of extension.tabManager.query()) { + if (!(tab.cookieStoreId in data)) { + data[tab.cookieStoreId] = []; + } + data[tab.cookieStoreId].push(tab.id); + } + + 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, + module: "cookies", + event: "onChanged", + extensionApi: this, + }).api(), + }, + }; + + return self; + } +}; |