network.sys.mjs (89858B)
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 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 import { RootBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/RootBiDiModule.sys.mjs"; 8 9 const lazy = {}; 10 11 ChromeUtils.defineESModuleGetters(lazy, { 12 NetworkHelper: 13 "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs", 14 15 assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", 16 CacheBehavior: "chrome://remote/content/shared/NetworkCacheManager.sys.mjs", 17 error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", 18 generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", 19 Log: "chrome://remote/content/shared/Log.sys.mjs", 20 matchURLPattern: 21 "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs", 22 NavigableManager: "chrome://remote/content/shared/NavigableManager.sys.mjs", 23 NetworkDataBytes: "chrome://remote/content/shared/NetworkDataBytes.sys.mjs", 24 NetworkDecodedBodySizeMap: 25 "chrome://remote/content/shared/NetworkDecodedBodySizeMap.sys.mjs", 26 NetworkListener: 27 "chrome://remote/content/shared/listeners/NetworkListener.sys.mjs", 28 NetworkResponse: "chrome://remote/content/shared/NetworkResponse.sys.mjs", 29 parseChallengeHeader: 30 "chrome://remote/content/shared/ChallengeHeaderParser.sys.mjs", 31 parseURLPattern: 32 "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs", 33 pprint: "chrome://remote/content/shared/Format.sys.mjs", 34 truncate: "chrome://remote/content/shared/Format.sys.mjs", 35 updateCacheBehavior: 36 "chrome://remote/content/shared/NetworkCacheManager.sys.mjs", 37 UserContextManager: 38 "chrome://remote/content/shared/UserContextManager.sys.mjs", 39 }); 40 41 ChromeUtils.defineLazyGetter(lazy, "logger", () => 42 lazy.Log.get(lazy.Log.TYPES.WEBDRIVER_BIDI) 43 ); 44 45 // WIP: See Bug 1994983, the invalid tests for network.setExtraHeaders expect 46 // the optional arguments not to accept null. 47 const NULL = Symbol("NULL"); 48 49 /** 50 * Defines the maximum total size as expected by the specification at 51 * https://w3c.github.io/webdriver-bidi/#max-total-data-size 52 */ 53 const DEFAULT_MAX_TOTAL_SIZE = 200 * 1000 * 1000; 54 XPCOMUtils.defineLazyPreferenceGetter( 55 lazy, 56 "maxTotalDataSize", 57 "remote.network.maxTotalDataSize", 58 DEFAULT_MAX_TOTAL_SIZE 59 ); 60 61 /** 62 * @typedef {object} AuthChallenge 63 * @property {string} scheme 64 * @property {string} realm 65 */ 66 67 /** 68 * @typedef {object} AuthCredentials 69 * @property {'password'} type 70 * @property {string} username 71 * @property {string} password 72 */ 73 74 /** 75 * @typedef {object} BaseParameters 76 * @property {string=} context 77 * @property {Array<string>?} intercepts 78 * @property {boolean} isBlocked 79 * @property {Navigation=} navigation 80 * @property {number} redirectCount 81 * @property {RequestData} request 82 * @property {number} timestamp 83 */ 84 85 /** 86 * @typedef {object} BlockedRequest 87 * @property {NetworkEventRecord} networkEventRecord 88 * @property {InterceptPhase} phase 89 */ 90 91 /** 92 * Enum of possible BytesValue types. 93 * 94 * @readonly 95 * @enum {BytesValueType} 96 */ 97 export const BytesValueType = { 98 Base64: "base64", 99 String: "string", 100 }; 101 102 /** 103 * @typedef {object} BytesValue 104 * @property {BytesValueType} type 105 * @property {string} value 106 */ 107 108 /** 109 * Enum of possible network data collector types. 110 * 111 * @readonly 112 * @enum {CollectorType} 113 */ 114 const CollectorType = { 115 Blob: "blob", 116 }; 117 118 /** 119 * @typedef {object} Collector 120 * @property {number} maxEncodedDataSize 121 * @property {Set<DataType>} dataTypes 122 * @property {string} collector 123 * @property {CollectorType} collectorType 124 * @property {Set<string>} userContexts 125 */ 126 127 /** 128 * Enum of possible continueWithAuth actions. 129 * 130 * @readonly 131 * @enum {ContinueWithAuthAction} 132 */ 133 const ContinueWithAuthAction = { 134 Cancel: "cancel", 135 Default: "default", 136 ProvideCredentials: "provideCredentials", 137 }; 138 139 /** 140 * @typedef {object} Cookie 141 * @property {string} domain 142 * @property {number=} expires 143 * @property {boolean} httpOnly 144 * @property {string} name 145 * @property {string} path 146 * @property {SameSite} sameSite 147 * @property {boolean} secure 148 * @property {number} size 149 * @property {BytesValue} value 150 */ 151 152 /** 153 * @typedef {object} CookieHeader 154 * @property {string} name 155 * @property {BytesValue} value 156 */ 157 158 /** 159 * Enum of possible network data types. 160 * 161 * @readonly 162 * @enum {DataType} 163 */ 164 const DataType = { 165 Request: "request", 166 Response: "response", 167 }; 168 169 /** 170 * @typedef {object} Data 171 * @property {BytesValue|null} bytes 172 * @property {Array<Collector>} collectors 173 * @property {boolean} pending 174 * @property {string} request 175 * @property {number} size 176 * @property {DataType} type 177 */ 178 179 /** 180 * @typedef {object} FetchTimingInfo 181 * @property {number} timeOrigin 182 * @property {number} requestTime 183 * @property {number} redirectStart 184 * @property {number} redirectEnd 185 * @property {number} fetchStart 186 * @property {number} dnsStart 187 * @property {number} dnsEnd 188 * @property {number} connectStart 189 * @property {number} connectEnd 190 * @property {number} tlsStart 191 * @property {number} requestStart 192 * @property {number} responseStart 193 * @property {number} responseEnd 194 */ 195 196 /** 197 * @typedef {object} Header 198 * @property {string} name 199 * @property {BytesValue} value 200 */ 201 202 /** 203 * @typedef {string} InitiatorType 204 */ 205 206 /** 207 * Enum of possible initiator types. 208 * 209 * @readonly 210 * @enum {InitiatorType} 211 */ 212 const InitiatorType = { 213 Other: "other", 214 Parser: "parser", 215 Preflight: "preflight", 216 Script: "script", 217 }; 218 219 /** 220 * @typedef {object} Initiator 221 * @property {InitiatorType} type 222 * @property {number=} columnNumber 223 * @property {number=} lineNumber 224 * @property {string=} request 225 * @property {StackTrace=} stackTrace 226 */ 227 228 /** 229 * Enum of intercept phases. 230 * 231 * @readonly 232 * @enum {InterceptPhase} 233 */ 234 const InterceptPhase = { 235 AuthRequired: "authRequired", 236 BeforeRequestSent: "beforeRequestSent", 237 ResponseStarted: "responseStarted", 238 }; 239 240 /** 241 * @typedef {object} InterceptProperties 242 * @property {Array<InterceptPhase>} phases 243 * @property {Array<URLPattern>} urlPatterns 244 */ 245 246 /** 247 * @typedef {object} RequestData 248 * @property {number|null} bodySize 249 * Defaults to null. 250 * @property {Array<Cookie>} cookies 251 * @property {Array<Header>} headers 252 * @property {number} headersSize 253 * @property {string} method 254 * @property {string} request 255 * @property {FetchTimingInfo} timings 256 * @property {string} url 257 */ 258 259 /** 260 * @typedef {object} BeforeRequestSentParametersProperties 261 * @property {Initiator} initiator 262 */ 263 264 /* eslint-disable jsdoc/valid-types */ 265 /** 266 * Parameters for the BeforeRequestSent event 267 * 268 * @typedef {BaseParameters & BeforeRequestSentParametersProperties} BeforeRequestSentParameters 269 */ 270 /* eslint-enable jsdoc/valid-types */ 271 272 /** 273 * @typedef {object} ResponseContent 274 * @property {number|null} size 275 * Defaults to null. 276 */ 277 278 /** 279 * @typedef {object} ResponseData 280 * @property {string} url 281 * @property {string} protocol 282 * @property {number} status 283 * @property {string} statusText 284 * @property {boolean} fromCache 285 * @property {Array<Header>} headers 286 * @property {string} mimeType 287 * @property {number} bytesReceived 288 * @property {number|null} headersSize 289 * Defaults to null. 290 * @property {number|null} bodySize 291 * Defaults to null. 292 * @property {ResponseContent} content 293 * @property {Array<AuthChallenge>=} authChallenges 294 */ 295 296 /** 297 * @typedef {object} ResponseStartedParametersProperties 298 * @property {ResponseData} response 299 */ 300 301 /* eslint-disable jsdoc/valid-types */ 302 /** 303 * Parameters for the ResponseStarted event 304 * 305 * @typedef {BaseParameters & ResponseStartedParametersProperties} ResponseStartedParameters 306 */ 307 /* eslint-enable jsdoc/valid-types */ 308 309 /** 310 * @typedef {object} ResponseCompletedParametersProperties 311 * @property {ResponseData} response 312 */ 313 314 /** 315 * Enum of possible sameSite values. 316 * 317 * @readonly 318 * @enum {SameSite} 319 */ 320 const SameSite = { 321 Lax: "lax", 322 None: "none", 323 Strict: "strict", 324 }; 325 326 /** 327 * @typedef {object} SetCookieHeader 328 * @property {string} name 329 * @property {BytesValue} value 330 * @property {string=} domain 331 * @property {boolean=} httpOnly 332 * @property {string=} expiry 333 * @property {number=} maxAge 334 * @property {string=} path 335 * @property {SameSite=} sameSite 336 * @property {boolean=} secure 337 */ 338 339 /** 340 * @typedef {object} URLPatternPattern 341 * @property {'pattern'} type 342 * @property {string=} protocol 343 * @property {string=} hostname 344 * @property {string=} port 345 * @property {string=} pathname 346 * @property {string=} search 347 */ 348 349 /** 350 * @typedef {object} URLPatternString 351 * @property {'string'} type 352 * @property {string} pattern 353 */ 354 355 /** 356 * @typedef {(URLPatternPattern|URLPatternString)} URLPattern 357 */ 358 359 /* eslint-disable jsdoc/valid-types */ 360 /** 361 * Parameters for the ResponseCompleted event 362 * 363 * @typedef {BaseParameters & ResponseCompletedParametersProperties} ResponseCompletedParameters 364 */ 365 /* eslint-enable jsdoc/valid-types */ 366 367 // @see https://searchfox.org/mozilla-central/rev/527d691a542ccc0f333e36689bd665cb000360b2/netwerk/protocol/http/HttpBaseChannel.cpp#2083-2088 368 const IMMUTABLE_RESPONSE_HEADERS = [ 369 "content-encoding", 370 "content-length", 371 "content-type", 372 "trailer", 373 "transfer-encoding", 374 ]; 375 376 const UNAVAILABLE_DATA_ERROR_REASON = { 377 Aborted: "aborted", 378 Evicted: "evicted", 379 }; 380 381 class NetworkModule extends RootBiDiModule { 382 #blockedRequests; 383 #collectedNetworkData; 384 #decodedBodySizeMap; 385 #extraHeaders; 386 #interceptMap; 387 #networkCollectors; 388 #networkListener; 389 #redirectedRequests; 390 #subscribedEvents; 391 392 constructor(messageHandler) { 393 super(messageHandler); 394 395 // Map of request id to BlockedRequest 396 this.#blockedRequests = new Map(); 397 398 // Map of collected network Data, from a composite key `${requestId}-${dataType}` 399 // to a network Data struct. 400 // https://w3c.github.io/webdriver-bidi/#collected-network-data 401 // TODO: This is a property of the remote end per spec, not of the session. 402 // At the moment, each network module starts its own network observer. This 403 // makes it impossible to have a session agnostic step when receiving a new 404 // network event. 405 // Note: Implemented as a Map. A Map is guaranteed to iterate in the order 406 // of insertion, but still provides fast lookup. 407 this.#collectedNetworkData = new Map(); 408 409 // Implements the BiDi Session extra headers. 410 // https://w3c.github.io/webdriver-bidi/#session-extra-headers 411 this.#extraHeaders = { 412 // Array of Header objects, initially empty. 413 defaultHeaders: [], 414 // WeakMap between navigables and arrays of Header objects. 415 // Due to technical limitations, navigables are represented via the 416 // BrowsingContextWebProgress of the top level browsing context. 417 navigableHeaders: new WeakMap(), 418 // Map between user context ids and arrays of Header objects. 419 userContextHeaders: new Map(), 420 }; 421 422 // Map of intercept id to InterceptProperties 423 this.#interceptMap = new Map(); 424 425 // Map of collector id to Collector 426 this.#networkCollectors = new Map(); 427 428 // Set of request ids which are being redirected using continueRequest with 429 // a url parameter. Those requests will lead to an additional beforeRequestSent 430 // event which needs to be filtered out. 431 this.#redirectedRequests = new Set(); 432 433 // Set of event names which have active subscriptions 434 this.#subscribedEvents = new Set(); 435 436 this.#decodedBodySizeMap = new lazy.NetworkDecodedBodySizeMap(); 437 438 this.#networkListener = new lazy.NetworkListener( 439 this.messageHandler.navigationManager, 440 this.#decodedBodySizeMap 441 ); 442 this.#networkListener.on("auth-required", this.#onAuthRequired); 443 this.#networkListener.on("before-request-sent", this.#onBeforeRequestSent); 444 this.#networkListener.on("fetch-error", this.#onFetchError); 445 this.#networkListener.on("response-completed", this.#onResponseEvent); 446 this.#networkListener.on("response-started", this.#onResponseEvent); 447 448 lazy.UserContextManager.on( 449 "user-context-deleted", 450 this.#onUserContextDeleted 451 ); 452 } 453 454 destroy() { 455 lazy.UserContextManager.off( 456 "user-context-deleted", 457 this.#onUserContextDeleted 458 ); 459 460 this.#networkListener.off("auth-required", this.#onAuthRequired); 461 this.#networkListener.off("before-request-sent", this.#onBeforeRequestSent); 462 this.#networkListener.off("fetch-error", this.#onFetchError); 463 this.#networkListener.off("response-completed", this.#onResponseEvent); 464 this.#networkListener.off("response-started", this.#onResponseEvent); 465 this.#networkListener.destroy(); 466 467 this.#decodedBodySizeMap.destroy(); 468 469 // Network related session cleanup steps 470 // https://w3c.github.io/webdriver-bidi/#cleanup-the-session 471 472 // Resume blocked requests 473 for (const [, { request }] of this.#blockedRequests) { 474 try { 475 request.wrappedChannel.resume(); 476 } catch { 477 lazy.logger.warn( 478 `Failed to resume request "${request.requestId}" when ending the session` 479 ); 480 } 481 } 482 483 // Remove collectors from collected data. 484 // TODO: This step is unnecessary until we support multiple sessions, because 485 // the collectedNetworkData is attached to the session and is cleaned up 486 // afterwards. 487 488 this.#blockedRequests = null; 489 this.#collectedNetworkData = null; 490 this.#decodedBodySizeMap = null; 491 this.#extraHeaders = null; 492 this.#interceptMap = null; 493 this.#networkCollectors = null; 494 this.#subscribedEvents = null; 495 } 496 497 /** 498 * Adds a data collector to collect network data. 499 * 500 * @param {object=} options 501 * @param {Array<DataType>} options.dataTypes 502 * Maximum size of data to collect in bytes. 503 * @param {number} options.maxEncodedDataSize 504 * Maximum size of data to collect in bytes. 505 * @param {CollectorType=} options.collectorType 506 * The type of data to collect. Optional, defaults to "blob". 507 * @param {Array<string>=} options.contexts 508 * Optional list of browsing context ids. 509 * @param {Array<string>=} options.userContexts 510 * Optional list of user context ids. 511 * 512 * @returns {object} 513 * An object with the following property: 514 * - collector {string} The unique id of the data collector. 515 * 516 * @throws {InvalidArgumentError} 517 * Raised if an argument is of an invalid type or value. 518 */ 519 addDataCollector(options = {}) { 520 const { 521 dataTypes, 522 maxEncodedDataSize, 523 collectorType = CollectorType.Blob, 524 contexts: contextIds = null, 525 userContexts: userContextIds = null, 526 } = options; 527 528 lazy.assert.positiveInteger( 529 maxEncodedDataSize, 530 lazy.pprint`Expected "maxEncodedDataSize" to be a positive integer, got ${maxEncodedDataSize}` 531 ); 532 533 if (maxEncodedDataSize === 0) { 534 throw new lazy.error.InvalidArgumentError( 535 `Expected "maxEncodedDataSize" to be greater than 0, got ${maxEncodedDataSize}` 536 ); 537 } 538 539 if (maxEncodedDataSize > lazy.maxTotalDataSize) { 540 throw new lazy.error.InvalidArgumentError( 541 `Expected "maxEncodedDataSize" to be less than the max total data size available (${lazy.maxTotalDataSize}), got ${maxEncodedDataSize}` 542 ); 543 } 544 545 lazy.assert.isNonEmptyArray( 546 dataTypes, 547 `Expected "dataTypes" to be a non-empty array, got ${dataTypes}` 548 ); 549 550 const supportedDataTypes = Object.values(DataType); 551 for (const dataType of dataTypes) { 552 if (!supportedDataTypes.includes(dataType)) { 553 throw new lazy.error.InvalidArgumentError( 554 `Expected "dataTypes" values to be one of ${supportedDataTypes},` + 555 lazy.pprint` got ${dataType}` 556 ); 557 } 558 } 559 560 const supportedCollectorTypes = Object.values(CollectorType); 561 if (!supportedCollectorTypes.includes(collectorType)) { 562 throw new lazy.error.InvalidArgumentError( 563 `Expected "collectorType" to be one of ${supportedCollectorTypes},` + 564 lazy.pprint` got ${collectorType}` 565 ); 566 } 567 568 const navigables = new Set(); 569 const userContexts = new Set(); 570 if (contextIds !== null) { 571 lazy.assert.isNonEmptyArray( 572 contextIds, 573 lazy.pprint`Expected "contexts" to be a non-empty array, got ${contextIds}` 574 ); 575 576 for (const contextId of contextIds) { 577 lazy.assert.string( 578 contextId, 579 lazy.pprint`Expected elements of "contexts" to be a string, got ${contextId}` 580 ); 581 const context = this._getNavigable(contextId); 582 583 lazy.assert.topLevel( 584 context, 585 lazy.pprint`Browsing context with id ${contextId} is not top-level` 586 ); 587 588 navigables.add(contextId); 589 } 590 } 591 592 if (userContextIds !== null) { 593 lazy.assert.isNonEmptyArray( 594 userContextIds, 595 lazy.pprint`Expected "userContexts" to be a non-empty array, got ${userContextIds}` 596 ); 597 598 for (const userContextId of userContextIds) { 599 lazy.assert.string( 600 userContextId, 601 lazy.pprint`Expected elements of "userContexts" to be a string, got ${userContextId}` 602 ); 603 604 const internalId = 605 lazy.UserContextManager.getInternalIdById(userContextId); 606 607 if (internalId === null) { 608 throw new lazy.error.NoSuchUserContextError( 609 `User context with id: ${userContextId} doesn't exist` 610 ); 611 } 612 613 userContexts.add(userContextId); 614 } 615 } 616 617 if (contextIds !== null && userContextIds !== null) { 618 throw new lazy.error.InvalidArgumentError( 619 `Providing both "contexts" and "userContexts" arguments is not supported` 620 ); 621 } 622 623 // Generate a unique collector ID 624 const collectorId = lazy.generateUUID(); 625 626 const collector = { 627 collector: collectorId, 628 collectorType, 629 contexts: navigables, 630 dataTypes, 631 maxEncodedDataSize, 632 userContexts, 633 }; 634 635 this.#networkCollectors.set(collectorId, collector); 636 637 return { 638 collector: collectorId, 639 }; 640 } 641 642 /** 643 * Adds a network intercept, which allows to intercept and modify network 644 * requests and responses. 645 * 646 * The network intercept will be created for the provided phases 647 * (InterceptPhase) and for specific url patterns. When a network event 648 * corresponding to an intercept phase has a URL which matches any url pattern 649 * of any intercept, the request will be suspended. 650 * 651 * @param {object=} options 652 * @param {Array<string>=} options.contexts 653 * The list of browsing context ids where this intercept should be used. 654 * Optional, defaults to null. 655 * @param {Array<InterceptPhase>} options.phases 656 * The phases where this intercept should be checked. 657 * @param {Array<URLPattern>=} options.urlPatterns 658 * The URL patterns for this intercept. Optional, defaults to empty array. 659 * 660 * @returns {object} 661 * An object with the following property: 662 * - intercept {string} The unique id of the network intercept. 663 * 664 * @throws {InvalidArgumentError} 665 * Raised if an argument is of an invalid type or value. 666 */ 667 addIntercept(options = {}) { 668 const { contexts = null, phases, urlPatterns = [] } = options; 669 670 if (contexts !== null) { 671 lazy.assert.isNonEmptyArray( 672 contexts, 673 `Expected "contexts" to be a non-empty array, got ${contexts}` 674 ); 675 676 for (const contextId of contexts) { 677 lazy.assert.string( 678 contextId, 679 `Expected elements of "contexts" to be a string, got ${contextId}` 680 ); 681 const context = this._getNavigable(contextId); 682 683 lazy.assert.topLevel( 684 context, 685 lazy.pprint`Browsing context with id ${contextId} is not top-level` 686 ); 687 } 688 } 689 690 lazy.assert.isNonEmptyArray( 691 phases, 692 `Expected "phases" to be a non-empty array, got ${phases}` 693 ); 694 695 const supportedInterceptPhases = Object.values(InterceptPhase); 696 for (const phase of phases) { 697 if (!supportedInterceptPhases.includes(phase)) { 698 throw new lazy.error.InvalidArgumentError( 699 `Expected "phases" values to be one of ${supportedInterceptPhases}, got ${phase}` 700 ); 701 } 702 } 703 704 lazy.assert.array( 705 urlPatterns, 706 `Expected "urlPatterns" to be an array, got ${urlPatterns}` 707 ); 708 709 const parsedPatterns = urlPatterns.map(urlPattern => 710 lazy.parseURLPattern(urlPattern) 711 ); 712 713 const interceptId = lazy.generateUUID(); 714 this.#interceptMap.set(interceptId, { 715 contexts, 716 phases, 717 urlPatterns: parsedPatterns, 718 }); 719 720 return { 721 intercept: interceptId, 722 }; 723 } 724 725 /** 726 * Continues a request that is blocked by a network intercept at the 727 * beforeRequestSent phase. 728 * 729 * @param {object=} options 730 * @param {string} options.request 731 * The id of the blocked request that should be continued. 732 * @param {BytesValue=} options.body 733 * Optional BytesValue to replace the body of the request. 734 * @param {Array<CookieHeader>=} options.cookies 735 * Optional array of cookie header values to replace the cookie header of 736 * the request. 737 * @param {Array<Header>=} options.headers 738 * Optional array of headers to replace the headers of the request. 739 * request. 740 * @param {string=} options.method 741 * Optional string to replace the method of the request. 742 * @param {string=} options.url 743 * Optional string to replace the url of the request. If the provided url 744 * is not a valid URL, an InvalidArgumentError will be thrown. 745 * 746 * @throws {InvalidArgumentError} 747 * Raised if an argument is of an invalid type or value. 748 * @throws {NoSuchRequestError} 749 * Raised if the request id does not match any request in the blocked 750 * requests map. 751 */ 752 async continueRequest(options = {}) { 753 const { 754 body = null, 755 cookies = null, 756 headers = null, 757 method = null, 758 url = null, 759 request: requestId, 760 } = options; 761 762 lazy.assert.string( 763 requestId, 764 `Expected "request" to be a string, got ${requestId}` 765 ); 766 767 if (body !== null) { 768 this.#assertBytesValue( 769 body, 770 lazy.truncate`Expected "body" to be a network.BytesValue, got ${body}` 771 ); 772 } 773 774 if (cookies !== null) { 775 lazy.assert.array( 776 cookies, 777 `Expected "cookies" to be an array got ${cookies}` 778 ); 779 780 for (const cookie of cookies) { 781 this.#assertHeader( 782 cookie, 783 `Expected values in "cookies" to be network.CookieHeader, got ${cookie}` 784 ); 785 } 786 } 787 788 let deserializedHeaders = []; 789 if (headers !== null) { 790 deserializedHeaders = this.#deserializeHeaders(headers); 791 } 792 793 if (method !== null) { 794 lazy.assert.string( 795 method, 796 `Expected "method" to be a string, got ${method}` 797 ); 798 lazy.assert.that( 799 value => this.#isValidHttpToken(value), 800 `Expected "method" to be a valid HTTP token, got ${method}` 801 )(method); 802 } 803 804 if (url !== null) { 805 lazy.assert.string(url, `Expected "url" to be a string, got ${url}`); 806 807 if (!URL.canParse(url)) { 808 throw new lazy.error.InvalidArgumentError( 809 `Expected "url" to be a valid URL, got ${url}` 810 ); 811 } 812 } 813 814 if (!this.#blockedRequests.has(requestId)) { 815 throw new lazy.error.NoSuchRequestError( 816 `Blocked request with id ${requestId} not found` 817 ); 818 } 819 820 const { phase, request, resolveBlockedEvent } = 821 this.#blockedRequests.get(requestId); 822 823 if (phase !== InterceptPhase.BeforeRequestSent) { 824 throw new lazy.error.InvalidArgumentError( 825 `Expected blocked request to be in "beforeRequestSent" phase, got ${phase}` 826 ); 827 } 828 829 if (method !== null) { 830 request.setRequestMethod(method); 831 } 832 833 if (headers !== null) { 834 // Delete all existing request headers. 835 request.headers.forEach(([name]) => { 836 request.clearRequestHeader(name); 837 }); 838 839 // Set all headers specified in the headers parameter. 840 for (const [name, value] of deserializedHeaders) { 841 request.setRequestHeader(name, value, { merge: true }); 842 } 843 } 844 845 if (cookies !== null) { 846 let cookieHeader = ""; 847 for (const cookie of cookies) { 848 if (cookieHeader != "") { 849 cookieHeader += ";"; 850 } 851 cookieHeader += this.#serializeCookieHeader(cookie); 852 } 853 854 let foundCookieHeader = false; 855 for (const [name] of request.headers) { 856 if (name.toLowerCase() == "cookie") { 857 // If there is already a cookie header, use merge: false to override 858 // the value. 859 request.setRequestHeader(name, cookieHeader, { merge: false }); 860 foundCookieHeader = true; 861 break; 862 } 863 } 864 865 if (!foundCookieHeader) { 866 request.setRequestHeader("Cookie", cookieHeader, { merge: false }); 867 } 868 } 869 870 if (body !== null) { 871 const value = deserializeBytesValue(body); 872 request.setRequestBody(value); 873 } 874 875 if (url !== null) { 876 // Store the requestId in the redirectedRequests set to skip the extra 877 // beforeRequestSent event. 878 this.#redirectedRequests.add(requestId); 879 request.redirectTo(url); 880 } 881 882 request.wrappedChannel.resume(); 883 884 resolveBlockedEvent(); 885 } 886 887 /** 888 * Continues a response that is blocked by a network intercept at the 889 * responseStarted or authRequired phase. 890 * 891 * @param {object=} options 892 * @param {string} options.request 893 * The id of the blocked request that should be continued. 894 * @param {Array<SetCookieHeader>=} options.cookies 895 * Optional array of set-cookie header values to replace the set-cookie 896 * headers of the response. 897 * @param {AuthCredentials=} options.credentials 898 * Optional AuthCredentials to use. 899 * @param {Array<Header>=} options.headers 900 * Optional array of header values to replace the headers of the response. 901 * @param {string=} options.reasonPhrase 902 * Optional string to replace the status message of the response. 903 * @param {number=} options.statusCode 904 * Optional number to replace the status code of the response. 905 * 906 * @throws {InvalidArgumentError} 907 * Raised if an argument is of an invalid type or value. 908 * @throws {NoSuchRequestError} 909 * Raised if the request id does not match any request in the blocked 910 * requests map. 911 */ 912 async continueResponse(options = {}) { 913 const { 914 cookies = null, 915 credentials = null, 916 headers = null, 917 reasonPhrase = null, 918 request: requestId, 919 statusCode = null, 920 } = options; 921 922 lazy.assert.string( 923 requestId, 924 `Expected "request" to be a string, got ${requestId}` 925 ); 926 927 if (cookies !== null) { 928 lazy.assert.array( 929 cookies, 930 `Expected "cookies" to be an array got ${cookies}` 931 ); 932 933 for (const cookie of cookies) { 934 this.#assertSetCookieHeader(cookie); 935 } 936 } 937 938 if (credentials !== null) { 939 this.#assertAuthCredentials(credentials); 940 } 941 942 let deserializedHeaders = []; 943 if (headers !== null) { 944 // For existing responses, are unable to update some response headers, 945 // so we skip them for the time being and log a warning. 946 // Bug 1914351 should remove this limitation. 947 deserializedHeaders = this.#deserializeHeaders(headers).filter( 948 ([name]) => { 949 if (IMMUTABLE_RESPONSE_HEADERS.includes(name.toLowerCase())) { 950 lazy.logger.warn( 951 `network.continueResponse cannot currently modify the header "${name}", skipping (see Bug 1914351).` 952 ); 953 return false; 954 } 955 return true; 956 } 957 ); 958 } 959 960 if (reasonPhrase !== null) { 961 lazy.assert.string( 962 reasonPhrase, 963 `Expected "reasonPhrase" to be a string, got ${reasonPhrase}` 964 ); 965 } 966 967 if (statusCode !== null) { 968 lazy.assert.positiveInteger( 969 statusCode, 970 `Expected "statusCode" to be a positive integer, got ${statusCode}` 971 ); 972 } 973 974 if (!this.#blockedRequests.has(requestId)) { 975 throw new lazy.error.NoSuchRequestError( 976 `Blocked request with id ${requestId} not found` 977 ); 978 } 979 980 const { authCallbacks, phase, request, resolveBlockedEvent, response } = 981 this.#blockedRequests.get(requestId); 982 983 if (headers !== null) { 984 // Delete all existing response headers. 985 response.headers 986 .filter( 987 ([name]) => 988 // All headers in IMMUTABLE_RESPONSE_HEADERS cannot be changed and 989 // will lead to a NS_ERROR_ILLEGAL_VALUE error. 990 // Bug 1914351 should remove this limitation. 991 !IMMUTABLE_RESPONSE_HEADERS.includes(name.toLowerCase()) 992 ) 993 .forEach(([name]) => response.clearResponseHeader(name)); 994 995 for (const [name, value] of deserializedHeaders) { 996 response.setResponseHeader(name, value, { merge: true }); 997 } 998 } 999 1000 if (cookies !== null) { 1001 for (const cookie of cookies) { 1002 const headerValue = this.#serializeSetCookieHeader(cookie); 1003 response.setResponseHeader("Set-Cookie", headerValue, { merge: true }); 1004 } 1005 } 1006 1007 if (statusCode !== null || reasonPhrase !== null) { 1008 response.setResponseStatus({ 1009 status: statusCode, 1010 statusText: reasonPhrase, 1011 }); 1012 } 1013 1014 if ( 1015 phase !== InterceptPhase.ResponseStarted && 1016 phase !== InterceptPhase.AuthRequired 1017 ) { 1018 throw new lazy.error.InvalidArgumentError( 1019 `Expected blocked request to be in "responseStarted" or "authRequired" phase, got ${phase}` 1020 ); 1021 } 1022 1023 if (phase === InterceptPhase.AuthRequired) { 1024 // Requests blocked in the AuthRequired phase should be resumed using 1025 // authCallbacks. 1026 if (credentials !== null) { 1027 await authCallbacks.provideAuthCredentials( 1028 credentials.username, 1029 credentials.password 1030 ); 1031 } else { 1032 await authCallbacks.provideAuthCredentials(); 1033 } 1034 } else { 1035 request.wrappedChannel.resume(); 1036 } 1037 1038 resolveBlockedEvent(); 1039 } 1040 1041 /** 1042 * Continues a response that is blocked by a network intercept at the 1043 * authRequired phase. 1044 * 1045 * @param {object=} options 1046 * @param {string} options.request 1047 * The id of the blocked request that should be continued. 1048 * @param {string} options.action 1049 * The continueWithAuth action, one of ContinueWithAuthAction. 1050 * @param {AuthCredentials=} options.credentials 1051 * The credentials to use for the ContinueWithAuthAction.ProvideCredentials 1052 * action. 1053 * 1054 * @throws {InvalidArgumentError} 1055 * Raised if an argument is of an invalid type or value. 1056 * @throws {NoSuchRequestError} 1057 * Raised if the request id does not match any request in the blocked 1058 * requests map. 1059 */ 1060 async continueWithAuth(options = {}) { 1061 const { action, credentials, request: requestId } = options; 1062 1063 lazy.assert.string( 1064 requestId, 1065 `Expected "request" to be a string, got ${requestId}` 1066 ); 1067 1068 if (!Object.values(ContinueWithAuthAction).includes(action)) { 1069 throw new lazy.error.InvalidArgumentError( 1070 `Expected "action" to be one of ${Object.values( 1071 ContinueWithAuthAction 1072 )} got ${action}` 1073 ); 1074 } 1075 1076 if (action == ContinueWithAuthAction.ProvideCredentials) { 1077 this.#assertAuthCredentials(credentials); 1078 } 1079 1080 if (!this.#blockedRequests.has(requestId)) { 1081 throw new lazy.error.NoSuchRequestError( 1082 `Blocked request with id ${requestId} not found` 1083 ); 1084 } 1085 1086 const { authCallbacks, phase, resolveBlockedEvent } = 1087 this.#blockedRequests.get(requestId); 1088 1089 if (phase !== InterceptPhase.AuthRequired) { 1090 throw new lazy.error.InvalidArgumentError( 1091 `Expected blocked request to be in "authRequired" phase, got ${phase}` 1092 ); 1093 } 1094 1095 switch (action) { 1096 case ContinueWithAuthAction.Cancel: { 1097 authCallbacks.cancelAuthPrompt(); 1098 break; 1099 } 1100 case ContinueWithAuthAction.Default: { 1101 authCallbacks.forwardAuthPrompt(); 1102 break; 1103 } 1104 case ContinueWithAuthAction.ProvideCredentials: { 1105 await authCallbacks.provideAuthCredentials( 1106 credentials.username, 1107 credentials.password 1108 ); 1109 1110 break; 1111 } 1112 } 1113 1114 resolveBlockedEvent(); 1115 } 1116 1117 /** 1118 * Releases a collected network data for a given collector and data type. 1119 * 1120 * @param {object=} options 1121 * @param {string} options.collector 1122 * The collector from which the data should be disowned. 1123 * @param {string} options.dataType 1124 * The data type of the data to disown. 1125 * @param {string} options.request 1126 * The id of the request for which data should be disowned. 1127 * 1128 * @throws {InvalidArgumentError} 1129 * Raised if an argument is of an invalid type or value. 1130 * @throws {NoSuchNetworkCollectorError} 1131 * Raised if the collector id could not be found in the internal collectors 1132 * map. 1133 * @throws {NoSuchNetworkDataError} 1134 * If the network data could not be found for the provided parameters. 1135 */ 1136 async disownData(options = {}) { 1137 const { collector, dataType, request: requestId } = options; 1138 1139 lazy.assert.string( 1140 requestId, 1141 lazy.pprint`Expected "request" to be a string, got ${requestId}` 1142 ); 1143 1144 const supportedDataTypes = Object.values(DataType); 1145 if (!supportedDataTypes.includes(dataType)) { 1146 throw new lazy.error.InvalidArgumentError( 1147 `Expected "dataType" to be one of ${supportedDataTypes},` + 1148 lazy.pprint` got ${dataType}` 1149 ); 1150 } 1151 1152 lazy.assert.string( 1153 collector, 1154 lazy.pprint`Expected "collector" to be a string, got ${collector}` 1155 ); 1156 1157 if (!this.#networkCollectors.has(collector)) { 1158 throw new lazy.error.NoSuchNetworkCollectorError( 1159 `Network data collector with id ${collector} not found` 1160 ); 1161 } 1162 1163 const collectedData = this.#getCollectedData(requestId, dataType); 1164 if (!collectedData) { 1165 throw new lazy.error.NoSuchNetworkDataError( 1166 `Network data for request id ${requestId} and DataType ${dataType} not found` 1167 ); 1168 } 1169 1170 if (!collectedData.collectors.has(collector)) { 1171 throw new lazy.error.NoSuchNetworkDataError( 1172 `Network data for request id ${requestId} and DataType ${dataType} does not match collector ${collector}` 1173 ); 1174 } 1175 1176 this.#removeCollectorFromData(collectedData, collector); 1177 } 1178 1179 /** 1180 * An object that holds information about a network data content. 1181 * 1182 * @typedef NetworkGetDataResult 1183 * 1184 * @property {BytesValue} bytes 1185 * The network data content as BytesValue. 1186 */ 1187 1188 /** 1189 * Retrieve a network data if available. 1190 * 1191 * @param {object} options 1192 * @param {string=} options.collector 1193 * Optional id of a collector. If provided, data will only be returned if 1194 * the collector is in the network data collectors. 1195 * @param {DataType} options.dataType 1196 * The type of the data to retrieve. 1197 * @param {boolean=} options.disown 1198 * Optional. If set to true, the collector parameter is mandatory and the 1199 * collector will be removed from the network data collectors. Defaults to 1200 * false. 1201 * @param {string} options.request 1202 * The id of the request of the data to retrieve. 1203 * 1204 * @returns {NetworkGetDataResult} 1205 * 1206 * @throws {InvalidArgumentError} 1207 * Raised if an argument is of an invalid type or value. 1208 * @throws {NoSuchNetworkCollectorError} 1209 * Raised if the collector id could not be found in the internal collectors 1210 * map. 1211 * @throws {NoSuchNetworkDataError} 1212 * If the network data could not be found for the provided parameters. 1213 * @throws {UnavailableNetworkDataError} 1214 * If the network data content is no longer available because it was 1215 * evicted. 1216 */ 1217 async getData(options = {}) { 1218 const { 1219 collector = null, 1220 dataType, 1221 disown = null, 1222 request: requestId, 1223 } = options; 1224 1225 lazy.assert.string( 1226 requestId, 1227 lazy.pprint`Expected "request" to be a string, got ${requestId}` 1228 ); 1229 1230 const supportedDataTypes = Object.values(DataType); 1231 if (!supportedDataTypes.includes(dataType)) { 1232 throw new lazy.error.InvalidArgumentError( 1233 `Expected "dataType" to be one of ${supportedDataTypes},` + 1234 lazy.pprint` got ${dataType}` 1235 ); 1236 } 1237 1238 if (collector !== null) { 1239 lazy.assert.string( 1240 collector, 1241 lazy.pprint`Expected "collector" to be a string, got ${collector}` 1242 ); 1243 1244 if (!this.#networkCollectors.has(collector)) { 1245 throw new lazy.error.NoSuchNetworkCollectorError( 1246 `Network data collector with id ${collector} not found` 1247 ); 1248 } 1249 } 1250 1251 if (disown !== null) { 1252 lazy.assert.boolean( 1253 disown, 1254 lazy.pprint`Expected "disown" to be a boolean, got ${disown}` 1255 ); 1256 1257 if (disown && collector === null) { 1258 throw new lazy.error.InvalidArgumentError( 1259 `Expected "collector" to be provided when using "disown"=true` 1260 ); 1261 } 1262 } 1263 1264 const collectedData = this.#getCollectedData(requestId, dataType); 1265 if (!collectedData) { 1266 throw new lazy.error.NoSuchNetworkDataError( 1267 `Network data for request id ${requestId} and DataType ${dataType} not found` 1268 ); 1269 } 1270 1271 if (collectedData.pending) { 1272 await collectedData.networkDataCollected.promise; 1273 } 1274 1275 if (collector !== null && !collectedData.collectors.has(collector)) { 1276 throw new lazy.error.NoSuchNetworkDataError( 1277 `Network data for request id ${requestId} and DataType ${dataType} does not match collector ${collector}` 1278 ); 1279 } 1280 1281 if (collectedData.bytes === null) { 1282 const reason = collectedData.unavailableReason; 1283 throw new lazy.error.UnavailableNetworkDataError( 1284 `Network data content for request id ${requestId} and DataType ${dataType} is unavailable (reason: ${reason})` 1285 ); 1286 } 1287 1288 const value = await collectedData.bytes.getBytesValue(); 1289 const type = collectedData.bytes.isBase64 1290 ? BytesValueType.Base64 1291 : BytesValueType.String; 1292 1293 if (disown) { 1294 this.#removeCollectorFromData(collectedData, collector); 1295 } 1296 1297 return { bytes: this.#serializeAsBytesValue(value, type) }; 1298 } 1299 1300 /** 1301 * Fails a request that is blocked by a network intercept. 1302 * 1303 * @param {object=} options 1304 * @param {string} options.request 1305 * The id of the blocked request that should be continued. 1306 * 1307 * @throws {InvalidArgumentError} 1308 * Raised if an argument is of an invalid type or value. 1309 * @throws {NoSuchRequestError} 1310 * Raised if the request id does not match any request in the blocked 1311 * requests map. 1312 */ 1313 async failRequest(options = {}) { 1314 const { request: requestId } = options; 1315 1316 lazy.assert.string( 1317 requestId, 1318 `Expected "request" to be a string, got ${requestId}` 1319 ); 1320 1321 if (!this.#blockedRequests.has(requestId)) { 1322 throw new lazy.error.NoSuchRequestError( 1323 `Blocked request with id ${requestId} not found` 1324 ); 1325 } 1326 1327 const { phase, request, resolveBlockedEvent } = 1328 this.#blockedRequests.get(requestId); 1329 1330 if (phase === InterceptPhase.AuthRequired) { 1331 throw new lazy.error.InvalidArgumentError( 1332 `Expected blocked request not to be in "authRequired" phase` 1333 ); 1334 } 1335 1336 request.wrappedChannel.resume(); 1337 request.wrappedChannel.cancel( 1338 Cr.NS_ERROR_ABORT, 1339 Ci.nsILoadInfo.BLOCKING_REASON_WEBDRIVER_BIDI 1340 ); 1341 1342 resolveBlockedEvent(); 1343 } 1344 1345 /** 1346 * Continues a request that’s blocked by a network intercept, by providing a 1347 * complete response. 1348 * 1349 * @param {object=} options 1350 * @param {string} options.request 1351 * The id of the blocked request for which the response should be 1352 * provided. 1353 * @param {BytesValue=} options.body 1354 * Optional BytesValue to replace the body of the response. 1355 * For now, only supported for requests blocked in beforeRequestSent. 1356 * @param {Array<SetCookieHeader>=} options.cookies 1357 * Optional array of set-cookie header values to use for the provided 1358 * response. 1359 * For now, only supported for requests blocked in beforeRequestSent. 1360 * @param {Array<Header>=} options.headers 1361 * Optional array of header values to use for the provided 1362 * response. 1363 * For now, only supported for requests blocked in beforeRequestSent. 1364 * @param {string=} options.reasonPhrase 1365 * Optional string to use as the status message for the provided response. 1366 * For now, only supported for requests blocked in beforeRequestSent. 1367 * @param {number=} options.statusCode 1368 * Optional number to use as the status code for the provided response. 1369 * For now, only supported for requests blocked in beforeRequestSent. 1370 * 1371 * @throws {InvalidArgumentError} 1372 * Raised if an argument is of an invalid type or value. 1373 * @throws {NoSuchRequestError} 1374 * Raised if the request id does not match any request in the blocked 1375 * requests map. 1376 */ 1377 async provideResponse(options = {}) { 1378 const { 1379 body = null, 1380 cookies = null, 1381 headers = null, 1382 reasonPhrase = null, 1383 request: requestId, 1384 statusCode = null, 1385 } = options; 1386 1387 lazy.assert.string( 1388 requestId, 1389 `Expected "request" to be a string, got ${requestId}` 1390 ); 1391 1392 if (body !== null) { 1393 this.#assertBytesValue( 1394 body, 1395 `Expected "body" to be a network.BytesValue, got ${body}` 1396 ); 1397 } 1398 1399 if (cookies !== null) { 1400 lazy.assert.array( 1401 cookies, 1402 `Expected "cookies" to be an array got ${cookies}` 1403 ); 1404 1405 for (const cookie of cookies) { 1406 this.#assertSetCookieHeader(cookie); 1407 } 1408 } 1409 1410 let deserializedHeaders = []; 1411 if (headers !== null) { 1412 deserializedHeaders = this.#deserializeHeaders(headers); 1413 } 1414 1415 if (reasonPhrase !== null) { 1416 lazy.assert.string( 1417 reasonPhrase, 1418 `Expected "reasonPhrase" to be a string, got ${reasonPhrase}` 1419 ); 1420 } 1421 1422 if (statusCode !== null) { 1423 lazy.assert.positiveInteger( 1424 statusCode, 1425 `Expected "statusCode" to be a positive integer, got ${statusCode}` 1426 ); 1427 } 1428 1429 if (!this.#blockedRequests.has(requestId)) { 1430 throw new lazy.error.NoSuchRequestError( 1431 `Blocked request with id ${requestId} not found` 1432 ); 1433 } 1434 1435 const { authCallbacks, phase, request, resolveBlockedEvent } = 1436 this.#blockedRequests.get(requestId); 1437 1438 // Handle optional arguments for the beforeRequestSent phase. 1439 // TODO: Support optional arguments in all phases, see Bug 1901055. 1440 if (phase === InterceptPhase.BeforeRequestSent) { 1441 // Create a new response. 1442 const replacedHttpResponse = Cc[ 1443 "@mozilla.org/network/replaced-http-response;1" 1444 ].createInstance(Ci.nsIReplacedHttpResponse); 1445 1446 if (statusCode !== null) { 1447 replacedHttpResponse.responseStatus = statusCode; 1448 } 1449 1450 if (reasonPhrase !== null) { 1451 replacedHttpResponse.responseStatusText = reasonPhrase; 1452 } 1453 1454 if (body !== null) { 1455 replacedHttpResponse.responseBody = deserializeBytesValue(body); 1456 } 1457 1458 if (headers !== null) { 1459 for (const [name, value] of deserializedHeaders) { 1460 replacedHttpResponse.setResponseHeader(name, value, true); 1461 } 1462 } 1463 1464 if (cookies !== null) { 1465 for (const cookie of cookies) { 1466 const headerValue = this.#serializeSetCookieHeader(cookie); 1467 replacedHttpResponse.setResponseHeader( 1468 "Set-Cookie", 1469 headerValue, 1470 true 1471 ); 1472 } 1473 } 1474 1475 request.setResponseOverride(replacedHttpResponse); 1476 request.wrappedChannel.resume(); 1477 } else { 1478 if (body !== null) { 1479 throw new lazy.error.UnsupportedOperationError( 1480 `The "body" parameter is only supported for the beforeRequestSent phase at the moment` 1481 ); 1482 } 1483 1484 if (cookies !== null) { 1485 throw new lazy.error.UnsupportedOperationError( 1486 `The "cookies" parameter is only supported for the beforeRequestSent phase at the moment` 1487 ); 1488 } 1489 1490 if (headers !== null) { 1491 throw new lazy.error.UnsupportedOperationError( 1492 `The "headers" parameter is only supported for the beforeRequestSent phase at the moment` 1493 ); 1494 } 1495 1496 if (reasonPhrase !== null) { 1497 throw new lazy.error.UnsupportedOperationError( 1498 `The "reasonPhrase" parameter is only supported for the beforeRequestSent phase at the moment` 1499 ); 1500 } 1501 1502 if (statusCode !== null) { 1503 throw new lazy.error.UnsupportedOperationError( 1504 `The "statusCode" parameter is only supported for the beforeRequestSent phase at the moment` 1505 ); 1506 } 1507 1508 if (phase === InterceptPhase.AuthRequired) { 1509 // AuthRequired with no optional argument, resume the authentication. 1510 await authCallbacks.provideAuthCredentials(); 1511 } else { 1512 // Any phase other than AuthRequired with no optional argument, resume the 1513 // request. 1514 request.wrappedChannel.resume(); 1515 } 1516 } 1517 1518 resolveBlockedEvent(); 1519 } 1520 1521 /** 1522 * Removes a data collector. 1523 * 1524 * @param {object=} options 1525 * @param {string} options.collector 1526 * The id of the collector to remove. 1527 * 1528 * @throws {InvalidArgumentError} 1529 * Raised if an argument is of an invalid type or value. 1530 * @throws {NoSuchNetworkCollectorError} 1531 * Raised if the collector id could not be found in the internal collectors 1532 * map. 1533 */ 1534 removeDataCollector(options = {}) { 1535 const { collector } = options; 1536 1537 lazy.assert.string( 1538 collector, 1539 lazy.pprint`Expected "collector" to be a string, got ${collector}` 1540 ); 1541 1542 if (!this.#networkCollectors.has(collector)) { 1543 throw new lazy.error.NoSuchNetworkCollectorError( 1544 `Network data collector with id ${collector} not found` 1545 ); 1546 } 1547 1548 this.#networkCollectors.delete(collector); 1549 1550 for (const [, collectedData] of this.#collectedNetworkData) { 1551 this.#removeCollectorFromData(collectedData, collector); 1552 } 1553 } 1554 1555 /** 1556 * Removes an existing network intercept. 1557 * 1558 * @param {object=} options 1559 * @param {string} options.intercept 1560 * The id of the intercept to remove. 1561 * 1562 * @throws {InvalidArgumentError} 1563 * Raised if an argument is of an invalid type or value. 1564 * @throws {NoSuchInterceptError} 1565 * Raised if the intercept id could not be found in the internal intercept 1566 * map. 1567 */ 1568 removeIntercept(options = {}) { 1569 const { intercept } = options; 1570 1571 lazy.assert.string( 1572 intercept, 1573 `Expected "intercept" to be a string, got ${intercept}` 1574 ); 1575 1576 if (!this.#interceptMap.has(intercept)) { 1577 throw new lazy.error.NoSuchInterceptError( 1578 `Network intercept with id ${intercept} not found` 1579 ); 1580 } 1581 1582 this.#interceptMap.delete(intercept); 1583 } 1584 1585 /** 1586 * Configures the network cache behavior for certain requests. 1587 * 1588 * @param {object=} options 1589 * @param {CacheBehavior} options.cacheBehavior 1590 * An enum value to set the network cache behavior. 1591 * @param {Array<string>=} options.contexts 1592 * The list of browsing context ids where the network cache 1593 * behavior should be updated. 1594 * 1595 * @throws {InvalidArgumentError} 1596 * Raised if an argument is of an invalid type or value. 1597 * @throws {NoSuchFrameError} 1598 * If the browsing context cannot be found. 1599 */ 1600 setCacheBehavior(options = {}) { 1601 const { cacheBehavior: behavior, contexts: contextIds = null } = options; 1602 1603 if (!Object.values(lazy.CacheBehavior).includes(behavior)) { 1604 throw new lazy.error.InvalidArgumentError( 1605 `Expected "cacheBehavior" to be one of ${Object.values( 1606 lazy.CacheBehavior 1607 )}` + lazy.pprint` got ${behavior}` 1608 ); 1609 } 1610 1611 if (contextIds === null) { 1612 // Set the default behavior if no specific context is specified. 1613 lazy.updateCacheBehavior(behavior); 1614 return; 1615 } 1616 1617 lazy.assert.isNonEmptyArray( 1618 contextIds, 1619 lazy.pprint`Expected "contexts" to be a non-empty array, got ${contextIds}` 1620 ); 1621 1622 const contexts = new Set(); 1623 for (const contextId of contextIds) { 1624 lazy.assert.string( 1625 contextId, 1626 lazy.pprint`Expected elements of "contexts" to be a string, got ${contextId}` 1627 ); 1628 const context = this._getNavigable(contextId); 1629 1630 lazy.assert.topLevel( 1631 context, 1632 lazy.pprint`Browsing context with id ${contextId} is not top-level` 1633 ); 1634 1635 contexts.add(context); 1636 } 1637 1638 lazy.updateCacheBehavior(behavior, contexts); 1639 } 1640 1641 /** 1642 * Allows to specify headers that will extend, or overwrite, existing request 1643 * headers. 1644 * 1645 * @param {object=} options 1646 * @param {Array<Header>} options.headers 1647 * Array of header values to replace the headers of the response. 1648 * @param {Array<string>=} options.contexts 1649 * Optional list of browsing context ids. 1650 * @param {Array<string>=} options.userContexts 1651 * Optional list of user context ids. 1652 * 1653 * @throws {InvalidArgumentError} 1654 * Raised if an argument is of an invalid type or value. 1655 * @throws {NoSuchFrameError} 1656 * If the browsing context cannot be found. 1657 */ 1658 setExtraHeaders(options = {}) { 1659 const { 1660 headers, 1661 contexts: contextIds = NULL, 1662 userContexts: userContextIds = NULL, 1663 } = options; 1664 1665 lazy.assert.array( 1666 headers, 1667 lazy.pprint`Expected "headers" to be an array, got ${headers}` 1668 ); 1669 1670 const deserializedHeaders = this.#deserializeHeaders(headers); 1671 1672 if (contextIds !== NULL && userContextIds !== NULL) { 1673 throw new lazy.error.InvalidArgumentError( 1674 `Providing both "contexts" and "userContexts" arguments is not supported` 1675 ); 1676 } 1677 1678 const navigables = new Set(); 1679 const userContexts = new Set(); 1680 if (userContextIds !== NULL) { 1681 lazy.assert.isNonEmptyArray( 1682 userContextIds, 1683 lazy.pprint`Expected "userContexts" to be a non-empty array, got ${userContextIds}` 1684 ); 1685 1686 for (const userContextId of userContextIds) { 1687 lazy.assert.string( 1688 userContextId, 1689 lazy.pprint`Expected elements of "userContexts" to be a string, got ${userContextId}` 1690 ); 1691 1692 const internalId = 1693 lazy.UserContextManager.getInternalIdById(userContextId); 1694 1695 if (internalId === null) { 1696 throw new lazy.error.NoSuchUserContextError( 1697 `User context with id: ${userContextId} doesn't exist` 1698 ); 1699 } 1700 1701 userContexts.add(userContextId); 1702 } 1703 } 1704 1705 if (contextIds !== NULL) { 1706 lazy.assert.isNonEmptyArray( 1707 contextIds, 1708 lazy.pprint`Expected "contexts" to be a non-empty array, got ${contextIds}` 1709 ); 1710 1711 for (const contextId of contextIds) { 1712 lazy.assert.string( 1713 contextId, 1714 lazy.pprint`Expected elements of "contexts" to be a string, got ${contextId}` 1715 ); 1716 const context = this._getNavigable(contextId); 1717 1718 lazy.assert.topLevel( 1719 context, 1720 lazy.pprint`Browsing context with id ${contextId} is not top-level` 1721 ); 1722 1723 navigables.add(contextId); 1724 } 1725 } 1726 1727 if (userContextIds !== NULL) { 1728 for (const userContextId of userContexts) { 1729 this.#extraHeaders.userContextHeaders.set( 1730 userContextId, 1731 deserializedHeaders 1732 ); 1733 } 1734 } else if (contextIds !== NULL) { 1735 for (const contextId of navigables) { 1736 const context = this._getNavigable(contextId); 1737 this.#extraHeaders.navigableHeaders.set( 1738 context.webProgress, 1739 deserializedHeaders 1740 ); 1741 } 1742 } else { 1743 this.#extraHeaders.defaultHeaders = deserializedHeaders; 1744 } 1745 1746 this.#networkListener.startListening(); 1747 } 1748 1749 /** 1750 * Add a new request in the blockedRequests map. 1751 * 1752 * @param {string} requestId 1753 * The request id. 1754 * @param {InterceptPhase} phase 1755 * The phase where the request is blocked. 1756 * @param {object=} options 1757 * @param {object=} options.authCallbacks 1758 * Only defined for requests blocked in the authRequired phase. 1759 * Provides callbacks to handle the authentication. 1760 * @param {nsIChannel=} options.requestChannel 1761 * The request channel. 1762 * @param {nsIChannel=} options.responseChannel 1763 * The response channel. 1764 */ 1765 #addBlockedRequest(requestId, phase, options = {}) { 1766 const { authCallbacks, request, response } = options; 1767 const { promise: blockedEventPromise, resolve: resolveBlockedEvent } = 1768 Promise.withResolvers(); 1769 1770 this.#blockedRequests.set(requestId, { 1771 authCallbacks, 1772 request, 1773 response, 1774 resolveBlockedEvent, 1775 phase, 1776 }); 1777 1778 blockedEventPromise.finally(() => { 1779 this.#blockedRequests.delete(requestId); 1780 }); 1781 } 1782 1783 /** 1784 * Implements https://w3c.github.io/webdriver-bidi/#allocate-size-to-record-data 1785 * 1786 * @param {number} size 1787 * The size to allocate in bytes. 1788 */ 1789 #allocateSizeToRecordData(size) { 1790 let availableSize = lazy.maxTotalDataSize; 1791 const alreadyCollectedData = []; 1792 for (const [, collectedData] of this.#collectedNetworkData) { 1793 if (collectedData.bytes !== null) { 1794 availableSize = availableSize - collectedData.size; 1795 alreadyCollectedData.push(collectedData); 1796 } 1797 } 1798 1799 if (size > availableSize) { 1800 for (const collectedData of alreadyCollectedData) { 1801 availableSize = availableSize + collectedData.size; 1802 collectedData.bytes = null; 1803 collectedData.unavailableReason = UNAVAILABLE_DATA_ERROR_REASON.Evicted; 1804 collectedData.size = null; 1805 1806 if (size <= availableSize) { 1807 return; 1808 } 1809 } 1810 } 1811 } 1812 1813 #assertAuthCredentials(credentials) { 1814 lazy.assert.object( 1815 credentials, 1816 `Expected "credentials" to be an object, got ${credentials}` 1817 ); 1818 1819 if (credentials.type !== "password") { 1820 throw new lazy.error.InvalidArgumentError( 1821 `Expected credentials "type" to be "password" got ${credentials.type}` 1822 ); 1823 } 1824 1825 lazy.assert.string( 1826 credentials.username, 1827 `Expected credentials "username" to be a string, got ${credentials.username}` 1828 ); 1829 lazy.assert.string( 1830 credentials.password, 1831 `Expected credentials "password" to be a string, got ${credentials.password}` 1832 ); 1833 } 1834 1835 #assertBytesValue(obj, msg) { 1836 lazy.assert.object(obj, msg); 1837 lazy.assert.string(obj.value, msg); 1838 lazy.assert.in(obj.type, Object.values(BytesValueType), msg); 1839 } 1840 1841 #assertHeader(value, msg) { 1842 lazy.assert.object(value, msg); 1843 lazy.assert.string(value.name, msg); 1844 this.#assertBytesValue(value.value, msg); 1845 } 1846 1847 #assertSetCookieHeader(setCookieHeader) { 1848 lazy.assert.object( 1849 setCookieHeader, 1850 `Expected set-cookie header to be an object, got ${setCookieHeader}` 1851 ); 1852 1853 const { 1854 name, 1855 value, 1856 domain = null, 1857 httpOnly = null, 1858 expiry = null, 1859 maxAge = null, 1860 path = null, 1861 sameSite = null, 1862 secure = null, 1863 } = setCookieHeader; 1864 1865 lazy.assert.string( 1866 name, 1867 `Expected set-cookie header "name" to be a string, got ${name}` 1868 ); 1869 1870 this.#assertBytesValue( 1871 value, 1872 `Expected set-cookie header "value" to be a BytesValue, got ${name}` 1873 ); 1874 1875 if (domain !== null) { 1876 lazy.assert.string( 1877 domain, 1878 `Expected set-cookie header "domain" to be a string, got ${domain}` 1879 ); 1880 } 1881 if (httpOnly !== null) { 1882 lazy.assert.boolean( 1883 httpOnly, 1884 `Expected set-cookie header "httpOnly" to be a boolean, got ${httpOnly}` 1885 ); 1886 } 1887 if (expiry !== null) { 1888 lazy.assert.string( 1889 expiry, 1890 `Expected set-cookie header "expiry" to be a string, got ${expiry}` 1891 ); 1892 } 1893 if (maxAge !== null) { 1894 lazy.assert.integer( 1895 maxAge, 1896 `Expected set-cookie header "maxAge" to be an integer, got ${maxAge}` 1897 ); 1898 } 1899 if (path !== null) { 1900 lazy.assert.string( 1901 path, 1902 `Expected set-cookie header "path" to be a string, got ${path}` 1903 ); 1904 } 1905 if (sameSite !== null) { 1906 lazy.assert.in( 1907 sameSite, 1908 Object.values(SameSite), 1909 `Expected set-cookie header "sameSite" to be one of ${Object.values( 1910 SameSite 1911 )}, got ${sameSite}` 1912 ); 1913 } 1914 if (secure !== null) { 1915 lazy.assert.boolean( 1916 secure, 1917 `Expected set-cookie header "secure" to be a boolean, got ${secure}` 1918 ); 1919 } 1920 } 1921 1922 #cloneNetworkRequestBody(request) { 1923 if (!this.#networkCollectors.size) { 1924 return; 1925 } 1926 1927 // If request body is missing or null, do not store any collected data. 1928 if (!request.postData || request.postData === null) { 1929 return; 1930 } 1931 1932 const collectedData = { 1933 bytes: null, 1934 collectors: new Set(), 1935 pending: true, 1936 // This allows to implement the await/resume on "network data collected" 1937 // described in the specification. 1938 networkDataCollected: Promise.withResolvers(), 1939 request: request.requestId, 1940 size: null, 1941 type: DataType.Request, 1942 }; 1943 1944 // The actual cloning is already handled by the DevTools 1945 // NetworkResponseListener, here we just have to prepare the networkData and 1946 // add it to the array. 1947 this.#collectedNetworkData.set( 1948 `${request.requestId}-${DataType.Request}`, 1949 collectedData 1950 ); 1951 } 1952 1953 #cloneNetworkResponseBody(request) { 1954 if (!this.#networkCollectors.size) { 1955 return; 1956 } 1957 1958 const collectedData = { 1959 bytes: null, 1960 // The cloned body is fully handled by DevTools' NetworkResponseListener 1961 // so it will not explicitly be stored here. 1962 collectors: new Set(), 1963 pending: true, 1964 // This allows to implement the await/resume on "network data collected" 1965 // described in the specification. 1966 networkDataCollected: Promise.withResolvers(), 1967 request: request.requestId, 1968 size: null, 1969 type: DataType.Response, 1970 // Internal string used in the UnavailableNetworkData error message. 1971 unavailableReason: null, 1972 }; 1973 1974 // The actual cloning is already handled by the DevTools 1975 // NetworkResponseListener, here we just have to prepare the networkData and 1976 // add it to the array. 1977 this.#collectedNetworkData.set( 1978 `${request.requestId}-${DataType.Response}`, 1979 collectedData 1980 ); 1981 } 1982 1983 #deserializeHeader(protocolHeader) { 1984 const name = protocolHeader.name; 1985 const value = deserializeBytesValue(protocolHeader.value); 1986 return [name, value]; 1987 } 1988 1989 #deserializeHeaders(headers) { 1990 const deserializedHeaders = []; 1991 lazy.assert.array( 1992 headers, 1993 lazy.pprint`Expected "headers" to be an array got ${headers}` 1994 ); 1995 1996 for (const header of headers) { 1997 this.#assertHeader( 1998 header, 1999 lazy.pprint`Expected values in "headers" to be network.Header, got ${header}` 2000 ); 2001 2002 // Deserialize headers immediately to validate the value 2003 const deserializedHeader = this.#deserializeHeader(header); 2004 lazy.assert.that( 2005 value => this.#isValidHttpToken(value), 2006 lazy.pprint`Expected "header" name to be a valid HTTP token, got ${deserializedHeader[0]}` 2007 )(deserializedHeader[0]); 2008 lazy.assert.that( 2009 value => this.#isValidHeaderValue(value), 2010 lazy.pprint`Expected "header" value to be a valid header value, got ${deserializedHeader[1]}` 2011 )(deserializedHeader[1]); 2012 2013 deserializedHeaders.push(deserializedHeader); 2014 } 2015 2016 return deserializedHeaders; 2017 } 2018 2019 #extractChallenges(response) { 2020 let headerName; 2021 2022 // Using case-insensitive match for header names, so we use the lowercase 2023 // version of the "WWW-Authenticate" / "Proxy-Authenticate" strings. 2024 if (response.status === 401) { 2025 headerName = "www-authenticate"; 2026 } else if (response.status === 407) { 2027 headerName = "proxy-authenticate"; 2028 } else { 2029 return null; 2030 } 2031 2032 const challenges = []; 2033 for (const [name, value] of response.headers) { 2034 if (name.toLowerCase() === headerName) { 2035 // A single header can contain several challenges. 2036 const headerChallenges = lazy.parseChallengeHeader(value); 2037 for (const headerChallenge of headerChallenges) { 2038 const realmParam = headerChallenge.params.find( 2039 param => param.name == "realm" 2040 ); 2041 const realm = realmParam ? realmParam.value : undefined; 2042 const challenge = { 2043 scheme: headerChallenge.scheme, 2044 realm, 2045 }; 2046 challenges.push(challenge); 2047 } 2048 } 2049 } 2050 2051 return challenges; 2052 } 2053 2054 #getCollectedData(requestId, dataType) { 2055 return this.#collectedNetworkData.get(`${requestId}-${dataType}`) || null; 2056 } 2057 2058 #getNetworkIntercepts(event, request, topContextId) { 2059 if (!request.supportsInterception) { 2060 // For requests which do not support interception (such as data URIs or 2061 // cached resources), do not attempt to match intercepts. 2062 return []; 2063 } 2064 2065 const intercepts = []; 2066 2067 let phase; 2068 switch (event) { 2069 case "network.beforeRequestSent": 2070 phase = InterceptPhase.BeforeRequestSent; 2071 break; 2072 case "network.responseStarted": 2073 phase = InterceptPhase.ResponseStarted; 2074 break; 2075 case "network.authRequired": 2076 phase = InterceptPhase.AuthRequired; 2077 break; 2078 case "network.responseCompleted": 2079 // The network.responseCompleted event does not match any interception 2080 // phase. Return immediately. 2081 return intercepts; 2082 } 2083 2084 const url = request.serializedURL; 2085 for (const [interceptId, intercept] of this.#interceptMap) { 2086 if ( 2087 intercept.contexts !== null && 2088 !intercept.contexts.includes(topContextId) 2089 ) { 2090 // Skip this intercept if the event's context does not match the list 2091 // of contexts for this intercept. 2092 continue; 2093 } 2094 2095 if (intercept.phases.includes(phase)) { 2096 const urlPatterns = intercept.urlPatterns; 2097 if ( 2098 !urlPatterns.length || 2099 urlPatterns.some(pattern => lazy.matchURLPattern(pattern, url)) 2100 ) { 2101 intercepts.push(interceptId); 2102 } 2103 } 2104 } 2105 2106 return intercepts; 2107 } 2108 2109 #getRequestData(request) { 2110 const requestId = request.requestId; 2111 2112 // "Let url be the result of running the URL serializer with request’s URL" 2113 // request.serializedURL is already serialized. 2114 const url = request.serializedURL; 2115 const method = request.method; 2116 2117 const bodySize = request.postDataSize; 2118 const headersSize = request.headersSize; 2119 const headers = []; 2120 const cookies = []; 2121 2122 for (const [name, value] of request.headers) { 2123 headers.push(this.#serializeHeader(name, value)); 2124 if (name.toLowerCase() == "cookie") { 2125 // TODO: Retrieve the actual cookies from the cookie store. 2126 const headerCookies = value.split(";"); 2127 for (const cookie of headerCookies) { 2128 const equal = cookie.indexOf("="); 2129 const cookieName = cookie.substr(0, equal); 2130 const cookieValue = cookie.substr(equal + 1); 2131 const serializedCookie = this.#serializeHeader( 2132 unescape(cookieName.trim()), 2133 unescape(cookieValue.trim()) 2134 ); 2135 cookies.push(serializedCookie); 2136 } 2137 } 2138 } 2139 2140 const destination = request.destination; 2141 const initiatorType = request.initiatorType; 2142 const timings = request.timings; 2143 2144 return { 2145 request: requestId, 2146 url, 2147 method, 2148 bodySize, 2149 headersSize, 2150 headers, 2151 cookies, 2152 destination, 2153 initiatorType, 2154 timings, 2155 }; 2156 } 2157 2158 #getResponseContentInfo(response) { 2159 return { 2160 size: response.decodedBodySize, 2161 }; 2162 } 2163 2164 #getResponseData(response) { 2165 const url = response.serializedURL; 2166 const protocol = response.protocol; 2167 const status = response.status; 2168 const statusText = response.statusMessage; 2169 // TODO: Ideally we should have a `isCacheStateLocal` getter 2170 // const fromCache = response.isCacheStateLocal(); 2171 const fromCache = response.fromCache; 2172 const mimeType = response.mimeType; 2173 const headers = []; 2174 for (const [name, value] of response.headers) { 2175 headers.push(this.#serializeHeader(name, value)); 2176 } 2177 2178 const bytesReceived = response.totalTransmittedSize; 2179 const headersSize = response.headersTransmittedSize; 2180 const bodySize = response.encodedBodySize; 2181 const content = this.#getResponseContentInfo(response); 2182 const authChallenges = this.#extractChallenges(response); 2183 2184 const params = { 2185 url, 2186 protocol, 2187 status, 2188 statusText, 2189 fromCache, 2190 headers, 2191 mimeType, 2192 bytesReceived, 2193 headersSize, 2194 bodySize, 2195 content, 2196 }; 2197 2198 if (authChallenges !== null) { 2199 params.authChallenges = authChallenges; 2200 } 2201 2202 return params; 2203 } 2204 2205 #getSuspendMarkerText(requestData, phase) { 2206 return `Request (id: ${requestData.request}) suspended by WebDriver BiDi in ${phase} phase`; 2207 } 2208 2209 #isValidHeaderValue(value) { 2210 if (!value.length) { 2211 return true; 2212 } 2213 2214 // For non-empty strings check against: 2215 // - leading or trailing tabs & spaces 2216 // - new lines and null bytes 2217 const chars = value.split(""); 2218 const tabOrSpace = [" ", "\t"]; 2219 const forbiddenChars = ["\r", "\n", "\0"]; 2220 return ( 2221 !tabOrSpace.includes(chars.at(0)) && 2222 !tabOrSpace.includes(chars.at(-1)) && 2223 forbiddenChars.every(c => !chars.includes(c)) 2224 ); 2225 } 2226 2227 /** 2228 * This helper is adapted from a C++ validation helper in nsHttp.cpp. 2229 * 2230 * @see https://searchfox.org/mozilla-central/rev/445a6e86233c733c5557ef44e1d33444adaddefc/netwerk/protocol/http/nsHttp.cpp#169 2231 */ 2232 #isValidHttpToken(token) { 2233 // prettier-ignore 2234 // This array corresponds to all char codes between 0 and 127, which is the 2235 // range of supported char codes for HTTP tokens. Within this range, 2236 // accepted char codes are marked with a 1, forbidden char codes with a 0. 2237 const validTokenMap = [ 2238 0, 0, 0, 0, 0, 0, 0, 0, // 0 2239 0, 0, 0, 0, 0, 0, 0, 0, // 8 2240 0, 0, 0, 0, 0, 0, 0, 0, // 16 2241 0, 0, 0, 0, 0, 0, 0, 0, // 24 2242 2243 0, 1, 0, 1, 1, 1, 1, 1, // 32 2244 0, 0, 1, 1, 0, 1, 1, 0, // 40 2245 1, 1, 1, 1, 1, 1, 1, 1, // 48 2246 1, 1, 0, 0, 0, 0, 0, 0, // 56 2247 2248 0, 1, 1, 1, 1, 1, 1, 1, // 64 2249 1, 1, 1, 1, 1, 1, 1, 1, // 72 2250 1, 1, 1, 1, 1, 1, 1, 1, // 80 2251 1, 1, 1, 0, 0, 0, 1, 1, // 88 2252 2253 1, 1, 1, 1, 1, 1, 1, 1, // 96 2254 1, 1, 1, 1, 1, 1, 1, 1, // 104 2255 1, 1, 1, 1, 1, 1, 1, 1, // 112 2256 1, 1, 1, 0, 1, 0, 1, 0 // 120 2257 ]; 2258 2259 if (!token.length) { 2260 return false; 2261 } 2262 return token 2263 .split("") 2264 .map(s => s.charCodeAt(0)) 2265 .every(c => validTokenMap[c]); 2266 } 2267 2268 /** 2269 * Implements https://w3c.github.io/webdriver-bidi/#match-collector-for-navigable 2270 * 2271 * @param {Collector} collector 2272 * The collector to match 2273 * @param {BrowsingContext} navigable 2274 * The navigable (BrowsingContext) to match 2275 * @returns {boolean} 2276 * True if the collector corresponds to the provided navigable. False 2277 * otherwise. 2278 */ 2279 #matchCollectorForNavigable(collector, navigable) { 2280 if (collector.contexts.size) { 2281 const navigableId = 2282 lazy.NavigableManager.getIdForBrowsingContext(navigable); 2283 return collector.contexts.has(navigableId); 2284 } 2285 2286 if (collector.userContexts.size) { 2287 const userContext = 2288 lazy.UserContextManager.getIdByBrowsingContext(navigable); 2289 return collector.userContexts.has(userContext); 2290 } 2291 2292 // Return true. 2293 return true; 2294 } 2295 2296 /** 2297 * Implements https://w3c.github.io/webdriver-bidi/#maybe-abort-network-response-body-collection 2298 * 2299 * @param {NetworkRequest} request 2300 * The request object for which we want to abort the body collection. 2301 */ 2302 #maybeAbortNetworkResponseBodyCollection(request) { 2303 const collectedData = this.#getCollectedData( 2304 request.requestId, 2305 DataType.Response 2306 ); 2307 if (collectedData === null) { 2308 return; 2309 } 2310 2311 lazy.logger.trace( 2312 `Network data not collected for request "${request.requestId}" and data type "${DataType.Response}"` + 2313 `: fetch error` 2314 ); 2315 collectedData.pending = false; 2316 collectedData.unavailableReason = UNAVAILABLE_DATA_ERROR_REASON.Aborted; 2317 collectedData.networkDataCollected.resolve(); 2318 } 2319 2320 /** 2321 * Implements https://w3c.github.io/webdriver-bidi/#maybe-collect-network-request-body 2322 * 2323 * @param {NetworkRequest} request 2324 * The request object for which we want to collect the body. 2325 */ 2326 async #maybeCollectNetworkRequestBody(request) { 2327 const collectedData = this.#getCollectedData( 2328 request.requestId, 2329 DataType.Request 2330 ); 2331 2332 if (collectedData === null) { 2333 return; 2334 } 2335 2336 this.#maybeCollectNetworkData({ 2337 collectedData, 2338 dataType: DataType.Request, 2339 request, 2340 readAndProcessBodyFn: request.readAndProcessRequestBody, 2341 size: request.postDataSize, 2342 }); 2343 } 2344 2345 /** 2346 * Implements https://www.w3.org/TR/webdriver-bidi/#maybe-collect-network-response-body 2347 * 2348 * @param {NetworkRequest} request 2349 * The request object for which we want to collect the body. 2350 * @param {NetworkResponse} response 2351 * The response object for which we want to collect the body. 2352 */ 2353 async #maybeCollectNetworkResponseBody(request, response) { 2354 if (response.willRedirect) { 2355 return; 2356 } 2357 2358 const collectedData = this.#getCollectedData( 2359 request.requestId, 2360 DataType.Response 2361 ); 2362 2363 if (collectedData === null) { 2364 return; 2365 } 2366 2367 if (!(response instanceof lazy.NetworkResponse) && !response.isDataURL) { 2368 lazy.logger.trace( 2369 `Network data not collected for request "${request.requestId}" and data type "${DataType.Response}"` + 2370 `: unsupported response (read from memory cache)` 2371 ); 2372 // Cached stencils do not return any response body. 2373 collectedData.pending = false; 2374 collectedData.networkDataCollected.resolve(); 2375 this.#collectedNetworkData.delete( 2376 `${collectedData.request}-${collectedData.type}` 2377 ); 2378 return; 2379 } 2380 2381 let readAndProcessBodyFn, size; 2382 if (response.isDataURL) { 2383 // Handle data URLs as a special case since the response is not provided 2384 // by the DevTools ResponseListener in this case. 2385 const url = request.serializedURL; 2386 const body = url.substring(url.indexOf(",") + 1); 2387 const isText = 2388 response.mimeType && 2389 lazy.NetworkHelper.isTextMimeType(response.mimeType); 2390 2391 readAndProcessBodyFn = () => 2392 new lazy.NetworkDataBytes({ 2393 getBytesValue: () => body, 2394 isBase64: !isText, 2395 }); 2396 size = body.length; 2397 } else { 2398 readAndProcessBodyFn = response.readAndProcessResponseBody; 2399 size = response.encodedBodySize; 2400 } 2401 2402 this.#maybeCollectNetworkData({ 2403 collectedData, 2404 dataType: DataType.Response, 2405 request, 2406 readAndProcessBodyFn, 2407 size, 2408 }); 2409 } 2410 2411 /** 2412 * Implements https://www.w3.org/TR/webdriver-bidi/#maybe-collect-network-data 2413 * 2414 * @param {object} options 2415 * @param {Data} options.collectedData 2416 * @param {DataType} options.dataType 2417 * @param {NetworkRequest} options.request 2418 * @param {Function} options.readAndProcessBodyFn 2419 * @param {number} options.size 2420 */ 2421 async #maybeCollectNetworkData(options) { 2422 const { 2423 collectedData, 2424 dataType, 2425 request, 2426 // Note: this parameter is not present in 2427 // https://www.w3.org/TR/webdriver-bidi/#maybe-collect-network-data 2428 // Each caller is responsible for providing a callable which will return 2429 // a NetworkDataBytes instance corresponding to the collected data. 2430 readAndProcessBodyFn, 2431 // Note: the spec assumes that in some cases the size can be computed 2432 // dynamically. But in practice we might be storing encoding data in a 2433 // format which makes it hard to get the size. So here we always expect 2434 // callers to provide a size. 2435 size, 2436 } = options; 2437 2438 const browsingContext = lazy.NavigableManager.getBrowsingContextById( 2439 request.contextId 2440 ); 2441 if (!browsingContext) { 2442 lazy.logger.trace( 2443 `Network data not collected for request "${request.requestId}" and data type "${dataType}"` + 2444 `: navigable no longer available` 2445 ); 2446 collectedData.pending = false; 2447 this.#collectedNetworkData.delete( 2448 `${collectedData.request}-${collectedData.type}` 2449 ); 2450 collectedData.networkDataCollected.resolve(); 2451 return; 2452 } 2453 2454 const topNavigable = browsingContext.top; 2455 let collectors = []; 2456 for (const [, collector] of this.#networkCollectors) { 2457 if ( 2458 collector.dataTypes.includes(dataType) && 2459 this.#matchCollectorForNavigable(collector, topNavigable) 2460 ) { 2461 collectors.push(collector); 2462 } 2463 } 2464 2465 if (!collectors.length) { 2466 lazy.logger.trace( 2467 `Network data not collected for request "${request.requestId}" and data type "${dataType}"` + 2468 `: no matching collector` 2469 ); 2470 collectedData.pending = false; 2471 this.#collectedNetworkData.delete( 2472 `${collectedData.request}-${collectedData.type}` 2473 ); 2474 collectedData.networkDataCollected.resolve(); 2475 return; 2476 } 2477 2478 let bytes = null; 2479 2480 // At this point, the specification expects to processBody for the cloned 2481 // body. Here we do not explicitly clone the bodies. 2482 // For responses, DevTools' NetworkResponseListener clones the stream. 2483 // For requests, NetworkHelper.readPostTextFromRequest clones the stream on 2484 // the fly to read it as text. 2485 try { 2486 const bytesOrNull = await readAndProcessBodyFn(); 2487 if (bytesOrNull !== null) { 2488 bytes = bytesOrNull; 2489 } 2490 } catch { 2491 // Let processBodyError be this step: Do nothing. 2492 } 2493 2494 // If the network module was destroyed while waiting to read the response 2495 // body, the session has been destroyed. Resolve the promise and bail out. 2496 if (!this.#collectedNetworkData) { 2497 collectedData.networkDataCollected.resolve(); 2498 return; 2499 } 2500 2501 if (bytes !== null) { 2502 for (const collector of collectors) { 2503 if (size <= collector.maxEncodedDataSize) { 2504 collectedData.collectors.add(collector.collector); 2505 } 2506 } 2507 2508 if (collectedData.collectors.size) { 2509 this.#allocateSizeToRecordData(size); 2510 collectedData.bytes = bytes; 2511 collectedData.size = size; 2512 } 2513 } 2514 2515 // Note: specification flips `collectedData.pending` to false earlier, but 2516 // the implementation is async with `await response.readResponseBody()`. 2517 // `collectedData.pending` is only flipped before returning here - and in 2518 // early returns above. 2519 collectedData.pending = false; 2520 if (!collectedData.collectors.size) { 2521 this.#collectedNetworkData.delete( 2522 `${collectedData.request}-${collectedData.type}` 2523 ); 2524 } 2525 collectedData.networkDataCollected.resolve(); 2526 } 2527 2528 #onAuthRequired = (name, data) => { 2529 const { authCallbacks, request, response } = data; 2530 2531 let isBlocked = false; 2532 try { 2533 const browsingContext = lazy.NavigableManager.getBrowsingContextById( 2534 request.contextId 2535 ); 2536 if (!browsingContext) { 2537 // Do not emit events if the context id does not match any existing 2538 // browsing context. 2539 return; 2540 } 2541 2542 const protocolEventName = "network.authRequired"; 2543 2544 const isListening = this._hasListener(protocolEventName, { 2545 contextId: browsingContext.id, 2546 }); 2547 if (!isListening) { 2548 // If there are no listeners subscribed to this event and this context, 2549 // bail out. 2550 return; 2551 } 2552 2553 const baseParameters = this.#processNetworkEvent( 2554 protocolEventName, 2555 request 2556 ); 2557 2558 const responseData = this.#getResponseData(response); 2559 const authRequiredEvent = { 2560 ...baseParameters, 2561 response: responseData, 2562 }; 2563 2564 this._emitEventForBrowsingContext( 2565 browsingContext.id, 2566 protocolEventName, 2567 authRequiredEvent 2568 ); 2569 2570 if (authRequiredEvent.isBlocked) { 2571 isBlocked = true; 2572 2573 // requestChannel.suspend() is not needed here because the request is 2574 // already blocked on the authentication prompt notification until 2575 // one of the authCallbacks is called. 2576 this.#addBlockedRequest( 2577 authRequiredEvent.request.request, 2578 InterceptPhase.AuthRequired, 2579 { 2580 authCallbacks, 2581 request, 2582 response, 2583 } 2584 ); 2585 } 2586 } finally { 2587 if (!isBlocked) { 2588 // If the request was not blocked, forward the auth prompt notification 2589 // to the next consumer. 2590 authCallbacks.forwardAuthPrompt(); 2591 } 2592 } 2593 }; 2594 2595 #onBeforeRequestSent = (name, data) => { 2596 const { request } = data; 2597 2598 if (this.#redirectedRequests.has(request.requestId)) { 2599 // If this beforeRequestSent event corresponds to a request that has 2600 // just been redirected using continueRequest, skip the event and remove 2601 // it from the redirectedRequests set. 2602 this.#redirectedRequests.delete(request.requestId); 2603 return; 2604 } 2605 2606 const browsingContext = lazy.NavigableManager.getBrowsingContextById( 2607 request.contextId 2608 ); 2609 if (!browsingContext) { 2610 // Do not emit events if the context id does not match any existing 2611 // browsing context. 2612 return; 2613 } 2614 2615 // Make sure a collected data is created for the request. 2616 // Note: this is supposed to be triggered from fetch and doesn't depend on 2617 // whether network events are used or not. 2618 this.#cloneNetworkRequestBody(request); 2619 2620 const relatedNavigables = [browsingContext]; 2621 this.#updateRequestHeaders(request, relatedNavigables); 2622 2623 const protocolEventName = "network.beforeRequestSent"; 2624 2625 const isListening = this._hasListener(protocolEventName, { 2626 contextId: browsingContext.id, 2627 }); 2628 if (!isListening) { 2629 // If there are no listeners subscribed to this event and this context, 2630 // bail out. 2631 return; 2632 } 2633 2634 this.#maybeCollectNetworkRequestBody(request); 2635 2636 const baseParameters = this.#processNetworkEvent( 2637 protocolEventName, 2638 request 2639 ); 2640 2641 // Bug 1805479: Handle the initiator, including stacktrace details. 2642 const initiator = { 2643 type: InitiatorType.Other, 2644 }; 2645 2646 const beforeRequestSentEvent = { 2647 ...baseParameters, 2648 initiator, 2649 }; 2650 2651 this._emitEventForBrowsingContext( 2652 browsingContext.id, 2653 protocolEventName, 2654 beforeRequestSentEvent 2655 ); 2656 if (beforeRequestSentEvent.isBlocked) { 2657 request.wrappedChannel.suspend( 2658 this.#getSuspendMarkerText(request, "beforeRequestSent") 2659 ); 2660 2661 this.#addBlockedRequest( 2662 beforeRequestSentEvent.request.request, 2663 InterceptPhase.BeforeRequestSent, 2664 { 2665 request, 2666 } 2667 ); 2668 } 2669 }; 2670 2671 #onFetchError = (name, data) => { 2672 const { request } = data; 2673 2674 const browsingContext = lazy.NavigableManager.getBrowsingContextById( 2675 request.contextId 2676 ); 2677 if (!browsingContext) { 2678 // Do not emit events if the context id does not match any existing 2679 // browsing context. 2680 return; 2681 } 2682 2683 const protocolEventName = "network.fetchError"; 2684 2685 const isListening = this._hasListener(protocolEventName, { 2686 contextId: browsingContext.id, 2687 }); 2688 if (!isListening) { 2689 // If there are no listeners subscribed to this event and this context, 2690 // bail out. 2691 return; 2692 } 2693 2694 this.#maybeAbortNetworkResponseBodyCollection(request); 2695 2696 const baseParameters = this.#processNetworkEvent( 2697 protocolEventName, 2698 request 2699 ); 2700 2701 const fetchErrorEvent = { 2702 ...baseParameters, 2703 errorText: request.errorText, 2704 }; 2705 2706 this._emitEventForBrowsingContext( 2707 browsingContext.id, 2708 protocolEventName, 2709 fetchErrorEvent 2710 ); 2711 }; 2712 2713 #onResponseEvent = async (name, data) => { 2714 const { request, response } = data; 2715 2716 const browsingContext = lazy.NavigableManager.getBrowsingContextById( 2717 request.contextId 2718 ); 2719 if (!browsingContext) { 2720 // Do not emit events if the context id does not match any existing 2721 // browsing context. 2722 return; 2723 } 2724 2725 const protocolEventName = 2726 name === "response-started" 2727 ? "network.responseStarted" 2728 : "network.responseCompleted"; 2729 2730 if (protocolEventName === "network.responseStarted") { 2731 this.#cloneNetworkResponseBody(request); 2732 } 2733 2734 const isListening = this._hasListener(protocolEventName, { 2735 contextId: browsingContext.id, 2736 }); 2737 if (!isListening) { 2738 // If there are no listeners subscribed to this event and this context, 2739 // bail out. 2740 return; 2741 } 2742 2743 if (protocolEventName === "network.responseCompleted") { 2744 this.#maybeCollectNetworkResponseBody(request, response); 2745 } 2746 2747 const baseParameters = this.#processNetworkEvent( 2748 protocolEventName, 2749 request 2750 ); 2751 2752 const responseData = this.#getResponseData(response); 2753 2754 const responseEvent = { 2755 ...baseParameters, 2756 response: responseData, 2757 }; 2758 2759 this._emitEventForBrowsingContext( 2760 browsingContext.id, 2761 protocolEventName, 2762 responseEvent 2763 ); 2764 2765 if ( 2766 protocolEventName === "network.responseStarted" && 2767 responseEvent.isBlocked && 2768 request.supportsInterception 2769 ) { 2770 request.wrappedChannel.suspend( 2771 this.#getSuspendMarkerText(request, "responseStarted") 2772 ); 2773 2774 this.#addBlockedRequest( 2775 responseEvent.request.request, 2776 InterceptPhase.ResponseStarted, 2777 { 2778 request, 2779 response, 2780 } 2781 ); 2782 } 2783 }; 2784 2785 #onUserContextDeleted = (name, data) => { 2786 const userContextId = data.userContextId; 2787 if (this.#extraHeaders.userContextHeaders.has(userContextId)) { 2788 this.#extraHeaders.userContextHeaders.delete(userContextId); 2789 } 2790 }; 2791 2792 #processNetworkEvent(event, request) { 2793 const requestData = this.#getRequestData(request); 2794 const navigation = request.navigationId; 2795 let contextId = null; 2796 let topContextId = null; 2797 if (request.contextId) { 2798 // Retrieve the top browsing context id for this network event. 2799 contextId = request.contextId; 2800 const browsingContext = 2801 lazy.NavigableManager.getBrowsingContextById(contextId); 2802 topContextId = lazy.NavigableManager.getIdForBrowsingContext( 2803 browsingContext.top 2804 ); 2805 } 2806 2807 const intercepts = this.#getNetworkIntercepts(event, request, topContextId); 2808 const redirectCount = request.redirectCount; 2809 const timestamp = Date.now(); 2810 const isBlocked = !!intercepts.length; 2811 const params = { 2812 context: contextId, 2813 isBlocked, 2814 navigation, 2815 redirectCount, 2816 request: requestData, 2817 timestamp, 2818 }; 2819 2820 if (isBlocked) { 2821 params.intercepts = intercepts; 2822 } 2823 2824 return params; 2825 } 2826 2827 /** 2828 * Implements https://w3c.github.io/webdriver-bidi/#remove-collector-from-data 2829 * 2830 * @param {Data} collectedData 2831 * The Data from which the collector should be removed. 2832 * @param {string} collectorId 2833 * The collector id to remove. 2834 */ 2835 #removeCollectorFromData(collectedData, collectorId) { 2836 if (collectedData.collectors.has(collectorId)) { 2837 collectedData.collectors.delete(collectorId); 2838 if (!collectedData.collectors.size) { 2839 this.#collectedNetworkData.delete( 2840 `${collectedData.request}-${collectedData.type}` 2841 ); 2842 } 2843 } 2844 } 2845 2846 #serializeCookieHeader(cookieHeader) { 2847 const name = cookieHeader.name; 2848 const value = deserializeBytesValue(cookieHeader.value); 2849 return `${name}=${value}`; 2850 } 2851 2852 #serializeHeader(name, value) { 2853 return { 2854 name, 2855 // TODO: For now, we handle all headers and cookies with the "string" type. 2856 // See Bug 1835216 to add support for "base64" type and handle non-utf8 2857 // values. 2858 value: this.#serializeAsBytesValue(value, BytesValueType.String), 2859 }; 2860 } 2861 2862 #serializeSetCookieHeader(setCookieHeader) { 2863 const { 2864 name, 2865 value, 2866 domain = null, 2867 httpOnly = null, 2868 expiry = null, 2869 maxAge = null, 2870 path = null, 2871 sameSite = null, 2872 secure = null, 2873 } = setCookieHeader; 2874 2875 let headerValue = `${name}=${deserializeBytesValue(value)}`; 2876 2877 if (expiry !== null) { 2878 headerValue += `;Expires=${expiry}`; 2879 } 2880 if (maxAge !== null) { 2881 headerValue += `;Max-Age=${maxAge}`; 2882 } 2883 if (domain !== null) { 2884 headerValue += `;Domain=${domain}`; 2885 } 2886 if (path !== null) { 2887 headerValue += `;Path=${path}`; 2888 } 2889 if (secure === true) { 2890 headerValue += `;Secure`; 2891 } 2892 if (httpOnly === true) { 2893 headerValue += `;HttpOnly`; 2894 } 2895 if (sameSite !== null) { 2896 headerValue += `;SameSite=${sameSite}`; 2897 } 2898 return headerValue; 2899 } 2900 2901 /** 2902 * Serialize a value as BytesValue. 2903 * 2904 * Note: This does not attempt to fully implement serialize protocol bytes 2905 * (https://w3c.github.io/webdriver-bidi/#serialize-protocol-bytes) as the 2906 * header values read from the Channel are already serialized as strings at 2907 * the moment. 2908 * 2909 * @param {string} value 2910 * The value to serialize. 2911 */ 2912 #serializeAsBytesValue(value, type) { 2913 return { 2914 type, 2915 value, 2916 }; 2917 } 2918 2919 #startListening(event) { 2920 if (this.#subscribedEvents.size == 0) { 2921 this.#networkListener.startListening(); 2922 } 2923 this.#subscribedEvents.add(event); 2924 } 2925 2926 #stopListening(event) { 2927 this.#subscribedEvents.delete(event); 2928 if (this.#subscribedEvents.size == 0) { 2929 this.#networkListener.stopListening(); 2930 } 2931 } 2932 2933 #subscribeEvent(event) { 2934 if (this.constructor.supportedEvents.includes(event)) { 2935 this.#startListening(event); 2936 } 2937 } 2938 2939 #unsubscribeEvent(event) { 2940 if (this.constructor.supportedEvents.includes(event)) { 2941 this.#stopListening(event); 2942 } 2943 } 2944 2945 /** 2946 * Implements https://w3c.github.io/webdriver-bidi/#update-headers 2947 */ 2948 #updateHeaders(request, headers) { 2949 for (const [name, value] of headers) { 2950 // Use merge: false to always override the value 2951 request.setRequestHeader(name, value, { merge: false }); 2952 } 2953 } 2954 2955 /** 2956 * Implements https://w3c.github.io/webdriver-bidi/#update-request-headers 2957 */ 2958 #updateRequestHeaders(request, navigables) { 2959 for (const browsingContext of navigables) { 2960 this.#updateHeaders(request, this.#extraHeaders.defaultHeaders); 2961 2962 const userContextHeaders = this.#extraHeaders.userContextHeaders; 2963 const userContext = 2964 lazy.UserContextManager.getIdByBrowsingContext(browsingContext); 2965 if (userContextHeaders.has(userContext)) { 2966 this.#updateHeaders(request, userContextHeaders.get(userContext)); 2967 } 2968 2969 const navigableHeaders = this.#extraHeaders.navigableHeaders; 2970 const topNavigableWebProgress = browsingContext.top.webProgress; 2971 if (navigableHeaders.has(topNavigableWebProgress)) { 2972 this.#updateHeaders( 2973 request, 2974 navigableHeaders.get(topNavigableWebProgress) 2975 ); 2976 } 2977 } 2978 } 2979 2980 /** 2981 * Internal commands 2982 */ 2983 2984 _applySessionData(params) { 2985 // TODO: Bug 1775231. Move this logic to a shared module or an abstract 2986 // class. 2987 const { category } = params; 2988 if (category === "event") { 2989 const filteredSessionData = params.sessionData.filter(item => 2990 this.messageHandler.matchesContext(item.contextDescriptor) 2991 ); 2992 for (const event of this.#subscribedEvents.values()) { 2993 const hasSessionItem = filteredSessionData.some( 2994 item => item.value === event 2995 ); 2996 // If there are no session items for this context, we should unsubscribe from the event. 2997 if (!hasSessionItem) { 2998 this.#unsubscribeEvent(event); 2999 } 3000 } 3001 3002 // Subscribe to all events, which have an item in SessionData. 3003 for (const { value } of filteredSessionData) { 3004 this.#subscribeEvent(value); 3005 } 3006 } 3007 } 3008 3009 _sendEventsForWindowGlobalNetworkResource(params) { 3010 this.#onBeforeRequestSent("before-request-sent", params); 3011 this.#onResponseEvent("response-started", params); 3012 this.#onResponseEvent("response-completed", params); 3013 } 3014 3015 _setDecodedBodySize(params) { 3016 const { channelId, decodedBodySize } = params; 3017 this.#decodedBodySizeMap.setDecodedBodySize(channelId, decodedBodySize); 3018 } 3019 3020 static get supportedEvents() { 3021 return [ 3022 "network.authRequired", 3023 "network.beforeRequestSent", 3024 "network.fetchError", 3025 "network.responseCompleted", 3026 "network.responseStarted", 3027 ]; 3028 } 3029 } 3030 3031 /** 3032 * Deserialize a network BytesValue. 3033 * 3034 * @param {BytesValue} bytesValue 3035 * The BytesValue to deserialize. 3036 * @returns {string} 3037 * The deserialized value. 3038 */ 3039 export function deserializeBytesValue(bytesValue) { 3040 const { type, value } = bytesValue; 3041 3042 if (type === BytesValueType.String) { 3043 return value; 3044 } 3045 3046 // For type === BytesValueType.Base64. 3047 return atob(value); 3048 } 3049 3050 export const network = NetworkModule;