summaryrefslogtreecommitdiffstats
path: root/toolkit/components/webextensions/ext-cookies.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/webextensions/ext-cookies.js')
-rw-r--r--toolkit/components/webextensions/ext-cookies.js484
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;
+});