/* 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"; const { BaseStorageActor, DEFAULT_VALUE, SEPARATOR_GUID, } = require("resource://devtools/server/actors/resources/storage/index.js"); const { LongStringActor, } = require("resource://devtools/server/actors/string.js"); // "Lax", "Strict" and "None" are special values of the SameSite property // that should not be translated. const COOKIE_SAMESITE = { LAX: "Lax", STRICT: "Strict", NONE: "None", }; // MAX_COOKIE_EXPIRY should be 2^63-1, but JavaScript can't handle that // precision. const MAX_COOKIE_EXPIRY = Math.pow(2, 62); /** * General helpers */ function trimHttpHttpsPort(url) { const match = url.match(/(.+):\d+$/); if (match) { url = match[1]; } if (url.startsWith("http://")) { return url.substr(7); } if (url.startsWith("https://")) { return url.substr(8); } return url; } class CookiesStorageActor extends BaseStorageActor { constructor(storageActor) { super(storageActor, "cookies"); Services.obs.addObserver(this, "cookie-changed"); Services.obs.addObserver(this, "private-cookie-changed"); } destroy() { Services.obs.removeObserver(this, "cookie-changed"); Services.obs.removeObserver(this, "private-cookie-changed"); super.destroy(); } static UNIQUE_KEY_INDEXES = { name: 0, host: 1, path: 2, partitionKey: 3 }; #getCookieUniqueKey(cookie) { return ( cookie.name + SEPARATOR_GUID + cookie.host + SEPARATOR_GUID + cookie.path + SEPARATOR_GUID + cookie.originAttributes.partitionKey ); } populateStoresForHost(host) { this.hostVsStores.set(host, new Map()); const cookies = this.getCookiesFromHost(host); for (const cookie of cookies) { if (this.isCookieAtHost(cookie, host)) { const uniqueKey = this.#getCookieUniqueKey(cookie); this.hostVsStores.get(host).set(uniqueKey, cookie); } } } getOriginAttributesFromHost(host) { const win = this.storageActor.getWindowFromHost(host); let originAttributes; if (win) { originAttributes = win.document.effectiveStoragePrincipal.originAttributes; } else { // If we can't find the window by host, fallback to the top window // origin attributes. originAttributes = this.storageActor.document?.effectiveStoragePrincipal.originAttributes; } return originAttributes; } getCookiesFromHost(host) { // Gather originAttributes list from host const hostBrowsingContexts = this.storageActor.getBrowsingContextsFromHost(host); const originAttributesList = []; if (hostBrowsingContexts.length) { // Since we need to get all browsing contexts to get their originAttributes, // we might get "duplicated" objects, which would translate into having the same // cookies multiple times. // To avoid that, we compute a unique key from originAttributes to only have unique ones. const uniqueOriginAttributes = new Set(); for (const bc of hostBrowsingContexts) { const { originAttributes } = bc.currentWindowGlobal.documentStoragePrincipal; // The object is small, seems fine to stringify it to compute a unique key const oaKey = JSON.stringify(originAttributes); if (!uniqueOriginAttributes.has(oaKey)) { originAttributesList.push(originAttributes); uniqueOriginAttributes.add(oaKey); } // A document might have an empty partitionKey in browsingContext.currentWindowGlobal.documentStoragePrincipal.originAttributes, // (e.g. a top level document), but still have partitioned cookies, in a different jar // (in CHIPS, for top level document that's first-party partitioned cookies). // In order to retrieve those, we create a new originAttribute with the // partitionKey from the window global cookie jar partitionKey if ( bc.currentWindowGlobal.cookieJarSettings.partitionKey !== originAttributes.partitionKey ) { const derivedOriginAttributes = { ...originAttributes, partitionKey: bc.currentWindowGlobal.cookieJarSettings.partitionKey, }; const derivedOaKey = JSON.stringify(derivedOriginAttributes); if (!uniqueOriginAttributes.has(derivedOaKey)) { originAttributesList.push(derivedOriginAttributes); uniqueOriginAttributes.add(derivedOaKey); } } } } else { // In case of WebExtension or BrowserToolbox, we may pass privileged hosts // which don't relate to any particular window. getOriginAttributesFromHost will // fallback to the top window origin attributes. originAttributesList.push(this.getOriginAttributesFromHost(host)); } // Local files have no host. if (host.startsWith("file:///")) { host = ""; } host = trimHttpHttpsPort(host); // Retrieve cookies all the passed originAttributes so we can get cookies from all jars let cookies; for (const originAttributes of originAttributesList) { const oaCookies = Services.cookies.getCookiesFromHost( host, originAttributes ); if (!cookies) { cookies = oaCookies; } else { cookies.push(...oaCookies); } } return cookies || []; } /** * Given a cookie object, figure out all the matching hosts from the page that * the cookie belong to. */ getMatchingHosts(cookies) { if (!cookies) { return []; } if (!cookies.length) { cookies = [cookies]; } const hosts = new Set(); for (const host of this.hosts) { for (const cookie of cookies) { if (this.isCookieAtHost(cookie, host)) { hosts.add(host); } } } return [...hosts]; } /** * Given a cookie object and a host, figure out if the cookie is valid for * that host. */ isCookieAtHost(cookie, host) { if (cookie.host == null) { return host == null; } host = trimHttpHttpsPort(host); if (cookie.host.startsWith(".")) { return ("." + host).endsWith(cookie.host); } if (cookie.host === "") { return host.startsWith("file://" + cookie.path); } return cookie.host == host; } toStoreObject(cookie) { if (!cookie) { return null; } const obj = { uniqueKey: this.#getCookieUniqueKey(cookie), name: cookie.name, host: cookie.host || "", path: cookie.path || "", // because expires is in mseconds expires: cookie.expires || 0, // because creationTime is in micro seconds creationTime: cookie.creationTime / 1000, // because updateTime is in micro seconds updateTime: cookie.updateTime / 1000, size: cookie.name.length + (cookie.value || "").length, // - do - lastAccessed: cookie.lastAccessed / 1000, value: new LongStringActor(this.conn, cookie.value || ""), hostOnly: !cookie.isDomain, isSecure: cookie.isSecure, isHttpOnly: cookie.isHttpOnly, sameSite: this.getSameSiteStringFromCookie(cookie), }; if (cookie.isPartitioned) { const rawPartitionKey = cookie.originAttributes.partitionKey; // We need to return the site derived from the partition key. // rawPartitionKey format should be like "(,,[port],[ancestorbit])" // see https://searchfox.org/mozilla-central/rev/23efe2c8c5b3a3182d449211ff9036fb34fe0219/caps/OriginAttributes.h#132-138 // We can ignore the `ancestorbit` part. const [scheme, baseDomain, port] = rawPartitionKey .replace(/(?^\()|(?\)$)/g, "") .split(","); const partitionKey = `${scheme}://${baseDomain}${ port !== undefined && /^\d+$/.test(port) ? ":" + port : "" }`; obj.partitionKey = partitionKey; } return obj; } getSameSiteStringFromCookie(cookie) { switch (cookie.sameSite) { case cookie.SAMESITE_LAX: return COOKIE_SAMESITE.LAX; case cookie.SAMESITE_STRICT: return COOKIE_SAMESITE.STRICT; case cookie.SAMESITE_NONE: return COOKIE_SAMESITE.NONE; } // cookie.SAMESITE_UNSET return ""; } /** * Notification observer for "cookie-change". * * @param {(nsICookie|nsICookie[])} cookie - Cookie/s changed. Depending on the action * this is either null, a single cookie or an array of cookies. * @param {nsICookieNotification_Action} action - The cookie operation, see * nsICookieNotification for details. */ onCookieChanged(cookie, action) { const { COOKIE_ADDED, COOKIE_CHANGED, COOKIE_DELETED, COOKIES_BATCH_DELETED, ALL_COOKIES_CLEARED, } = Ci.nsICookieNotification; const hosts = this.getMatchingHosts(cookie); if (!hosts.length) { return; } const data = {}; switch (action) { case COOKIE_ADDED: case COOKIE_CHANGED: if (hosts.length) { for (const host of hosts) { const uniqueKey = this.#getCookieUniqueKey(cookie); this.hostVsStores.get(host).set(uniqueKey, cookie); data[host] = [uniqueKey]; } const actionStr = action == COOKIE_ADDED ? "added" : "changed"; this.storageActor.update(actionStr, "cookies", data); } break; case COOKIE_DELETED: if (hosts.length) { for (const host of hosts) { const uniqueKey = this.#getCookieUniqueKey(cookie); this.hostVsStores.get(host).delete(uniqueKey); data[host] = [uniqueKey]; } this.storageActor.update("deleted", "cookies", data); } break; case COOKIES_BATCH_DELETED: if (hosts.length) { for (const host of hosts) { const stores = []; // For COOKIES_BATCH_DELETED cookie is an array. for (const batchCookie of cookie) { const uniqueKey = this.#getCookieUniqueKey(batchCookie); this.hostVsStores.get(host).delete(uniqueKey); stores.push(uniqueKey); } data[host] = stores; } this.storageActor.update("deleted", "cookies", data); } break; case ALL_COOKIES_CLEARED: if (hosts.length) { for (const host of hosts) { data[host] = []; } this.storageActor.update("cleared", "cookies", data); } break; } } async getFields() { const fields = [ { name: "uniqueKey", editable: false, private: true }, { name: "name", editable: true, hidden: false }, { name: "value", editable: true, hidden: false }, { name: "host", editable: true, hidden: false }, { name: "path", editable: true, hidden: false }, { name: "expires", editable: true, hidden: false }, { name: "size", editable: false, hidden: false }, { name: "isHttpOnly", editable: true, hidden: false }, { name: "isSecure", editable: true, hidden: false }, { name: "sameSite", editable: false, hidden: false }, { name: "lastAccessed", editable: false, hidden: false }, { name: "creationTime", editable: false, hidden: true }, { name: "updateTime", editable: false, hidden: true }, { name: "hostOnly", editable: false, hidden: true }, ]; if (Services.prefs.getBoolPref("network.cookie.CHIPS.enabled", false)) { fields.push({ name: "partitionKey", editable: false, hidden: false }); } return fields; } /** * Pass the editItem command from the content to the chrome process. * * @param {object} data * See editCookie() for format details. * @returns {object} An object with an "errorString" property. */ async editItem(data) { const potentialErrorMessage = this.editCookie(data); return { errorString: potentialErrorMessage }; } /** * Add a cookie on given host * * @param {string} guid * @param {string} host * @returns {object} An object with an "errorString" property. */ async addItem(guid, host) { const window = this.storageActor.getWindowFromHost(host); const principal = window.document.effectiveStoragePrincipal; const potentialErrorMessage = this.addCookie(guid, principal); return { errorString: potentialErrorMessage }; } async removeItem(host, uniqueKey) { if (uniqueKey === undefined) { return; } this._removeCookies(host, { uniqueKey }); } async removeAll(host, domain) { this._removeCookies(host, { domain }); } async removeAllSessionCookies(host, domain) { this._removeCookies(host, { domain, session: true }); } /** * Add a cookie on given principal * * @param {string} guid * @param {Principal} principal * @returns {string | null} If the cookie couldn't be added (e.g. it's invalid), * an error string will be returned. */ addCookie(guid, principal) { // Set expiry time for cookie 1 day into the future // NOTE: Services.cookies.add expects the time in mseconds. const ONE_DAY_IN_MSECONDS = 60 * 60 * 24 * 1000; const time = Date.now(); const expiry = time + ONE_DAY_IN_MSECONDS; // principal throws an error when we try to access principal.host if it // does not exist (which happens at about: pages). // We check for asciiHost instead, which is always present, and has a // value of "" when the host is not available. const domain = principal.asciiHost ? principal.host : principal.baseDomain; const cv = Services.cookies.add( domain, "/", guid, // name DEFAULT_VALUE, // value false, // isSecure false, // isHttpOnly, false, // isSession, expiry, // expires, principal.originAttributes, // originAttributes Ci.nsICookie.SAMESITE_LAX, // sameSite principal.scheme === "https" // schemeMap ? Ci.nsICookie.SCHEME_HTTPS : Ci.nsICookie.SCHEME_HTTP ); if (cv.result != Ci.nsICookieValidation.eOK) { return cv.errorString; } return null; } /** * Apply the results of a cookie edit. * * @param {object} data * An object in the following format: * { * host: "http://www.mozilla.org", * field: "value", * editCookie: "name", * oldValue: "%7BHello%7D", * newValue: "%7BHelloo%7D", * items: { * name: "optimizelyBuckets", * path: "/", * host: ".mozilla.org", * expires: "Mon, 02 Jun 2025 12:37:37 GMT", * creationTime: "Tue, 18 Nov 2014 16:21:18 GMT", * updateTime: "Tue, 18 Nov 2014 16:21:18 GMT", * lastAccessed: "Wed, 17 Feb 2016 10:06:23 GMT", * value: "%7BHelloo%7D", * isDomain: "true", * isSecure: "false", * isHttpOnly: "false" * } * } * @returns {(string | null)} If cookie couldn't be updated (e.g. it's invalid), an error string * will be returned. */ // eslint-disable-next-line complexity editCookie(data) { let { field, oldValue, newValue } = data; const origName = field === "name" ? oldValue : data.items.name; const origHost = field === "host" ? oldValue : data.items.host; const origPath = field === "path" ? oldValue : data.items.path; // We can't use `data.items.partitionKey` as it's the formatted value and we need // to check against the "raw" one. Its value can't be modified, so we don't need to // look into oldValue. const partitionKey = data.items.uniqueKey.split(SEPARATOR_GUID)[ CookiesStorageActor.UNIQUE_KEY_INDEXES.partitionKey ]; let cookie = null; const cookies = this.getCookiesFromHost(data.host); for (const nsiCookie of cookies) { if ( nsiCookie.name === origName && nsiCookie.host === origHost && nsiCookie.path === origPath && nsiCookie.originAttributes.partitionKey === partitionKey ) { cookie = { host: nsiCookie.host, path: nsiCookie.path, name: nsiCookie.name, value: nsiCookie.value, isSecure: nsiCookie.isSecure, isHttpOnly: nsiCookie.isHttpOnly, isSession: nsiCookie.isSession, expires: nsiCookie.expires, originAttributes: nsiCookie.originAttributes, sameSite: nsiCookie.sameSite, schemeMap: nsiCookie.schemeMap, isPartitioned: nsiCookie.isPartitioned, }; break; } } if (!cookie) { return null; } // If the date is expired set it for 10 seconds in the future. const now = new Date(); if (!cookie.isSession && cookie.expires <= now) { const tenMsFromNow = now.getTime() + 10 * 1000; cookie.expires = tenMsFromNow; } let origCookieRemoved = false; switch (field) { case "isSecure": case "isHttpOnly": case "isSession": newValue = newValue === "true"; break; case "expires": newValue = Date.parse(newValue); if (isNaN(newValue)) { newValue = MAX_COOKIE_EXPIRY; } else { newValue = Services.cookies.maybeCapExpiry(newValue); } break; case "host": case "name": case "path": // Remove the edited cookie. Services.cookies.remove( origHost, origName, origPath, cookie.originAttributes ); origCookieRemoved = true; break; } // Apply changes. cookie[field] = newValue; // cookie.isSession is not always set correctly on session cookies so we // need to trust cookie.expires instead. cookie.isSession = !cookie.expires; // Add the edited cookie. const cv = Services.cookies.add( cookie.host, cookie.path, cookie.name, cookie.value, cookie.isSecure, cookie.isHttpOnly, cookie.isSession, cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires, cookie.originAttributes, cookie.sameSite, cookie.schemeMap, cookie.isPartitioned ); if (cv.result != Ci.nsICookieValidation.eOK) { if (origCookieRemoved) { // Re-add the cookie with the original values if it was removed. Services.cookies.add( origHost, origPath, origName, cookie.value, cookie.isSecure, cookie.isHttpOnly, cookie.isSession, cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires, cookie.originAttributes, cookie.sameSite, cookie.schemeMap, cookie.isPartitioned ); } return cv.errorString; } return null; } _removeCookies(host, opts = {}) { // We use a uniqueId to emulate compound keys for cookies. We need to // extract the cookie name to remove the correct cookie. if (opts.uniqueKey) { const uniqueKeyParts = opts.uniqueKey.split(SEPARATOR_GUID); opts.name = uniqueKeyParts[CookiesStorageActor.UNIQUE_KEY_INDEXES.name]; opts.path = uniqueKeyParts[CookiesStorageActor.UNIQUE_KEY_INDEXES.path]; opts.partitionKey = uniqueKeyParts[CookiesStorageActor.UNIQUE_KEY_INDEXES.partitionKey] || ""; } const cookies = this.getCookiesFromHost(host); for (const cookie of cookies) { if ( this.isCookieAtHost(cookie, host) && (!opts.name || cookie.name === opts.name) && (!opts.domain || cookie.host === opts.domain) && (!opts.path || cookie.path === opts.path) && (!opts.uniqueKey || // make sure to pick the cookie from the correct jar cookie.originAttributes.partitionKey === opts.partitionKey) && // for session cookie removal (!opts.session || (!cookie.expires && !cookie.maxAge)) ) { Services.cookies.remove( cookie.host, cookie.name, cookie.path, cookie.originAttributes ); } } } removeCookie(host, name, originAttributes) { if (name !== undefined) { this._removeCookies(host, { name, originAttributes }); } } removeAllCookies(host, domain, originAttributes) { this._removeCookies(host, { domain, originAttributes }); } observe(subject, topic) { if ( !subject || (topic != "cookie-changed" && topic != "private-cookie-changed") || !this.storageActor || !this.storageActor.windows ) { return; } const notification = subject.QueryInterface(Ci.nsICookieNotification); let cookie; if (notification.action == Ci.nsICookieNotification.COOKIES_BATCH_DELETED) { // Extract the batch deleted cookies from nsIArray. const cookiesNoInterface = notification.batchDeletedCookies.QueryInterface(Ci.nsIArray); cookie = []; for (let i = 0; i < cookiesNoInterface.length; i++) { cookie.push(cookiesNoInterface.queryElementAt(i, Ci.nsICookie)); } } else if (notification.cookie) { // Otherwise, get the single cookie affected by the operation. cookie = notification.cookie.QueryInterface(Ci.nsICookie); } this.onCookieChanged(cookie, notification.action); } } exports.CookiesStorageActor = CookiesStorageActor;