/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const { Actor } = require("resource://devtools/shared/protocol.js"); const { networkEventSpec, } = require("resource://devtools/shared/specs/network-event.js"); const { TYPES: { NETWORK_EVENT }, } = require("resource://devtools/server/actors/resources/index.js"); const { LongStringActor, } = require("resource://devtools/server/actors/string.js"); const lazy = {}; ChromeUtils.defineESModuleGetters( lazy, { NetworkUtils: "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs", }, { global: "contextual" } ); const CONTENT_TYPE_REGEXP = /^content-type/i; function isDataChannel(channel) { return channel instanceof Ci.nsIDataChannel; } function isFileChannel(channel) { return channel instanceof Ci.nsIFileChannel; } /** * Creates an actor for a network event. * * @class * @param {DevToolsServerConnection} conn * The connection into which this Actor will be added. * @param {object} sessionContext * The Session Context to help know what is debugged. * See devtools/server/actors/watcher/session-context.js * @param {object} options * Dictionary object with the following attributes: * - onNetworkEventUpdate: optional function * Callback for updates for the network event * - onNetworkEventDestroy: optional function * Callback for the destruction of the network event * @param {object} networkEventOptions * Object describing the network event or the configuration of the * network observer, and which cannot be easily inferred from the raw * channel. * - extension: optional object with `blocking` or `blocked` extension IDs * id of the blocking webextension if any * - blockedReason: optional number or string * - discardRequestBody: boolean * - discardResponseBody: boolean * - fromCache: boolean * - fromServiceWorker: boolean * - timestamp: number * @param {nsIChannel} channel * The channel related to this network event */ class NetworkEventActor extends Actor { constructor( conn, sessionContext, { onNetworkEventUpdate, onNetworkEventDestroy }, networkEventOptions, channel ) { super(conn, networkEventSpec); this._sessionContext = sessionContext; this._onNetworkEventUpdate = onNetworkEventUpdate; this._onNetworkEventDestroy = onNetworkEventDestroy; // Store the channelId which will act as resource id. this._channelId = channel.channelId; this._timings = {}; this._serverTimings = []; this._discardRequestBody = !!networkEventOptions.discardRequestBody; this._discardResponseBody = !!networkEventOptions.discardResponseBody; this._response = { headers: [], cookies: [], content: {}, }; this._earlyHintsResponse = { headers: [], rawHeaders: "", }; if (isDataChannel(channel) || isFileChannel(channel)) { this._innerWindowId = lazy.NetworkUtils.getChannelInnerWindowId(channel); this._isNavigationRequest = false; this._request = { cookies: [], headers: [], postData: {}, rawHeaders: "", }; this._resource = this._createResource(networkEventOptions, channel); return; } // innerWindowId and isNavigationRequest are used to check if the actor // should be destroyed when a window is destroyed. See network-events.js. this._innerWindowId = lazy.NetworkUtils.getChannelInnerWindowId(channel); this._isNavigationRequest = lazy.NetworkUtils.isNavigationRequest(channel); this._isRedirect = false; // Retrieve cookies and headers from the channel const { cookies, headers } = lazy.NetworkUtils.fetchRequestHeadersAndCookies(channel); this._request = { cookies, headers, postData: {}, }; this._resource = this._createResource(networkEventOptions, channel); this._memoryCacheData = null; } /** * Return the network event actor as a resource, and add the actorID which is * not available in the constructor yet. */ asResource() { return { actor: this.actorID, ...this._resource, }; } /** * Create the resource corresponding to this actor. */ _createResource(networkEventOptions, channel) { let wsChannel; let method; if (isDataChannel(channel) || isFileChannel(channel)) { channel.QueryInterface(Ci.nsIChannel); wsChannel = null; method = "GET"; } else { channel = channel.QueryInterface(Ci.nsIHttpChannel); wsChannel = lazy.NetworkUtils.getWebSocketChannel(channel); method = channel.requestMethod; } // Use the WebSocket channel URL for websockets. const url = wsChannel ? wsChannel.URI.spec : channel.URI.spec; let browsingContextID = lazy.NetworkUtils.getChannelBrowsingContextID(channel); // Ensure that we have a browsing context ID for all requests. // Only privileged requests debugged via the Browser Toolbox (sessionContext.type == "all") can be unrelated to any browsing context. if (!browsingContextID && this._sessionContext.type != "all") { throw new Error(`Got a request ${url} without a browsingContextID set`); } // The browsingContextID is used by the ResourceCommand on the client // to find the related Target Front. // // For now in the browser and web extension toolboxes, requests // do not relate to any specific WindowGlobalTargetActor // as we are still using a unique target (ParentProcessTargetActor) for everything. if ( this._sessionContext.type == "all" || this._sessionContext.type == "webextension" ) { browsingContextID = -1; } const cause = lazy.NetworkUtils.getCauseDetails(channel); // Both xhr and fetch are flagged as XHR in DevTools. const isXHR = cause.type == "xhr" || cause.type == "fetch"; // For websocket requests the serial is used instead of the channel id. const stacktraceResourceId = cause.type == "websocket" ? wsChannel.serial : channel.channelId; // If a timestamp was provided, it is a high resolution timestamp // corresponding to ACTIVITY_SUBTYPE_REQUEST_HEADER. Fallback to Date.now(). const timeStamp = networkEventOptions.timestamp ? networkEventOptions.timestamp / 1000 : Date.now(); let blockedReason = networkEventOptions.blockedReason; // Check if blockedReason was set to a falsy value, meaning the blocked did // not give an explicit blocked reason. if ( blockedReason === 0 || blockedReason === false || blockedReason === null || blockedReason === "" ) { blockedReason = "unknown"; } const resource = { resourceId: this._channelId, resourceType: NETWORK_EVENT, blockedReason, extension: networkEventOptions.extension, browsingContextID, cause, // This is used specifically in the browser toolbox console to distinguish privileged // resources from the parent process from those from the content. chromeContext: lazy.NetworkUtils.isChannelFromSystemPrincipal(channel), innerWindowId: this._innerWindowId, isNavigationRequest: this._isNavigationRequest, isThirdPartyTrackingResource: lazy.NetworkUtils.isThirdPartyTrackingResource(channel), isXHR, method, priority: lazy.NetworkUtils.getChannelPriority(channel), private: lazy.NetworkUtils.isChannelPrivate(channel), referrerPolicy: lazy.NetworkUtils.getReferrerPolicy(channel), stacktraceResourceId, startedDateTime: new Date(timeStamp).toISOString(), securityFlags: channel.loadInfo.securityFlags, timeStamp, timings: {}, url, }; return resource; } /** * Releases this actor from the pool. */ destroy(conn) { if (!this._channelId) { return; } if (this._onNetworkEventDestroy) { this._onNetworkEventDestroy(this._channelId); } this._channelId = null; super.destroy(conn); } release() { // Per spec, destroy is automatically going to be called after this request } getInnerWindowId() { return this._innerWindowId; } isNavigationRequest() { return this._isNavigationRequest; } /** * The "getRequestHeaders" packet type handler. * * @return object * The response packet - network request headers. */ getRequestHeaders() { let rawHeaders; let headersSize = 0; if (this._request.rawHeaders) { headersSize = this._request.rawHeaders.length; rawHeaders = this._createLongStringActor(this._request.rawHeaders); } return { headers: this._request.headers.map(header => ({ name: header.name, value: this._createLongStringActor(header.value), })), headersSize, rawHeaders, }; } /** * The "getRequestCookies" packet type handler. * * @return object * The response packet - network request cookies. */ getRequestCookies() { return { cookies: this._request.cookies.map(cookie => ({ name: cookie.name, value: this._createLongStringActor(cookie.value), })), }; } /** * The "getRequestPostData" packet type handler. * * @return object * The response packet - network POST data. */ getRequestPostData() { let postDataText; if (this._request.postData.text) { // Create a long string actor for the postData text if needed. postDataText = this._createLongStringActor(this._request.postData.text); } return { postData: { size: this._request.postData.size, text: postDataText, }, postDataDiscarded: this._discardRequestBody, }; } /** * The "getSecurityInfo" packet type handler. * * @return object * The response packet - connection security information. */ getSecurityInfo() { return { securityInfo: this._securityInfo, }; } /** * The "getEarlyHintsResponseHeaders" packet type handler. * * @return object * The response packet - network early hint response headers. */ getEarlyHintsResponseHeaders() { const { rawHeaders, headers } = this._earlyHintsResponse; return { headers: headers.map(header => ({ name: header.name, value: this._createLongStringActor(header.value), })), headersSize: rawHeaders.length, rawHeaders: this._createLongStringActor(rawHeaders), }; } /** * The "getResponseHeaders" packet type handler. * * @return object * The response packet - network response headers. */ getResponseHeaders() { let rawHeaders; let headersSize = 0; if (this._response.rawHeaders) { headersSize = this._response.rawHeaders.length; rawHeaders = this._createLongStringActor(this._response.rawHeaders); } return { headers: this._response.headers.map(header => ({ name: header.name, value: this._createLongStringActor(header.value), })), headersSize, rawHeaders, }; } /** * The "getResponseCache" packet type handler. * * @return object * The cache packet - network cache information. */ getResponseCache() { return { cache: this._response.responseCache, }; } /** * The "getResponseCookies" packet type handler. * * @return object * The response packet - network response cookies. */ getResponseCookies() { // As opposed to request cookies, response cookies can come with additional // properties. const cookieOptionalProperties = [ "domain", "expires", "httpOnly", "path", "samesite", "secure", ]; return { cookies: this._response.cookies.map(cookie => { const cookieResponse = { name: cookie.name, value: this._createLongStringActor(cookie.value), }; for (const prop of cookieOptionalProperties) { if (prop in cookie) { cookieResponse[prop] = cookie[prop]; } } return cookieResponse; }), }; } /** * The "getResponseContent" packet type handler. * * @return object * The response packet - network response content. */ getResponseContent() { if (!this._response.content.text && this._memoryCacheData) { const data = this._memoryCacheData; this._memoryCacheData = null; if (data.key.startsWith("SharedScriptCache:")) { // This should be performed on the same process as the // http-on-resource-cache-response observer notification is received, in // order to query on the SharedScriptCache instance that has the // corresponding cache entry. const text = ChromeUtils.getCachedJavaScriptSource( data.key, this._resource.url, data.charset ); if (text !== undefined) { this._response.content.text = text; } else { this._discardResponseBody = true; } } } const content = { ...this._response.content }; if (this._response.contentLongStringActor) { // Remove the old actor from the pool as new actor will be created // with updated content. this.unmanage(this._response.contentLongStringActor); } this._response.contentLongStringActor = new LongStringActor( this.conn, // When trying to fetch content on a previous page load, or a cancelled request, // `response.content` can be an empty object content.text || "" ); // bug 1462561 - Use "json" type and manually manage/marshall actors to workaround // protocol.js performance issue this.manage(this._response.contentLongStringActor); content.text = this._discardResponseBody ? "" : this._response.contentLongStringActor.form(); return { content, contentDiscarded: this._discardResponseBody, }; } /** * The "getEventTimings" packet type handler. * * @return object * The response packet - network event timings. */ getEventTimings() { return { timings: this._timings, totalTime: this._totalTime, offsets: this._offsets, serverTimings: this._serverTimings, serviceWorkerTimings: this._serviceWorkerTimings, }; } /****************************************************************** * Listeners for new network event data coming from NetworkMonitor. *****************************************************************/ addCacheDetails({ fromCache, fromServiceWorker }) { this._resource.fromCache = fromCache; this._resource.fromServiceWorker = fromServiceWorker; this._onEventUpdate(lazy.NetworkUtils.NETWORK_EVENT_TYPES.CACHE_DETAILS, { fromCache, fromServiceWorker, }); } addMemoryCacheData(channel, memoryCacheKey) { let charset = ""; if (channel instanceof Ci.nsIHttpChannel) { charset = channel.classicScriptHintCharset || ""; } this._memoryCacheData = { key: memoryCacheKey, charset, }; } addRawHeaders({ channel, rawHeaders }) { this._request.rawHeaders = rawHeaders; // For regular requests, some additional headers might only be available // when rawHeaders are provided, so we update the request headers here. const { headers } = lazy.NetworkUtils.fetchRequestHeadersAndCookies(channel); this._request.headers = headers; } /** * Add network request POST data. * * @param object postData * The request POST data. */ addRequestPostData(postData) { // Ignore calls when this actor is already destroyed if (this.isDestroyed()) { return; } this._request.postData = postData; this._onEventUpdate( lazy.NetworkUtils.NETWORK_EVENT_TYPES.REQUEST_POSTDATA, {} ); } /** * Add the initial network response information. * * @param {object} options * @param {nsIChannel} options.channel * @param {boolean} options.fromCache * @param {string} options.rawHeaders * @param {string} options.proxyResponseRawHeaders * @param {string} options.earlyHintsResponseRawHeaders */ addResponseStart({ channel, fromCache, rawHeaders = "", proxyResponseRawHeaders, earlyHintsResponseRawHeaders, }) { // Ignore calls when this actor is already destroyed if (this.isDestroyed()) { return; } // Avoid reading responseStatus and responseStatusText dynamically from // the channel as much as possible. In some cases, eg 304 Not Modified, the // channel is dynamically replaced with the original channel and we lose the // original information about the channel status as the request progresses. // Reading this synchronously in this method is fine, but we extract them as // separate variables here to bring some attention to this issue. const { responseStatus, responseStatusText } = channel; this._isRedirect = lazy.NetworkUtils.isRedirect(responseStatus); fromCache = fromCache || lazy.NetworkUtils.isFromCache(channel); const isDataOrFile = isDataChannel(channel) || isFileChannel(channel); // Read response headers and cookies. let responseHeaders = []; let responseCookies = []; if (!this._blockedReason && !isDataOrFile) { const { cookies, headers } = lazy.NetworkUtils.fetchResponseHeadersAndCookies(channel); responseCookies = cookies; responseHeaders = headers; } // Handle response headers this._response.rawHeaders = rawHeaders; this._response.headers = responseHeaders; this._response.cookies = responseCookies; // Handle the rest of the response start metadata. this._response.headersSize = rawHeaders ? rawHeaders.length : 0; // Early Hint Response Headers if (earlyHintsResponseRawHeaders) { this._earlyHintsResponse.headers = lazy.NetworkUtils.parseEarlyHintsResponseHeaders( earlyHintsResponseRawHeaders ); this._earlyHintsResponse.rawHeaders = earlyHintsResponseRawHeaders; } // Discard the response body for known redirect response statuses. if (this._isRedirect) { this._discardResponseBody = true; } // Mime type needs to be sent on response start for identifying an sse channel. const contentTypeHeader = responseHeaders.find(header => CONTENT_TYPE_REGEXP.test(header.name) ); let mimeType = ""; if (contentTypeHeader) { mimeType = contentTypeHeader.value; } let waitingTime = null; if (!isDataOrFile) { const timedChannel = channel.QueryInterface(Ci.nsITimedChannel); waitingTime = Math.round( (timedChannel.responseStartTime - timedChannel.requestStartTime) / 1000 ); } let proxyInfo = []; if (proxyResponseRawHeaders) { // The typical format for proxy raw headers is `HTTP/2 200 Connected\r\nConnection: keep-alive` // The content is parsed and split into http version (HTTP/2), status(200) and status text (Connected) proxyInfo = proxyResponseRawHeaders.split("\r\n")[0].split(" "); } this._onEventUpdate(lazy.NetworkUtils.NETWORK_EVENT_TYPES.RESPONSE_START, { httpVersion: isDataOrFile ? null : lazy.NetworkUtils.getHttpVersion(channel), mimeType, remoteAddress: fromCache ? "" : channel.remoteAddress, remotePort: fromCache ? "" : channel.remotePort, status: isDataOrFile ? "200" : responseStatus + "", statusText: isDataOrFile ? "0K" : responseStatusText, isRedirect: this._isRedirect, earlyHintsStatus: earlyHintsResponseRawHeaders ? "103" : "", waitingTime, isResolvedByTRR: channel.isResolvedByTRR, proxyHttpVersion: proxyInfo[0], proxyStatus: proxyInfo[1], proxyStatusText: proxyInfo[2], }); } /** * Add connection security information. * * @param object info * The object containing security information. */ addSecurityInfo(info) { // Ignore calls when this actor is already destroyed if (this.isDestroyed()) { return; } this._securityInfo = info; this._onEventUpdate(lazy.NetworkUtils.NETWORK_EVENT_TYPES.SECURITY_INFO, { state: info.state, }); } /** * Add network response content end. * * @param object */ addResponseContentComplete({ blockedReason, extension }) { // Ignore calls when this actor is already destroyed if (this.isDestroyed()) { return; } this._onEventUpdate( lazy.NetworkUtils.NETWORK_EVENT_TYPES.RESPONSE_CONTENT_COMPLETE, { blockedReason, extension, } ); } /** * Add network response content. * * @param object content * The response content. */ addResponseContent(content, data) { const { blockedReason, extension } = data || {}; // Ignore calls when this actor is already destroyed if (this.isDestroyed()) { return; } this._response.content = content; this._onEventUpdate( lazy.NetworkUtils.NETWORK_EVENT_TYPES.RESPONSE_CONTENT, { mimeType: content.mimeType, contentSize: content.size, transferredSize: content.transferredSize, blockedReason, extension, } ); } addResponseCache(content) { // Ignore calls when this actor is already destroyed if (this.isDestroyed()) { return; } this._response.responseCache = content.responseCache; this._onEventUpdate( lazy.NetworkUtils.NETWORK_EVENT_TYPES.RESPONSE_CACHE, {} ); } /** * Add network event timing information. * * @param number total * The total time of the network event. * @param object timings * Timing details about the network event. * @param object offsets */ addEventTimings(total, timings, offsets) { // Ignore calls when this actor is already destroyed if (this.isDestroyed()) { return; } this._totalTime = total; this._timings = timings; this._offsets = offsets; this._onEventUpdate(lazy.NetworkUtils.NETWORK_EVENT_TYPES.EVENT_TIMINGS, { totalTime: total, }); } /** * Store server timing information. They are merged together * with network event timing data when they are available and * notification sent to the client. * See `addEventTimings` above for more information. * * @param object serverTimings * Timing details extracted from the Server-Timing header. */ addServerTimings(serverTimings) { if (!serverTimings || this.isDestroyed()) { return; } this._serverTimings = serverTimings; } /** * Store service worker timing information. They are merged together * with network event timing data when they are available and * notification sent to the client. * See `addEventTimnings`` above for more information. * * @param object serviceWorkerTimings * Timing details extracted from the Timed Channel. */ addServiceWorkerTimings(serviceWorkerTimings) { if (!serviceWorkerTimings || this.isDestroyed()) { return; } this._serviceWorkerTimings = serviceWorkerTimings; } _createLongStringActor(string) { if (string?.actorID) { return string; } const longStringActor = new LongStringActor(this.conn, string); // bug 1462561 - Use "json" type and manually manage/marshall actors to workaround // protocol.js performance issue this.manage(longStringActor); return longStringActor.form(); } /** * Sends the updated event data to the client * * @private * @param string updateType * @param object data * The properties that have changed for the event */ _onEventUpdate(updateType, data) { if (this._onNetworkEventUpdate) { this._onNetworkEventUpdate({ resourceId: this._channelId, updateType, ...data, }); } } } exports.NetworkEventActor = NetworkEventActor;