diff options
Diffstat (limited to 'remote/webdriver-bidi/modules/root/network.sys.mjs')
-rw-r--r-- | remote/webdriver-bidi/modules/root/network.sys.mjs | 1730 |
1 files changed, 1730 insertions, 0 deletions
diff --git a/remote/webdriver-bidi/modules/root/network.sys.mjs b/remote/webdriver-bidi/modules/root/network.sys.mjs new file mode 100644 index 0000000000..238b9f3640 --- /dev/null +++ b/remote/webdriver-bidi/modules/root/network.sys.mjs @@ -0,0 +1,1730 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + matchURLPattern: + "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs", + notifyNavigationStarted: + "chrome://remote/content/shared/NavigationManager.sys.mjs", + NetworkListener: + "chrome://remote/content/shared/listeners/NetworkListener.sys.mjs", + parseChallengeHeader: + "chrome://remote/content/shared/ChallengeHeaderParser.sys.mjs", + parseURLPattern: + "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +/** + * @typedef {object} AuthChallenge + * @property {string} scheme + * @property {string} realm + */ + +/** + * @typedef {object} AuthCredentials + * @property {'password'} type + * @property {string} username + * @property {string} password + */ + +/** + * @typedef {object} BaseParameters + * @property {string=} context + * @property {Array<string>?} intercepts + * @property {boolean} isBlocked + * @property {Navigation=} navigation + * @property {number} redirectCount + * @property {RequestData} request + * @property {number} timestamp + */ + +/** + * @typedef {object} BlockedRequest + * @property {NetworkEventRecord} networkEventRecord + * @property {InterceptPhase} phase + */ + +/** + * Enum of possible BytesValue types. + * + * @readonly + * @enum {BytesValueType} + */ +export const BytesValueType = { + Base64: "base64", + String: "string", +}; + +/** + * @typedef {object} BytesValue + * @property {BytesValueType} type + * @property {string} value + */ + +/** + * Enum of possible continueWithAuth actions. + * + * @readonly + * @enum {ContinueWithAuthAction} + */ +const ContinueWithAuthAction = { + Cancel: "cancel", + Default: "default", + ProvideCredentials: "provideCredentials", +}; + +/** + * @typedef {object} Cookie + * @property {string} domain + * @property {number=} expires + * @property {boolean} httpOnly + * @property {string} name + * @property {string} path + * @property {SameSite} sameSite + * @property {boolean} secure + * @property {number} size + * @property {BytesValue} value + */ + +/** + * @typedef {object} CookieHeader + * @property {string} name + * @property {BytesValue} value + */ + +/** + * @typedef {object} FetchTimingInfo + * @property {number} timeOrigin + * @property {number} requestTime + * @property {number} redirectStart + * @property {number} redirectEnd + * @property {number} fetchStart + * @property {number} dnsStart + * @property {number} dnsEnd + * @property {number} connectStart + * @property {number} connectEnd + * @property {number} tlsStart + * @property {number} requestStart + * @property {number} responseStart + * @property {number} responseEnd + */ + +/** + * @typedef {object} Header + * @property {string} name + * @property {BytesValue} value + */ + +/** + * @typedef {string} InitiatorType + */ + +/** + * Enum of possible initiator types. + * + * @readonly + * @enum {InitiatorType} + */ +const InitiatorType = { + Other: "other", + Parser: "parser", + Preflight: "preflight", + Script: "script", +}; + +/** + * @typedef {object} Initiator + * @property {InitiatorType} type + * @property {number=} columnNumber + * @property {number=} lineNumber + * @property {string=} request + * @property {StackTrace=} stackTrace + */ + +/** + * Enum of intercept phases. + * + * @readonly + * @enum {InterceptPhase} + */ +const InterceptPhase = { + AuthRequired: "authRequired", + BeforeRequestSent: "beforeRequestSent", + ResponseStarted: "responseStarted", +}; + +/** + * @typedef {object} InterceptProperties + * @property {Array<InterceptPhase>} phases + * @property {Array<URLPattern>} urlPatterns + */ + +/** + * @typedef {object} RequestData + * @property {number|null} bodySize + * Defaults to null. + * @property {Array<Cookie>} cookies + * @property {Array<Header>} headers + * @property {number} headersSize + * @property {string} method + * @property {string} request + * @property {FetchTimingInfo} timings + * @property {string} url + */ + +/** + * @typedef {object} BeforeRequestSentParametersProperties + * @property {Initiator} initiator + */ + +/* eslint-disable jsdoc/valid-types */ +/** + * Parameters for the BeforeRequestSent event + * + * @typedef {BaseParameters & BeforeRequestSentParametersProperties} BeforeRequestSentParameters + */ +/* eslint-enable jsdoc/valid-types */ + +/** + * @typedef {object} ResponseContent + * @property {number|null} size + * Defaults to null. + */ + +/** + * @typedef {object} ResponseData + * @property {string} url + * @property {string} protocol + * @property {number} status + * @property {string} statusText + * @property {boolean} fromCache + * @property {Array<Header>} headers + * @property {string} mimeType + * @property {number} bytesReceived + * @property {number|null} headersSize + * Defaults to null. + * @property {number|null} bodySize + * Defaults to null. + * @property {ResponseContent} content + * @property {Array<AuthChallenge>=} authChallenges + */ + +/** + * @typedef {object} ResponseStartedParametersProperties + * @property {ResponseData} response + */ + +/* eslint-disable jsdoc/valid-types */ +/** + * Parameters for the ResponseStarted event + * + * @typedef {BaseParameters & ResponseStartedParametersProperties} ResponseStartedParameters + */ +/* eslint-enable jsdoc/valid-types */ + +/** + * @typedef {object} ResponseCompletedParametersProperties + * @property {ResponseData} response + */ + +/** + * Enum of possible sameSite values. + * + * @readonly + * @enum {SameSite} + */ +const SameSite = { + Lax: "lax", + None: "none", + Script: "script", +}; + +/** + * @typedef {object} SetCookieHeader + * @property {string} name + * @property {BytesValue} value + * @property {string=} domain + * @property {boolean=} httpOnly + * @property {string=} expiry + * @property {number=} maxAge + * @property {string=} path + * @property {SameSite=} sameSite + * @property {boolean=} secure + */ + +/** + * @typedef {object} URLPatternPattern + * @property {'pattern'} type + * @property {string=} protocol + * @property {string=} hostname + * @property {string=} port + * @property {string=} pathname + * @property {string=} search + */ + +/** + * @typedef {object} URLPatternString + * @property {'string'} type + * @property {string} pattern + */ + +/** + * @typedef {(URLPatternPattern|URLPatternString)} URLPattern + */ + +/* eslint-disable jsdoc/valid-types */ +/** + * Parameters for the ResponseCompleted event + * + * @typedef {BaseParameters & ResponseCompletedParametersProperties} ResponseCompletedParameters + */ +/* eslint-enable jsdoc/valid-types */ + +class NetworkModule extends Module { + #blockedRequests; + #interceptMap; + #networkListener; + #subscribedEvents; + + constructor(messageHandler) { + super(messageHandler); + + // Map of request id to BlockedRequest + this.#blockedRequests = new Map(); + + // Map of intercept id to InterceptProperties + this.#interceptMap = new Map(); + + // Set of event names which have active subscriptions + this.#subscribedEvents = new Set(); + + this.#networkListener = new lazy.NetworkListener(); + this.#networkListener.on("auth-required", this.#onAuthRequired); + this.#networkListener.on("before-request-sent", this.#onBeforeRequestSent); + this.#networkListener.on("fetch-error", this.#onFetchError); + this.#networkListener.on("response-completed", this.#onResponseEvent); + this.#networkListener.on("response-started", this.#onResponseEvent); + } + + destroy() { + this.#networkListener.off("auth-required", this.#onAuthRequired); + this.#networkListener.off("before-request-sent", this.#onBeforeRequestSent); + this.#networkListener.off("fetch-error", this.#onFetchError); + this.#networkListener.off("response-completed", this.#onResponseEvent); + this.#networkListener.off("response-started", this.#onResponseEvent); + this.#networkListener.destroy(); + + this.#blockedRequests = null; + this.#interceptMap = null; + this.#subscribedEvents = null; + } + + /** + * Adds a network intercept, which allows to intercept and modify network + * requests and responses. + * + * The network intercept will be created for the provided phases + * (InterceptPhase) and for specific url patterns. When a network event + * corresponding to an intercept phase has a URL which matches any url pattern + * of any intercept, the request will be suspended. + * + * @param {object=} options + * @param {Array<InterceptPhase>} options.phases + * The phases where this intercept should be checked. + * @param {Array<URLPattern>=} options.urlPatterns + * The URL patterns for this intercept. Optional, defaults to empty array. + * + * @returns {object} + * An object with the following property: + * - intercept {string} The unique id of the network intercept. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + */ + addIntercept(options = {}) { + const { phases, urlPatterns = [] } = options; + + lazy.assert.array( + phases, + `Expected "phases" to be an array, got ${phases}` + ); + + if (!options.phases.length) { + throw new lazy.error.InvalidArgumentError( + `Expected "phases" to contain at least one phase, got an empty array` + ); + } + + const supportedInterceptPhases = Object.values(InterceptPhase); + for (const phase of phases) { + if (!supportedInterceptPhases.includes(phase)) { + throw new lazy.error.InvalidArgumentError( + `Expected "phases" values to be one of ${supportedInterceptPhases}, got ${phase}` + ); + } + } + + lazy.assert.array( + urlPatterns, + `Expected "urlPatterns" to be an array, got ${urlPatterns}` + ); + + const parsedPatterns = urlPatterns.map(urlPattern => + lazy.parseURLPattern(urlPattern) + ); + + const interceptId = lazy.generateUUID(); + this.#interceptMap.set(interceptId, { + phases, + urlPatterns: parsedPatterns, + }); + + return { + intercept: interceptId, + }; + } + + /** + * Continues a request that is blocked by a network intercept at the + * beforeRequestSent phase. + * + * @param {object=} options + * @param {string} options.request + * The id of the blocked request that should be continued. + * @param {BytesValue=} options.body [unsupported] + * Optional BytesValue to replace the body of the request. + * @param {Array<CookieHeader>=} options.cookies [unsupported] + * Optional array of cookie header values to replace the cookie header of + * the request. + * @param {Array<Header>=} options.headers [unsupported] + * Optional array of headers to replace the headers of the request. + * request. + * @param {string=} options.method [unsupported] + * Optional string to replace the method of the request. + * @param {string=} options.url [unsupported] + * Optional string to replace the url of the request. If the provided url + * is not a valid URL, an InvalidArgumentError will be thrown. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchRequestError} + * Raised if the request id does not match any request in the blocked + * requests map. + */ + async continueRequest(options = {}) { + const { + body = null, + cookies = null, + headers = null, + method = null, + url = null, + request: requestId, + } = options; + + lazy.assert.string( + requestId, + `Expected "request" to be a string, got ${requestId}` + ); + + if (body !== null) { + this.#assertBytesValue( + body, + `Expected "body" to be a network.BytesValue, got ${body}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"body" not supported yet in network.continueRequest` + ); + } + + if (cookies !== null) { + lazy.assert.array( + cookies, + `Expected "cookies" to be an array got ${cookies}` + ); + + for (const cookie of cookies) { + this.#assertHeader( + cookie, + `Expected values in "cookies" to be network.CookieHeader, got ${cookie}` + ); + } + + throw new lazy.error.UnsupportedOperationError( + `"cookies" not supported yet in network.continueRequest` + ); + } + + if (headers !== null) { + lazy.assert.array( + headers, + `Expected "headers" to be an array got ${headers}` + ); + + for (const header of headers) { + this.#assertHeader( + header, + `Expected values in "headers" to be network.Header, got ${header}` + ); + } + + throw new lazy.error.UnsupportedOperationError( + `"headers" not supported yet in network.continueRequest` + ); + } + + if (method !== null) { + lazy.assert.string( + method, + `Expected "method" to be a string, got ${method}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"method" not supported yet in network.continueRequest` + ); + } + + if (url !== null) { + lazy.assert.string(url, `Expected "url" to be a string, got ${url}`); + + throw new lazy.error.UnsupportedOperationError( + `"url" not supported yet in network.continueRequest` + ); + } + + if (!this.#blockedRequests.has(requestId)) { + throw new lazy.error.NoSuchRequestError( + `Blocked request with id ${requestId} not found` + ); + } + + const { phase, request, resolveBlockedEvent } = + this.#blockedRequests.get(requestId); + + if (phase !== InterceptPhase.BeforeRequestSent) { + throw new lazy.error.InvalidArgumentError( + `Expected blocked request to be in "beforeRequestSent" phase, got ${phase}` + ); + } + + const wrapper = ChannelWrapper.get(request); + wrapper.resume(); + + resolveBlockedEvent(); + } + + /** + * Continues a response that is blocked by a network intercept at the + * responseStarted or authRequired phase. + * + * @param {object=} options + * @param {string} options.request + * The id of the blocked request that should be continued. + * @param {Array<SetCookieHeader>=} options.cookies [unsupported] + * Optional array of set-cookie header values to replace the set-cookie + * headers of the response. + * @param {AuthCredentials=} options.credentials + * Optional AuthCredentials to use. + * @param {Array<Header>=} options.headers [unsupported] + * Optional array of header values to replace the headers of the response. + * @param {string=} options.reasonPhrase [unsupported] + * Optional string to replace the status message of the response. + * @param {number=} options.statusCode [unsupported] + * Optional number to replace the status code of the response. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchRequestError} + * Raised if the request id does not match any request in the blocked + * requests map. + */ + async continueResponse(options = {}) { + const { + cookies = null, + credentials = null, + headers = null, + reasonPhrase = null, + request: requestId, + statusCode = null, + } = options; + + lazy.assert.string( + requestId, + `Expected "request" to be a string, got ${requestId}` + ); + + if (cookies !== null) { + lazy.assert.array( + cookies, + `Expected "cookies" to be an array got ${cookies}` + ); + + for (const cookie of cookies) { + this.#assertSetCookieHeader(cookie); + } + + throw new lazy.error.UnsupportedOperationError( + `"cookies" not supported yet in network.continueResponse` + ); + } + + if (credentials !== null) { + this.#assertAuthCredentials(credentials); + } + + if (headers !== null) { + lazy.assert.array( + headers, + `Expected "headers" to be an array got ${headers}` + ); + + for (const header of headers) { + this.#assertHeader( + header, + `Expected values in "headers" to be network.Header, got ${header}` + ); + } + + throw new lazy.error.UnsupportedOperationError( + `"headers" not supported yet in network.continueResponse` + ); + } + + if (reasonPhrase !== null) { + lazy.assert.string( + reasonPhrase, + `Expected "reasonPhrase" to be a string, got ${reasonPhrase}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"reasonPhrase" not supported yet in network.continueResponse` + ); + } + + if (statusCode !== null) { + lazy.assert.positiveInteger( + statusCode, + `Expected "statusCode" to be a positive integer, got ${statusCode}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"statusCode" not supported yet in network.continueResponse` + ); + } + + if (!this.#blockedRequests.has(requestId)) { + throw new lazy.error.NoSuchRequestError( + `Blocked request with id ${requestId} not found` + ); + } + + const { authCallbacks, phase, request, resolveBlockedEvent } = + this.#blockedRequests.get(requestId); + + if ( + phase !== InterceptPhase.ResponseStarted && + phase !== InterceptPhase.AuthRequired + ) { + throw new lazy.error.InvalidArgumentError( + `Expected blocked request to be in "responseStarted" or "authRequired" phase, got ${phase}` + ); + } + + if (phase === InterceptPhase.AuthRequired) { + // Requests blocked in the AuthRequired phase should be resumed using + // authCallbacks. + if (credentials !== null) { + await authCallbacks.provideAuthCredentials( + credentials.username, + credentials.password + ); + } else { + await authCallbacks.provideAuthCredentials(); + } + } else { + const wrapper = ChannelWrapper.get(request); + wrapper.resume(); + } + + resolveBlockedEvent(); + } + + /** + * Continues a response that is blocked by a network intercept at the + * authRequired phase. + * + * @param {object=} options + * @param {string} options.request + * The id of the blocked request that should be continued. + * @param {string} options.action + * The continueWithAuth action, one of ContinueWithAuthAction. + * @param {AuthCredentials=} options.credentials + * The credentials to use for the ContinueWithAuthAction.ProvideCredentials + * action. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchRequestError} + * Raised if the request id does not match any request in the blocked + * requests map. + */ + async continueWithAuth(options = {}) { + const { action, credentials, request: requestId } = options; + + lazy.assert.string( + requestId, + `Expected "request" to be a string, got ${requestId}` + ); + + if (!Object.values(ContinueWithAuthAction).includes(action)) { + throw new lazy.error.InvalidArgumentError( + `Expected "action" to be one of ${Object.values( + ContinueWithAuthAction + )} got ${action}` + ); + } + + if (action == ContinueWithAuthAction.ProvideCredentials) { + this.#assertAuthCredentials(credentials); + } + + if (!this.#blockedRequests.has(requestId)) { + throw new lazy.error.NoSuchRequestError( + `Blocked request with id ${requestId} not found` + ); + } + + const { authCallbacks, phase, resolveBlockedEvent } = + this.#blockedRequests.get(requestId); + + if (phase !== InterceptPhase.AuthRequired) { + throw new lazy.error.InvalidArgumentError( + `Expected blocked request to be in "authRequired" phase, got ${phase}` + ); + } + + switch (action) { + case ContinueWithAuthAction.Cancel: { + authCallbacks.cancelAuthPrompt(); + break; + } + case ContinueWithAuthAction.Default: { + authCallbacks.forwardAuthPrompt(); + break; + } + case ContinueWithAuthAction.ProvideCredentials: { + await authCallbacks.provideAuthCredentials( + credentials.username, + credentials.password + ); + + break; + } + } + + resolveBlockedEvent(); + } + + /** + * Fails a request that is blocked by a network intercept. + * + * @param {object=} options + * @param {string} options.request + * The id of the blocked request that should be continued. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchRequestError} + * Raised if the request id does not match any request in the blocked + * requests map. + */ + async failRequest(options = {}) { + const { request: requestId } = options; + + lazy.assert.string( + requestId, + `Expected "request" to be a string, got ${requestId}` + ); + + if (!this.#blockedRequests.has(requestId)) { + throw new lazy.error.NoSuchRequestError( + `Blocked request with id ${requestId} not found` + ); + } + + const { phase, request, resolveBlockedEvent } = + this.#blockedRequests.get(requestId); + + if (phase === InterceptPhase.AuthRequired) { + throw new lazy.error.InvalidArgumentError( + `Expected blocked request not to be in "authRequired" phase` + ); + } + + const wrapper = ChannelWrapper.get(request); + wrapper.resume(); + wrapper.cancel( + Cr.NS_ERROR_ABORT, + Ci.nsILoadInfo.BLOCKING_REASON_WEBDRIVER_BIDI + ); + + resolveBlockedEvent(); + } + + /** + * Continues a request that’s blocked by a network intercept, by providing a + * complete response. + * + * @param {object=} options + * @param {string} options.request + * The id of the blocked request for which the response should be + * provided. + * @param {BytesValue=} options.body [unsupported] + * Optional BytesValue to replace the body of the response. + * @param {Array<SetCookieHeader>=} options.cookies [unsupported] + * Optional array of set-cookie header values to use for the provided + * response. + * @param {Array<Header>=} options.headers [unsupported] + * Optional array of header values to use for the provided + * response. + * @param {string=} options.reasonPhrase [unsupported] + * Optional string to use as the status message for the provided response. + * @param {number=} options.statusCode [unsupported] + * Optional number to use as the status code for the provided response. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchRequestError} + * Raised if the request id does not match any request in the blocked + * requests map. + */ + async provideResponse(options = {}) { + const { + body = null, + cookies = null, + headers = null, + reasonPhrase = null, + request: requestId, + statusCode = null, + } = options; + + lazy.assert.string( + requestId, + `Expected "request" to be a string, got ${requestId}` + ); + + if (body !== null) { + this.#assertBytesValue( + body, + `Expected "body" to be a network.BytesValue, got ${body}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"body" not supported yet in network.provideResponse` + ); + } + + if (cookies !== null) { + lazy.assert.array( + cookies, + `Expected "cookies" to be an array got ${cookies}` + ); + + for (const cookie of cookies) { + this.#assertSetCookieHeader(cookie); + } + + throw new lazy.error.UnsupportedOperationError( + `"cookies" not supported yet in network.provideResponse` + ); + } + + if (headers !== null) { + lazy.assert.array( + headers, + `Expected "headers" to be an array got ${headers}` + ); + + for (const header of headers) { + this.#assertHeader( + header, + `Expected values in "headers" to be network.Header, got ${header}` + ); + } + + throw new lazy.error.UnsupportedOperationError( + `"headers" not supported yet in network.provideResponse` + ); + } + + if (reasonPhrase !== null) { + lazy.assert.string( + reasonPhrase, + `Expected "reasonPhrase" to be a string, got ${reasonPhrase}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"reasonPhrase" not supported yet in network.provideResponse` + ); + } + + if (statusCode !== null) { + lazy.assert.positiveInteger( + statusCode, + `Expected "statusCode" to be a positive integer, got ${statusCode}` + ); + + throw new lazy.error.UnsupportedOperationError( + `"statusCode" not supported yet in network.provideResponse` + ); + } + + if (!this.#blockedRequests.has(requestId)) { + throw new lazy.error.NoSuchRequestError( + `Blocked request with id ${requestId} not found` + ); + } + + const { authCallbacks, phase, request, resolveBlockedEvent } = + this.#blockedRequests.get(requestId); + + if (phase === InterceptPhase.AuthRequired) { + await authCallbacks.provideAuthCredentials(); + } else { + const wrapper = ChannelWrapper.get(request); + wrapper.resume(); + } + + resolveBlockedEvent(); + } + + /** + * Removes an existing network intercept. + * + * @param {object=} options + * @param {string} options.intercept + * The id of the intercept to remove. + * + * @throws {InvalidArgumentError} + * Raised if an argument is of an invalid type or value. + * @throws {NoSuchInterceptError} + * Raised if the intercept id could not be found in the internal intercept + * map. + */ + removeIntercept(options = {}) { + const { intercept } = options; + + lazy.assert.string( + intercept, + `Expected "intercept" to be a string, got ${intercept}` + ); + + if (!this.#interceptMap.has(intercept)) { + throw new lazy.error.NoSuchInterceptError( + `Network intercept with id ${intercept} not found` + ); + } + + this.#interceptMap.delete(intercept); + } + + /** + * Add a new request in the blockedRequests map. + * + * @param {string} requestId + * The request id. + * @param {InterceptPhase} phase + * The phase where the request is blocked. + * @param {object=} options + * @param {object=} options.authCallbacks + * Only defined for requests blocked in the authRequired phase. + * Provides callbacks to handle the authentication. + * @param {nsIChannel=} options.requestChannel + * The request channel. + * @param {nsIChannel=} options.responseChannel + * The response channel. + */ + #addBlockedRequest(requestId, phase, options = {}) { + const { + authCallbacks, + requestChannel: request, + responseChannel: response, + } = options; + const { promise: blockedEventPromise, resolve: resolveBlockedEvent } = + Promise.withResolvers(); + + this.#blockedRequests.set(requestId, { + authCallbacks, + request, + response, + resolveBlockedEvent, + phase, + }); + + blockedEventPromise.finally(() => { + this.#blockedRequests.delete(requestId); + }); + } + + #assertAuthCredentials(credentials) { + lazy.assert.object( + credentials, + `Expected "credentials" to be an object, got ${credentials}` + ); + + if (credentials.type !== "password") { + throw new lazy.error.InvalidArgumentError( + `Expected credentials "type" to be "password" got ${credentials.type}` + ); + } + + lazy.assert.string( + credentials.username, + `Expected credentials "username" to be a string, got ${credentials.username}` + ); + lazy.assert.string( + credentials.password, + `Expected credentials "password" to be a string, got ${credentials.password}` + ); + } + + #assertBytesValue(obj, msg) { + lazy.assert.object(obj, msg); + lazy.assert.string(obj.value, msg); + lazy.assert.in(obj.type, Object.values(BytesValueType), msg); + } + + #assertHeader(value, msg) { + lazy.assert.object(value, msg); + lazy.assert.string(value.name, msg); + this.#assertBytesValue(value.value, msg); + } + + #assertSetCookieHeader(setCookieHeader) { + lazy.assert.object( + setCookieHeader, + `Expected set-cookie header to be an object, got ${setCookieHeader}` + ); + + const { + name, + value, + domain = null, + httpOnly = null, + expiry = null, + maxAge = null, + path = null, + sameSite = null, + secure = null, + } = setCookieHeader; + + lazy.assert.string( + name, + `Expected set-cookie header "name" to be a string, got ${name}` + ); + + this.#assertBytesValue( + value, + `Expected set-cookie header "value" to be a BytesValue, got ${name}` + ); + + if (domain !== null) { + lazy.assert.string( + domain, + `Expected set-cookie header "domain" to be a string, got ${domain}` + ); + } + if (httpOnly !== null) { + lazy.assert.boolean( + httpOnly, + `Expected set-cookie header "httpOnly" to be a boolean, got ${httpOnly}` + ); + } + if (expiry !== null) { + lazy.assert.string( + expiry, + `Expected set-cookie header "expiry" to be a string, got ${expiry}` + ); + } + if (maxAge !== null) { + lazy.assert.integer( + maxAge, + `Expected set-cookie header "maxAge" to be an integer, got ${maxAge}` + ); + } + if (path !== null) { + lazy.assert.string( + path, + `Expected set-cookie header "path" to be a string, got ${path}` + ); + } + if (sameSite !== null) { + lazy.assert.in( + sameSite, + Object.values(SameSite), + `Expected set-cookie header "sameSite" to be one of ${Object.values( + SameSite + )}, got ${sameSite}` + ); + } + if (secure !== null) { + lazy.assert.boolean( + secure, + `Expected set-cookie header "secure" to be a boolean, got ${secure}` + ); + } + } + + #extractChallenges(responseData) { + let headerName; + + // Using case-insensitive match for header names, so we use the lowercase + // version of the "WWW-Authenticate" / "Proxy-Authenticate" strings. + if (responseData.status === 401) { + headerName = "www-authenticate"; + } else if (responseData.status === 407) { + headerName = "proxy-authenticate"; + } else { + return null; + } + + const challenges = []; + + for (const header of responseData.headers) { + if (header.name.toLowerCase() === headerName) { + // A single header can contain several challenges. + const headerChallenges = lazy.parseChallengeHeader(header.value); + for (const headerChallenge of headerChallenges) { + const realmParam = headerChallenge.params.find( + param => param.name == "realm" + ); + const realm = realmParam ? realmParam.value : undefined; + const challenge = { + scheme: headerChallenge.scheme, + realm, + }; + challenges.push(challenge); + } + } + } + + return challenges; + } + + #getContextInfo(browsingContext) { + return { + contextId: browsingContext.id, + type: lazy.WindowGlobalMessageHandler.type, + }; + } + + #getSuspendMarkerText(requestData, phase) { + return `Request (id: ${requestData.request}) suspended by WebDriver BiDi in ${phase} phase`; + } + + #getNetworkIntercepts(event, requestData) { + const intercepts = []; + + let phase; + switch (event) { + case "network.beforeRequestSent": + phase = InterceptPhase.BeforeRequestSent; + break; + case "network.responseStarted": + phase = InterceptPhase.ResponseStarted; + break; + case "network.authRequired": + phase = InterceptPhase.AuthRequired; + break; + case "network.responseCompleted": + // The network.responseCompleted event does not match any interception + // phase. Return immediately. + return intercepts; + } + + const url = requestData.url; + for (const [interceptId, intercept] of this.#interceptMap) { + if (intercept.phases.includes(phase)) { + const urlPatterns = intercept.urlPatterns; + if ( + !urlPatterns.length || + urlPatterns.some(pattern => lazy.matchURLPattern(pattern, url)) + ) { + intercepts.push(interceptId); + } + } + } + + return intercepts; + } + + #getNavigationId(eventName, isNavigationRequest, browsingContext, url) { + if (!isNavigationRequest) { + // Not a navigation request return null. + return null; + } + + let navigation = + this.messageHandler.navigationManager.getNavigationForBrowsingContext( + browsingContext + ); + + // `onBeforeRequestSent` might be too early for the NavigationManager. + // If there is no ongoing navigation, create one ourselves. + // TODO: Bug 1835704 to detect navigations earlier and avoid this. + if ( + eventName === "network.beforeRequestSent" && + (!navigation || navigation.finished) + ) { + navigation = lazy.notifyNavigationStarted({ + contextDetails: { context: browsingContext }, + url, + }); + } + + return navigation ? navigation.navigationId : null; + } + + #onAuthRequired = (name, data) => { + const { + authCallbacks, + contextId, + isNavigationRequest, + redirectCount, + requestChannel, + requestData, + responseChannel, + responseData, + timestamp, + } = data; + + let isBlocked = false; + try { + const browsingContext = lazy.TabManager.getBrowsingContextById(contextId); + if (!browsingContext) { + // Do not emit events if the context id does not match any existing + // browsing context. + return; + } + + const protocolEventName = "network.authRequired"; + + // Process the navigation to create potentially missing navigation ids + // before the early return below. + const navigation = this.#getNavigationId( + protocolEventName, + isNavigationRequest, + browsingContext, + requestData.url + ); + + const isListening = this.messageHandler.eventsDispatcher.hasListener( + protocolEventName, + { contextId } + ); + if (!isListening) { + // If there are no listeners subscribed to this event and this context, + // bail out. + return; + } + + const baseParameters = this.#processNetworkEvent(protocolEventName, { + contextId, + navigation, + redirectCount, + requestData, + timestamp, + }); + + const authRequiredEvent = this.#serializeNetworkEvent({ + ...baseParameters, + response: responseData, + }); + + const authChallenges = this.#extractChallenges(responseData); + // authChallenges should never be null for a request which triggered an + // authRequired event. + authRequiredEvent.response.authChallenges = authChallenges; + + this.emitEvent( + protocolEventName, + authRequiredEvent, + this.#getContextInfo(browsingContext) + ); + + if (authRequiredEvent.isBlocked) { + isBlocked = true; + + // requestChannel.suspend() is not needed here because the request is + // already blocked on the authentication prompt notification until + // one of the authCallbacks is called. + this.#addBlockedRequest( + authRequiredEvent.request.request, + InterceptPhase.AuthRequired, + { + authCallbacks, + requestChannel, + responseChannel, + } + ); + } + } finally { + if (!isBlocked) { + // If the request was not blocked, forward the auth prompt notification + // to the next consumer. + authCallbacks.forwardAuthPrompt(); + } + } + }; + + #onBeforeRequestSent = (name, data) => { + const { + contextId, + isNavigationRequest, + redirectCount, + requestChannel, + requestData, + timestamp, + } = data; + + const browsingContext = lazy.TabManager.getBrowsingContextById(contextId); + if (!browsingContext) { + // Do not emit events if the context id does not match any existing + // browsing context. + return; + } + + const internalEventName = "network._beforeRequestSent"; + const protocolEventName = "network.beforeRequestSent"; + + // Process the navigation to create potentially missing navigation ids + // before the early return below. + const navigation = this.#getNavigationId( + protocolEventName, + isNavigationRequest, + browsingContext, + requestData.url + ); + + // Always emit internal events, they are used to support the browsingContext + // navigate command. + // Bug 1861922: Replace internal events with a Network listener helper + // directly using the NetworkObserver. + this.emitEvent( + internalEventName, + { + navigation, + url: requestData.url, + }, + this.#getContextInfo(browsingContext) + ); + + const isListening = this.messageHandler.eventsDispatcher.hasListener( + protocolEventName, + { contextId } + ); + if (!isListening) { + // If there are no listeners subscribed to this event and this context, + // bail out. + return; + } + + const baseParameters = this.#processNetworkEvent(protocolEventName, { + contextId, + navigation, + redirectCount, + requestData, + timestamp, + }); + + // Bug 1805479: Handle the initiator, including stacktrace details. + const initiator = { + type: InitiatorType.Other, + }; + + const beforeRequestSentEvent = this.#serializeNetworkEvent({ + ...baseParameters, + initiator, + }); + + this.emitEvent( + protocolEventName, + beforeRequestSentEvent, + this.#getContextInfo(browsingContext) + ); + + if (beforeRequestSentEvent.isBlocked) { + // TODO: Requests suspended in beforeRequestSent still reach the server at + // the moment. https://bugzilla.mozilla.org/show_bug.cgi?id=1849686 + const wrapper = ChannelWrapper.get(requestChannel); + wrapper.suspend( + this.#getSuspendMarkerText(requestData, "beforeRequestSent") + ); + + this.#addBlockedRequest( + beforeRequestSentEvent.request.request, + InterceptPhase.BeforeRequestSent, + { + requestChannel, + } + ); + } + }; + + #onFetchError = (name, data) => { + const { + contextId, + errorText, + isNavigationRequest, + redirectCount, + requestData, + timestamp, + } = data; + + const browsingContext = lazy.TabManager.getBrowsingContextById(contextId); + if (!browsingContext) { + // Do not emit events if the context id does not match any existing + // browsing context. + return; + } + + const internalEventName = "network._fetchError"; + const protocolEventName = "network.fetchError"; + + // Process the navigation to create potentially missing navigation ids + // before the early return below. + const navigation = this.#getNavigationId( + protocolEventName, + isNavigationRequest, + browsingContext, + requestData.url + ); + + // Always emit internal events, they are used to support the browsingContext + // navigate command. + // Bug 1861922: Replace internal events with a Network listener helper + // directly using the NetworkObserver. + this.emitEvent( + internalEventName, + { + navigation, + url: requestData.url, + }, + this.#getContextInfo(browsingContext) + ); + + const isListening = this.messageHandler.eventsDispatcher.hasListener( + protocolEventName, + { contextId } + ); + if (!isListening) { + // If there are no listeners subscribed to this event and this context, + // bail out. + return; + } + + const baseParameters = this.#processNetworkEvent(protocolEventName, { + contextId, + navigation, + redirectCount, + requestData, + timestamp, + }); + + const fetchErrorEvent = this.#serializeNetworkEvent({ + ...baseParameters, + errorText, + }); + + this.emitEvent( + protocolEventName, + fetchErrorEvent, + this.#getContextInfo(browsingContext) + ); + }; + + #onResponseEvent = (name, data) => { + const { + contextId, + isNavigationRequest, + redirectCount, + requestChannel, + requestData, + responseChannel, + responseData, + timestamp, + } = data; + + const browsingContext = lazy.TabManager.getBrowsingContextById(contextId); + if (!browsingContext) { + // Do not emit events if the context id does not match any existing + // browsing context. + return; + } + + const protocolEventName = + name === "response-started" + ? "network.responseStarted" + : "network.responseCompleted"; + + const internalEventName = + name === "response-started" + ? "network._responseStarted" + : "network._responseCompleted"; + + // Process the navigation to create potentially missing navigation ids + // before the early return below. + const navigation = this.#getNavigationId( + protocolEventName, + isNavigationRequest, + browsingContext, + requestData.url + ); + + // Always emit internal events, they are used to support the browsingContext + // navigate command. + // Bug 1861922: Replace internal events with a Network listener helper + // directly using the NetworkObserver. + this.emitEvent( + internalEventName, + { + navigation, + url: requestData.url, + }, + this.#getContextInfo(browsingContext) + ); + + const isListening = this.messageHandler.eventsDispatcher.hasListener( + protocolEventName, + { contextId } + ); + if (!isListening) { + // If there are no listeners subscribed to this event and this context, + // bail out. + return; + } + + const baseParameters = this.#processNetworkEvent(protocolEventName, { + contextId, + navigation, + redirectCount, + requestData, + timestamp, + }); + + const responseEvent = this.#serializeNetworkEvent({ + ...baseParameters, + response: responseData, + }); + + const authChallenges = this.#extractChallenges(responseData); + if (authChallenges !== null) { + responseEvent.response.authChallenges = authChallenges; + } + + this.emitEvent( + protocolEventName, + responseEvent, + this.#getContextInfo(browsingContext) + ); + + if ( + protocolEventName === "network.responseStarted" && + responseEvent.isBlocked + ) { + const wrapper = ChannelWrapper.get(requestChannel); + wrapper.suspend( + this.#getSuspendMarkerText(requestData, "responseStarted") + ); + + this.#addBlockedRequest( + responseEvent.request.request, + InterceptPhase.ResponseStarted, + { + requestChannel, + responseChannel, + } + ); + } + }; + + /** + * Process the network event data for a given network event name and create + * the corresponding base parameters. + * + * @param {string} eventName + * One of the supported network event names. + * @param {object} data + * @param {string} data.contextId + * The browsing context id for the network event. + * @param {string|null} data.navigation + * The navigation id if this is a network event for a navigation request. + * @param {number} data.redirectCount + * The redirect count for the network event. + * @param {RequestData} data.requestData + * The network.RequestData information for the network event. + * @param {number} data.timestamp + * The timestamp when the network event was created. + */ + #processNetworkEvent(eventName, data) { + const { contextId, navigation, redirectCount, requestData, timestamp } = + data; + const intercepts = this.#getNetworkIntercepts(eventName, requestData); + const isBlocked = !!intercepts.length; + + const baseParameters = { + context: contextId, + isBlocked, + navigation, + redirectCount, + request: requestData, + timestamp, + }; + + if (isBlocked) { + baseParameters.intercepts = intercepts; + } + + return baseParameters; + } + + #serializeHeadersOrCookies(headersOrCookies) { + return headersOrCookies.map(item => ({ + name: item.name, + value: this.#serializeStringAsBytesValue(item.value), + })); + } + + /** + * Serialize in-place all cookies and headers arrays found in a given network + * event payload. + * + * @param {object} networkEvent + * The network event parameters object to serialize. + * @returns {object} + * The serialized network event parameters. + */ + #serializeNetworkEvent(networkEvent) { + // Make a shallow copy of networkEvent before serializing the headers and + // cookies arrays in request/response. + const serialized = { ...networkEvent }; + + // Make a shallow copy of the request data. + serialized.request = { ...networkEvent.request }; + serialized.request.cookies = this.#serializeHeadersOrCookies( + networkEvent.request.cookies + ); + serialized.request.headers = this.#serializeHeadersOrCookies( + networkEvent.request.headers + ); + + if (networkEvent.response?.headers) { + // Make a shallow copy of the response data. + serialized.response = { ...networkEvent.response }; + serialized.response.headers = this.#serializeHeadersOrCookies( + networkEvent.response.headers + ); + } + + return serialized; + } + + /** + * Serialize a string value as BytesValue. + * + * Note: This does not attempt to fully implement serialize protocol bytes + * (https://w3c.github.io/webdriver-bidi/#serialize-protocol-bytes) as the + * header values read from the Channel are already serialized as strings at + * the moment. + * + * @param {string} value + * The value to serialize. + */ + #serializeStringAsBytesValue(value) { + // TODO: For now, we handle all headers and cookies with the "string" type. + // See Bug 1835216 to add support for "base64" type and handle non-utf8 + // values. + return { + type: BytesValueType.String, + value, + }; + } + + #startListening(event) { + if (this.#subscribedEvents.size == 0) { + this.#networkListener.startListening(); + } + this.#subscribedEvents.add(event); + } + + #stopListening(event) { + this.#subscribedEvents.delete(event); + if (this.#subscribedEvents.size == 0) { + this.#networkListener.stopListening(); + } + } + + #subscribeEvent(event) { + if (this.constructor.supportedEvents.includes(event)) { + this.#startListening(event); + } + } + + #unsubscribeEvent(event) { + if (this.constructor.supportedEvents.includes(event)) { + this.#stopListening(event); + } + } + + /** + * Internal commands + */ + + _applySessionData(params) { + // TODO: Bug 1775231. Move this logic to a shared module or an abstract + // class. + const { category } = params; + if (category === "event") { + const filteredSessionData = params.sessionData.filter(item => + this.messageHandler.matchesContext(item.contextDescriptor) + ); + for (const event of this.#subscribedEvents.values()) { + const hasSessionItem = filteredSessionData.some( + item => item.value === event + ); + // If there are no session items for this context, we should unsubscribe from the event. + if (!hasSessionItem) { + this.#unsubscribeEvent(event); + } + } + + // Subscribe to all events, which have an item in SessionData. + for (const { value } of filteredSessionData) { + this.#subscribeEvent(value); + } + } + } + + static get supportedEvents() { + return [ + "network.authRequired", + "network.beforeRequestSent", + "network.fetchError", + "network.responseCompleted", + "network.responseStarted", + ]; + } +} + +export const network = NetworkModule; |