cookies.js (21740B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { 8 BaseStorageActor, 9 DEFAULT_VALUE, 10 SEPARATOR_GUID, 11 } = require("resource://devtools/server/actors/resources/storage/index.js"); 12 const { 13 LongStringActor, 14 } = require("resource://devtools/server/actors/string.js"); 15 16 // "Lax", "Strict" and "None" are special values of the SameSite property 17 // that should not be translated. 18 const COOKIE_SAMESITE = { 19 LAX: "Lax", 20 STRICT: "Strict", 21 NONE: "None", 22 }; 23 24 // MAX_COOKIE_EXPIRY should be 2^63-1, but JavaScript can't handle that 25 // precision. 26 const MAX_COOKIE_EXPIRY = Math.pow(2, 62); 27 28 /** 29 * General helpers 30 */ 31 function trimHttpHttpsPort(url) { 32 const match = url.match(/(.+):\d+$/); 33 34 if (match) { 35 url = match[1]; 36 } 37 if (url.startsWith("http://")) { 38 return url.substr(7); 39 } 40 if (url.startsWith("https://")) { 41 return url.substr(8); 42 } 43 return url; 44 } 45 46 class CookiesStorageActor extends BaseStorageActor { 47 constructor(storageActor) { 48 super(storageActor, "cookies"); 49 50 Services.obs.addObserver(this, "cookie-changed"); 51 Services.obs.addObserver(this, "private-cookie-changed"); 52 } 53 54 destroy() { 55 Services.obs.removeObserver(this, "cookie-changed"); 56 Services.obs.removeObserver(this, "private-cookie-changed"); 57 58 super.destroy(); 59 } 60 61 static UNIQUE_KEY_INDEXES = { name: 0, host: 1, path: 2, partitionKey: 3 }; 62 63 #getCookieUniqueKey(cookie) { 64 return ( 65 cookie.name + 66 SEPARATOR_GUID + 67 cookie.host + 68 SEPARATOR_GUID + 69 cookie.path + 70 SEPARATOR_GUID + 71 cookie.originAttributes.partitionKey 72 ); 73 } 74 75 populateStoresForHost(host) { 76 this.hostVsStores.set(host, new Map()); 77 const cookies = this.getCookiesFromHost(host); 78 for (const cookie of cookies) { 79 if (this.isCookieAtHost(cookie, host)) { 80 const uniqueKey = this.#getCookieUniqueKey(cookie); 81 this.hostVsStores.get(host).set(uniqueKey, cookie); 82 } 83 } 84 } 85 86 getOriginAttributesFromHost(host) { 87 const win = this.storageActor.getWindowFromHost(host); 88 let originAttributes; 89 if (win) { 90 originAttributes = 91 win.document.effectiveStoragePrincipal.originAttributes; 92 } else { 93 // If we can't find the window by host, fallback to the top window 94 // origin attributes. 95 originAttributes = 96 this.storageActor.document?.effectiveStoragePrincipal.originAttributes; 97 } 98 99 return originAttributes; 100 } 101 102 getCookiesFromHost(host) { 103 // Gather originAttributes list from host 104 const hostBrowsingContexts = 105 this.storageActor.getBrowsingContextsFromHost(host); 106 const originAttributesList = []; 107 if (hostBrowsingContexts.length) { 108 // Since we need to get all browsing contexts to get their originAttributes, 109 // we might get "duplicated" objects, which would translate into having the same 110 // cookies multiple times. 111 // To avoid that, we compute a unique key from originAttributes to only have unique ones. 112 const uniqueOriginAttributes = new Set(); 113 for (const bc of hostBrowsingContexts) { 114 const { originAttributes } = 115 bc.currentWindowGlobal.documentStoragePrincipal; 116 // The object is small, seems fine to stringify it to compute a unique key 117 const oaKey = JSON.stringify(originAttributes); 118 if (!uniqueOriginAttributes.has(oaKey)) { 119 originAttributesList.push(originAttributes); 120 uniqueOriginAttributes.add(oaKey); 121 } 122 123 // A document might have an empty partitionKey in browsingContext.currentWindowGlobal.documentStoragePrincipal.originAttributes, 124 // (e.g. a top level document), but still have partitioned cookies, in a different jar 125 // (in CHIPS, for top level document that's first-party partitioned cookies). 126 // In order to retrieve those, we create a new originAttribute with the 127 // partitionKey from the window global cookie jar partitionKey 128 if ( 129 bc.currentWindowGlobal.cookieJarSettings.partitionKey !== 130 originAttributes.partitionKey 131 ) { 132 const derivedOriginAttributes = { 133 ...originAttributes, 134 partitionKey: bc.currentWindowGlobal.cookieJarSettings.partitionKey, 135 }; 136 const derivedOaKey = JSON.stringify(derivedOriginAttributes); 137 if (!uniqueOriginAttributes.has(derivedOaKey)) { 138 originAttributesList.push(derivedOriginAttributes); 139 uniqueOriginAttributes.add(derivedOaKey); 140 } 141 } 142 } 143 } else { 144 // In case of WebExtension or BrowserToolbox, we may pass privileged hosts 145 // which don't relate to any particular window. getOriginAttributesFromHost will 146 // fallback to the top window origin attributes. 147 originAttributesList.push(this.getOriginAttributesFromHost(host)); 148 } 149 150 // Local files have no host. 151 if (host.startsWith("file:///")) { 152 host = ""; 153 } 154 155 host = trimHttpHttpsPort(host); 156 157 // Retrieve cookies all the passed originAttributes so we can get cookies from all jars 158 let cookies; 159 for (const originAttributes of originAttributesList) { 160 const oaCookies = Services.cookies.getCookiesFromHost( 161 host, 162 originAttributes 163 ); 164 if (!cookies) { 165 cookies = oaCookies; 166 } else { 167 cookies.push(...oaCookies); 168 } 169 } 170 return cookies || []; 171 } 172 173 /** 174 * Given a cookie object, figure out all the matching hosts from the page that 175 * the cookie belong to. 176 */ 177 getMatchingHosts(cookies) { 178 if (!cookies) { 179 return []; 180 } 181 if (!cookies.length) { 182 cookies = [cookies]; 183 } 184 const hosts = new Set(); 185 for (const host of this.hosts) { 186 for (const cookie of cookies) { 187 if (this.isCookieAtHost(cookie, host)) { 188 hosts.add(host); 189 } 190 } 191 } 192 return [...hosts]; 193 } 194 195 /** 196 * Given a cookie object and a host, figure out if the cookie is valid for 197 * that host. 198 */ 199 isCookieAtHost(cookie, host) { 200 if (cookie.host == null) { 201 return host == null; 202 } 203 204 host = trimHttpHttpsPort(host); 205 206 if (cookie.host.startsWith(".")) { 207 return ("." + host).endsWith(cookie.host); 208 } 209 if (cookie.host === "") { 210 return host.startsWith("file://" + cookie.path); 211 } 212 213 return cookie.host == host; 214 } 215 216 toStoreObject(cookie) { 217 if (!cookie) { 218 return null; 219 } 220 221 const obj = { 222 uniqueKey: this.#getCookieUniqueKey(cookie), 223 name: cookie.name, 224 host: cookie.host || "", 225 path: cookie.path || "", 226 227 // because expires is in mseconds 228 expires: cookie.expires || 0, 229 230 // because creationTime is in micro seconds 231 creationTime: cookie.creationTime / 1000, 232 233 // because updateTime is in micro seconds 234 updateTime: cookie.updateTime / 1000, 235 236 size: cookie.name.length + (cookie.value || "").length, 237 238 // - do - 239 lastAccessed: cookie.lastAccessed / 1000, 240 value: new LongStringActor(this.conn, cookie.value || ""), 241 hostOnly: !cookie.isDomain, 242 isSecure: cookie.isSecure, 243 isHttpOnly: cookie.isHttpOnly, 244 sameSite: this.getSameSiteStringFromCookie(cookie), 245 }; 246 247 if (cookie.isPartitioned) { 248 const rawPartitionKey = cookie.originAttributes.partitionKey; 249 // We need to return the site derived from the partition key. 250 // rawPartitionKey format should be like "(<scheme>,<baseDomain>,[port],[ancestorbit])" 251 // see https://searchfox.org/mozilla-central/rev/23efe2c8c5b3a3182d449211ff9036fb34fe0219/caps/OriginAttributes.h#132-138 252 // We can ignore the `ancestorbit` part. 253 const [scheme, baseDomain, port] = rawPartitionKey 254 .replace(/(?<openingparen>^\()|(?<closingparen>\)$)/g, "") 255 .split(","); 256 const partitionKey = `${scheme}://${baseDomain}${ 257 port !== undefined && /^\d+$/.test(port) ? ":" + port : "" 258 }`; 259 obj.partitionKey = partitionKey; 260 } 261 262 return obj; 263 } 264 265 getSameSiteStringFromCookie(cookie) { 266 switch (cookie.sameSite) { 267 case cookie.SAMESITE_LAX: 268 return COOKIE_SAMESITE.LAX; 269 case cookie.SAMESITE_STRICT: 270 return COOKIE_SAMESITE.STRICT; 271 case cookie.SAMESITE_NONE: 272 return COOKIE_SAMESITE.NONE; 273 } 274 // cookie.SAMESITE_UNSET 275 return ""; 276 } 277 278 /** 279 * Notification observer for "cookie-change". 280 * 281 * @param {(nsICookie|nsICookie[])} cookie - Cookie/s changed. Depending on the action 282 * this is either null, a single cookie or an array of cookies. 283 * @param {nsICookieNotification_Action} action - The cookie operation, see 284 * nsICookieNotification for details. 285 */ 286 onCookieChanged(cookie, action) { 287 const { 288 COOKIE_ADDED, 289 COOKIE_CHANGED, 290 COOKIE_DELETED, 291 COOKIES_BATCH_DELETED, 292 ALL_COOKIES_CLEARED, 293 } = Ci.nsICookieNotification; 294 295 const hosts = this.getMatchingHosts(cookie); 296 if (!hosts.length) { 297 return; 298 } 299 300 const data = {}; 301 302 switch (action) { 303 case COOKIE_ADDED: 304 case COOKIE_CHANGED: 305 if (hosts.length) { 306 for (const host of hosts) { 307 const uniqueKey = this.#getCookieUniqueKey(cookie); 308 this.hostVsStores.get(host).set(uniqueKey, cookie); 309 data[host] = [uniqueKey]; 310 } 311 const actionStr = action == COOKIE_ADDED ? "added" : "changed"; 312 this.storageActor.update(actionStr, "cookies", data); 313 } 314 break; 315 316 case COOKIE_DELETED: 317 if (hosts.length) { 318 for (const host of hosts) { 319 const uniqueKey = this.#getCookieUniqueKey(cookie); 320 this.hostVsStores.get(host).delete(uniqueKey); 321 data[host] = [uniqueKey]; 322 } 323 this.storageActor.update("deleted", "cookies", data); 324 } 325 break; 326 327 case COOKIES_BATCH_DELETED: 328 if (hosts.length) { 329 for (const host of hosts) { 330 const stores = []; 331 // For COOKIES_BATCH_DELETED cookie is an array. 332 for (const batchCookie of cookie) { 333 const uniqueKey = this.#getCookieUniqueKey(batchCookie); 334 this.hostVsStores.get(host).delete(uniqueKey); 335 stores.push(uniqueKey); 336 } 337 data[host] = stores; 338 } 339 this.storageActor.update("deleted", "cookies", data); 340 } 341 break; 342 343 case ALL_COOKIES_CLEARED: 344 if (hosts.length) { 345 for (const host of hosts) { 346 data[host] = []; 347 } 348 this.storageActor.update("cleared", "cookies", data); 349 } 350 break; 351 } 352 } 353 354 async getFields() { 355 const fields = [ 356 { name: "uniqueKey", editable: false, private: true }, 357 { name: "name", editable: true, hidden: false }, 358 { name: "value", editable: true, hidden: false }, 359 { name: "host", editable: true, hidden: false }, 360 { name: "path", editable: true, hidden: false }, 361 { name: "expires", editable: true, hidden: false }, 362 { name: "size", editable: false, hidden: false }, 363 { name: "isHttpOnly", editable: true, hidden: false }, 364 { name: "isSecure", editable: true, hidden: false }, 365 { name: "sameSite", editable: false, hidden: false }, 366 { name: "lastAccessed", editable: false, hidden: false }, 367 { name: "creationTime", editable: false, hidden: true }, 368 { name: "updateTime", editable: false, hidden: true }, 369 { name: "hostOnly", editable: false, hidden: true }, 370 ]; 371 372 if (Services.prefs.getBoolPref("network.cookie.CHIPS.enabled", false)) { 373 fields.push({ name: "partitionKey", editable: false, hidden: false }); 374 } 375 376 return fields; 377 } 378 379 /** 380 * Pass the editItem command from the content to the chrome process. 381 * 382 * @param {object} data 383 * See editCookie() for format details. 384 * @returns {object} An object with an "errorString" property. 385 */ 386 async editItem(data) { 387 const potentialErrorMessage = this.editCookie(data); 388 return { errorString: potentialErrorMessage }; 389 } 390 391 /** 392 * Add a cookie on given host 393 * 394 * @param {string} guid 395 * @param {string} host 396 * @returns {object} An object with an "errorString" property. 397 */ 398 async addItem(guid, host) { 399 const window = this.storageActor.getWindowFromHost(host); 400 const principal = window.document.effectiveStoragePrincipal; 401 const potentialErrorMessage = this.addCookie(guid, principal); 402 return { errorString: potentialErrorMessage }; 403 } 404 405 async removeItem(host, uniqueKey) { 406 if (uniqueKey === undefined) { 407 return; 408 } 409 this._removeCookies(host, { uniqueKey }); 410 } 411 412 async removeAll(host, domain) { 413 this._removeCookies(host, { domain }); 414 } 415 416 async removeAllSessionCookies(host, domain) { 417 this._removeCookies(host, { domain, session: true }); 418 } 419 420 /** 421 * Add a cookie on given principal 422 * 423 * @param {string} guid 424 * @param {Principal} principal 425 * @returns {string | null} If the cookie couldn't be added (e.g. it's invalid), 426 * an error string will be returned. 427 */ 428 addCookie(guid, principal) { 429 // Set expiry time for cookie 1 day into the future 430 // NOTE: Services.cookies.add expects the time in mseconds. 431 const ONE_DAY_IN_MSECONDS = 60 * 60 * 24 * 1000; 432 const time = Date.now(); 433 const expiry = time + ONE_DAY_IN_MSECONDS; 434 435 // principal throws an error when we try to access principal.host if it 436 // does not exist (which happens at about: pages). 437 // We check for asciiHost instead, which is always present, and has a 438 // value of "" when the host is not available. 439 const domain = principal.asciiHost ? principal.host : principal.baseDomain; 440 441 const cv = Services.cookies.add( 442 domain, 443 "/", 444 guid, // name 445 DEFAULT_VALUE, // value 446 false, // isSecure 447 false, // isHttpOnly, 448 false, // isSession, 449 expiry, // expires, 450 principal.originAttributes, // originAttributes 451 Ci.nsICookie.SAMESITE_LAX, // sameSite 452 principal.scheme === "https" // schemeMap 453 ? Ci.nsICookie.SCHEME_HTTPS 454 : Ci.nsICookie.SCHEME_HTTP 455 ); 456 457 if (cv.result != Ci.nsICookieValidation.eOK) { 458 return cv.errorString; 459 } 460 461 return null; 462 } 463 464 /** 465 * Apply the results of a cookie edit. 466 * 467 * @param {object} data 468 * An object in the following format: 469 * { 470 * host: "http://www.mozilla.org", 471 * field: "value", 472 * editCookie: "name", 473 * oldValue: "%7BHello%7D", 474 * newValue: "%7BHelloo%7D", 475 * items: { 476 * name: "optimizelyBuckets", 477 * path: "/", 478 * host: ".mozilla.org", 479 * expires: "Mon, 02 Jun 2025 12:37:37 GMT", 480 * creationTime: "Tue, 18 Nov 2014 16:21:18 GMT", 481 * updateTime: "Tue, 18 Nov 2014 16:21:18 GMT", 482 * lastAccessed: "Wed, 17 Feb 2016 10:06:23 GMT", 483 * value: "%7BHelloo%7D", 484 * isDomain: "true", 485 * isSecure: "false", 486 * isHttpOnly: "false" 487 * } 488 * } 489 * @returns {(string | null)} If cookie couldn't be updated (e.g. it's invalid), an error string 490 * will be returned. 491 */ 492 // eslint-disable-next-line complexity 493 editCookie(data) { 494 let { field, oldValue, newValue } = data; 495 const origName = field === "name" ? oldValue : data.items.name; 496 const origHost = field === "host" ? oldValue : data.items.host; 497 const origPath = field === "path" ? oldValue : data.items.path; 498 // We can't use `data.items.partitionKey` as it's the formatted value and we need 499 // to check against the "raw" one. Its value can't be modified, so we don't need to 500 // look into oldValue. 501 const partitionKey = 502 data.items.uniqueKey.split(SEPARATOR_GUID)[ 503 CookiesStorageActor.UNIQUE_KEY_INDEXES.partitionKey 504 ]; 505 let cookie = null; 506 507 const cookies = this.getCookiesFromHost(data.host); 508 for (const nsiCookie of cookies) { 509 if ( 510 nsiCookie.name === origName && 511 nsiCookie.host === origHost && 512 nsiCookie.path === origPath && 513 nsiCookie.originAttributes.partitionKey === partitionKey 514 ) { 515 cookie = { 516 host: nsiCookie.host, 517 path: nsiCookie.path, 518 name: nsiCookie.name, 519 value: nsiCookie.value, 520 isSecure: nsiCookie.isSecure, 521 isHttpOnly: nsiCookie.isHttpOnly, 522 isSession: nsiCookie.isSession, 523 expires: nsiCookie.expires, 524 originAttributes: nsiCookie.originAttributes, 525 sameSite: nsiCookie.sameSite, 526 schemeMap: nsiCookie.schemeMap, 527 isPartitioned: nsiCookie.isPartitioned, 528 }; 529 break; 530 } 531 } 532 533 if (!cookie) { 534 return null; 535 } 536 537 // If the date is expired set it for 10 seconds in the future. 538 const now = new Date(); 539 if (!cookie.isSession && cookie.expires <= now) { 540 const tenMsFromNow = now.getTime() + 10 * 1000; 541 542 cookie.expires = tenMsFromNow; 543 } 544 545 let origCookieRemoved = false; 546 547 switch (field) { 548 case "isSecure": 549 case "isHttpOnly": 550 case "isSession": 551 newValue = newValue === "true"; 552 break; 553 554 case "expires": 555 newValue = Date.parse(newValue); 556 557 if (isNaN(newValue)) { 558 newValue = MAX_COOKIE_EXPIRY; 559 } else { 560 newValue = Services.cookies.maybeCapExpiry(newValue); 561 } 562 break; 563 564 case "host": 565 case "name": 566 case "path": 567 // Remove the edited cookie. 568 Services.cookies.remove( 569 origHost, 570 origName, 571 origPath, 572 cookie.originAttributes 573 ); 574 origCookieRemoved = true; 575 break; 576 } 577 578 // Apply changes. 579 cookie[field] = newValue; 580 581 // cookie.isSession is not always set correctly on session cookies so we 582 // need to trust cookie.expires instead. 583 cookie.isSession = !cookie.expires; 584 585 // Add the edited cookie. 586 const cv = Services.cookies.add( 587 cookie.host, 588 cookie.path, 589 cookie.name, 590 cookie.value, 591 cookie.isSecure, 592 cookie.isHttpOnly, 593 cookie.isSession, 594 cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires, 595 cookie.originAttributes, 596 cookie.sameSite, 597 cookie.schemeMap, 598 cookie.isPartitioned 599 ); 600 601 if (cv.result != Ci.nsICookieValidation.eOK) { 602 if (origCookieRemoved) { 603 // Re-add the cookie with the original values if it was removed. 604 Services.cookies.add( 605 origHost, 606 origPath, 607 origName, 608 cookie.value, 609 cookie.isSecure, 610 cookie.isHttpOnly, 611 cookie.isSession, 612 cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires, 613 cookie.originAttributes, 614 cookie.sameSite, 615 cookie.schemeMap, 616 cookie.isPartitioned 617 ); 618 } 619 620 return cv.errorString; 621 } 622 623 return null; 624 } 625 626 _removeCookies(host, opts = {}) { 627 // We use a uniqueId to emulate compound keys for cookies. We need to 628 // extract the cookie name to remove the correct cookie. 629 if (opts.uniqueKey) { 630 const uniqueKeyParts = opts.uniqueKey.split(SEPARATOR_GUID); 631 632 opts.name = uniqueKeyParts[CookiesStorageActor.UNIQUE_KEY_INDEXES.name]; 633 opts.path = uniqueKeyParts[CookiesStorageActor.UNIQUE_KEY_INDEXES.path]; 634 opts.partitionKey = 635 uniqueKeyParts[CookiesStorageActor.UNIQUE_KEY_INDEXES.partitionKey] || 636 ""; 637 } 638 639 const cookies = this.getCookiesFromHost(host); 640 for (const cookie of cookies) { 641 if ( 642 this.isCookieAtHost(cookie, host) && 643 (!opts.name || cookie.name === opts.name) && 644 (!opts.domain || cookie.host === opts.domain) && 645 (!opts.path || cookie.path === opts.path) && 646 (!opts.uniqueKey || 647 // make sure to pick the cookie from the correct jar 648 cookie.originAttributes.partitionKey === opts.partitionKey) && 649 // for session cookie removal 650 (!opts.session || (!cookie.expires && !cookie.maxAge)) 651 ) { 652 Services.cookies.remove( 653 cookie.host, 654 cookie.name, 655 cookie.path, 656 cookie.originAttributes 657 ); 658 } 659 } 660 } 661 662 removeCookie(host, name, originAttributes) { 663 if (name !== undefined) { 664 this._removeCookies(host, { name, originAttributes }); 665 } 666 } 667 668 removeAllCookies(host, domain, originAttributes) { 669 this._removeCookies(host, { domain, originAttributes }); 670 } 671 672 observe(subject, topic) { 673 if ( 674 !subject || 675 (topic != "cookie-changed" && topic != "private-cookie-changed") || 676 !this.storageActor || 677 !this.storageActor.windows 678 ) { 679 return; 680 } 681 682 const notification = subject.QueryInterface(Ci.nsICookieNotification); 683 let cookie; 684 if (notification.action == Ci.nsICookieNotification.COOKIES_BATCH_DELETED) { 685 // Extract the batch deleted cookies from nsIArray. 686 const cookiesNoInterface = 687 notification.batchDeletedCookies.QueryInterface(Ci.nsIArray); 688 cookie = []; 689 for (let i = 0; i < cookiesNoInterface.length; i++) { 690 cookie.push(cookiesNoInterface.queryElementAt(i, Ci.nsICookie)); 691 } 692 } else if (notification.cookie) { 693 // Otherwise, get the single cookie affected by the operation. 694 cookie = notification.cookie.QueryInterface(Ci.nsICookie); 695 } 696 697 this.onCookieChanged(cookie, notification.action); 698 } 699 } 700 exports.CookiesStorageActor = CookiesStorageActor;