diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/shared/network-observer/NetworkResponseListener.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/shared/network-observer/NetworkResponseListener.sys.mjs')
-rw-r--r-- | devtools/shared/network-observer/NetworkResponseListener.sys.mjs | 609 |
1 files changed, 609 insertions, 0 deletions
diff --git a/devtools/shared/network-observer/NetworkResponseListener.sys.mjs b/devtools/shared/network-observer/NetworkResponseListener.sys.mjs new file mode 100644 index 0000000000..23246c73de --- /dev/null +++ b/devtools/shared/network-observer/NetworkResponseListener.sys.mjs @@ -0,0 +1,609 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + getResponseCacheObject: + "resource://devtools/shared/platform/CacheEntry.sys.mjs", + NetworkHelper: + "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs", + NetworkUtils: + "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + NetUtil: "resource://gre/modules/NetUtil.jsm", +}); + +// Network logging + +/** + * The network response listener implements the nsIStreamListener and + * nsIRequestObserver interfaces. This is used within the NetworkObserver feature + * to get the response body of the request. + * + * The code is mostly based on code listings from: + * + * http://www.softwareishard.com/blog/firebug/ + * nsitraceablechannel-intercept-http-traffic/ + * + * @constructor + * @param {Object} httpActivity + * HttpActivity object associated with this request. See NetworkObserver + * more information. + * @param {Map} decodedCertificateCache + * A Map of certificate fingerprints to decoded certificates, to avoid + * repeatedly decoding previously-seen certificates. + */ +export class NetworkResponseListener { + /** + * The compressed and encoded response body size. Will progressively increase + * until the full response is received. + * + * @type {Number} + */ + #bodySize = 0; + /** + * The uncompressed, decoded response body size. + * + * @type {Number} + */ + #decodedBodySize = 0; + /** + * nsIStreamListener created by nsIStreamConverterService.asyncConvertData + * + * @type {nsIStreamListener} + */ + #converter = null; + /** + * See constructor argument of the same name. + * + * @type {Map} + */ + #decodedCertificateCache; + /** + * Is the channel from a service worker + * + * @type {boolean} + */ + #fromServiceWorker; + /** + * See constructor argument of the same name. + * + * @type {Object} + */ + #httpActivity; + /** + * Set from sink.inputStream, mainly to prevent GC. + * + * @type {nsIInputStream} + */ + #inputStream = null; + /** + * Explicit flag to check if this listener was already destroyed. + * + * @type {boolean} + */ + #isDestroyed = false; + /** + * Internal promise used to hold the completion of #getSecurityInfo. + * + * @type {Promise} + */ + #onSecurityInfo = null; + /** + * Offset for the onDataAvailable calls where we pass the data from our pipe + * to the converter. + * + * @type {Number} + */ + #offset = 0; + /** + * Stores the received data as a string. + * + * @type {string} + */ + #receivedData = ""; + /** + * The nsIRequest we are started for. + * + * @type {nsIRequest} + */ + #request = null; + /** + * The response will be written into the outputStream of this nsIPipe. + * Both ends of the pipe must be blocking. + * + * @type {nsIPipe} + */ + #sink = null; + /** + * Indicates if the response had a size greater than response body limit. + * + * @type {boolean} + */ + #truncated = false; + /** + * Backup for existing notificationCallbacks set on the monitored channel. + * Initialized in the constructor. + * + * @type {Object} + */ + #wrappedNotificationCallbacks; + + constructor(httpActivity, decodedCertificateCache, fromServiceWorker) { + this.#httpActivity = httpActivity; + this.#decodedCertificateCache = decodedCertificateCache; + this.#fromServiceWorker = fromServiceWorker; + + // Note that this is really only needed for the non-e10s case. + // See bug 1309523. + const channel = this.#httpActivity.channel; + // If the channel already had notificationCallbacks, hold them here + // internally so that we can forward getInterface requests to that object. + this.#wrappedNotificationCallbacks = channel.notificationCallbacks; + channel.notificationCallbacks = this; + } + + set inputStream(inputStream) { + this.#inputStream = inputStream; + } + + set sink(sink) { + this.#sink = sink; + } + + // nsIInterfaceRequestor implementation + + /** + * This object implements nsIProgressEventSink, but also needs to forward + * interface requests to the notification callbacks of other objects. + */ + getInterface(iid) { + if (iid.equals(Ci.nsIProgressEventSink)) { + return this; + } + if (this.#wrappedNotificationCallbacks) { + return this.#wrappedNotificationCallbacks.getInterface(iid); + } + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + } + + /** + * Forward notifications for interfaces this object implements, in case other + * objects also implemented them. + */ + #forwardNotification(iid, method, args) { + if (!this.#wrappedNotificationCallbacks) { + return; + } + try { + const impl = this.#wrappedNotificationCallbacks.getInterface(iid); + impl[method].apply(impl, args); + } catch (e) { + if (e.result != Cr.NS_ERROR_NO_INTERFACE) { + throw e; + } + } + } + + /** + * Set the async listener for the given nsIAsyncInputStream. This allows us to + * wait asynchronously for any data coming from the stream. + * + * @param nsIAsyncInputStream stream + * The input stream from where we are waiting for data to come in. + * @param nsIInputStreamCallback listener + * The input stream callback you want. This is an object that must have + * the onInputStreamReady() method. If the argument is null, then the + * current callback is removed. + * @return void + */ + setAsyncListener(stream, listener) { + // Asynchronously wait for the stream to be readable or closed. + stream.asyncWait(listener, 0, 0, Services.tm.mainThread); + } + + /** + * Stores the received data, if request/response body logging is enabled. It + * also does limit the number of stored bytes, based on the + * `devtools.netmonitor.responseBodyLimit` pref. + * + * Learn more about nsIStreamListener at: + * https://developer.mozilla.org/en/XPCOM_Interface_Reference/nsIStreamListener + * + * @param nsIRequest request + * @param nsISupports context + * @param nsIInputStream inputStream + * @param unsigned long offset + * @param unsigned long count + */ + onDataAvailable(request, inputStream, offset, count) { + const data = lazy.NetUtil.readInputStreamToString(inputStream, count); + + this.#decodedBodySize += count; + + if (!this.#httpActivity.discardResponseBody) { + const limit = Services.prefs.getIntPref( + "devtools.netmonitor.responseBodyLimit" + ); + if (this.#receivedData.length <= limit || limit == 0) { + this.#receivedData += lazy.NetworkHelper.convertToUnicode( + data, + request.contentCharset + ); + } + if (this.#receivedData.length > limit && limit > 0) { + this.#receivedData = this.#receivedData.substr(0, limit); + this.#truncated = true; + } + } + } + + /** + * See documentation at + * https://developer.mozilla.org/En/NsIRequestObserver + * + * @param nsIRequest request + * @param nsISupports context + */ + onStartRequest(request) { + request = request.QueryInterface(Ci.nsIChannel); + // Converter will call this again, we should just ignore that. + if (this.#request) { + return; + } + + this.#request = request; + this.#onSecurityInfo = this.#getSecurityInfo(); + // We need to track the offset for the onDataAvailable calls where + // we pass the data from our pipe to the converter. + this.#offset = 0; + + const channel = this.#request; + + // Bug 1372115 - We should load bytecode cached requests from cache as the actual + // channel content is going to be optimized data that reflects platform internals + // instead of the content user expects (i.e. content served by HTTP server) + // Note that bytecode cached is one example, there may be wasm or other usecase in + // future. + let isOptimizedContent = false; + try { + if (channel instanceof Ci.nsICacheInfoChannel) { + isOptimizedContent = channel.alternativeDataType; + } + } catch (e) { + // Accessing `alternativeDataType` for some SW requests throws. + } + if (isOptimizedContent) { + let charset; + try { + charset = this.#request.contentCharset; + } catch (e) { + // Accessing the charset sometimes throws NS_ERROR_NOT_AVAILABLE when + // reloading the page + } + if (!charset) { + charset = this.#httpActivity.charset; + } + lazy.NetworkHelper.loadFromCache( + this.#httpActivity.url, + charset, + this.#onComplete.bind(this) + ); + return; + } + + // In the multi-process mode, the conversion happens on the child + // side while we can only monitor the channel on the parent + // side. If the content is gzipped, we have to unzip it + // ourself. For that we use the stream converter services. Do not + // do that for Service workers as they are run in the child + // process. + if ( + !this.#fromServiceWorker && + channel instanceof Ci.nsIEncodedChannel && + channel.contentEncodings && + !channel.applyConversion + ) { + const encodingHeader = channel.getResponseHeader("Content-Encoding"); + const scs = Cc["@mozilla.org/streamConverters;1"].getService( + Ci.nsIStreamConverterService + ); + const encodings = encodingHeader.split(/\s*\t*,\s*\t*/); + let nextListener = this; + const acceptedEncodings = [ + "gzip", + "deflate", + "br", + "x-gzip", + "x-deflate", + ]; + for (const i in encodings) { + // There can be multiple conversions applied + const enc = encodings[i].toLowerCase(); + if (acceptedEncodings.indexOf(enc) > -1) { + this.#converter = scs.asyncConvertData( + enc, + "uncompressed", + nextListener, + null + ); + nextListener = this.#converter; + } + } + if (this.#converter) { + this.#converter.onStartRequest(this.#request, null); + } + } + // Asynchronously wait for the data coming from the request. + this.setAsyncListener(this.#sink.inputStream, this); + } + + /** + * Parse security state of this request and report it to the client. + */ + async #getSecurityInfo() { + // Many properties of the securityInfo (e.g., the server certificate or HPKP + // status) are not available in the content process and can't be even touched safely, + // because their C++ getters trigger assertions. This function is called in content + // process for synthesized responses from service workers, in the parent otherwise. + if (Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT) { + return; + } + + // Take the security information from the original nsIHTTPChannel instead of + // the nsIRequest received in onStartRequest. If response to this request + // was a redirect from http to https, the request object seems to contain + // security info for the https request after redirect. + const secinfo = this.#httpActivity.channel.securityInfo; + const info = await lazy.NetworkHelper.parseSecurityInfo( + secinfo, + this.#request.loadInfo.originAttributes, + this.#httpActivity, + this.#decodedCertificateCache + ); + let isRacing = false; + try { + const channel = this.#httpActivity.channel; + if (channel instanceof Ci.nsICacheInfoChannel) { + isRacing = channel.isRacing(); + } + } catch (err) { + // See the following bug for more details: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1582589 + } + + this.#httpActivity.owner.addSecurityInfo(info, isRacing); + } + + /** + * Fetches cache information from CacheEntry + * @private + */ + async #fetchCacheInformation() { + // TODO: This method is async and #httpActivity is nullified in the #destroy + // method of this class. Backup httpActivity to avoid errors here. + const httpActivity = this.#httpActivity; + const cacheEntry = await lazy.getResponseCacheObject(this.#request); + httpActivity.owner.addResponseCache({ + responseCache: cacheEntry, + }); + } + + /** + * Handle the onStopRequest by closing the sink output stream. + * + * For more documentation about nsIRequestObserver go to: + * https://developer.mozilla.org/En/NsIRequestObserver + */ + onStopRequest() { + // Bug 1429365: onStopRequest may be called after onComplete for resources loaded + // from bytecode cache. + if (!this.#httpActivity) { + return; + } + this.#sink.outputStream.close(); + } + + // nsIProgressEventSink implementation + + /** + * Handle progress event as data is transferred. This is used to record the + * size on the wire, which may be compressed / encoded. + */ + onProgress(request, progress, progressMax) { + this.#bodySize = progress; + + // Need to forward as well to keep things like Download Manager's progress + // bar working properly. + this.#forwardNotification(Ci.nsIProgressEventSink, "onProgress", arguments); + } + + onStatus() { + this.#forwardNotification(Ci.nsIProgressEventSink, "onStatus", arguments); + } + + /** + * Clean up the response listener once the response input stream is closed. + * This is called from onStopRequest() or from onInputStreamReady() when the + * stream is closed. + * @return void + */ + onStreamClose() { + if (!this.#httpActivity) { + return; + } + // Remove our listener from the request input stream. + this.setAsyncListener(this.#sink.inputStream, null); + + let responseStatus; + try { + responseStatus = this.#httpActivity.channel.responseStatus; + } catch (e) { + // Will throw NS_ERROR_NOT_AVAILABLE if the response has not been received + // yet. + } + if (this.#request.fromCache || responseStatus == 304) { + this.#fetchCacheInformation(); + } + + if (!this.#httpActivity.discardResponseBody && this.#receivedData.length) { + this.#onComplete(this.#receivedData); + } else if ( + !this.#httpActivity.discardResponseBody && + responseStatus == 304 + ) { + // Response is cached, so we load it from cache. + let charset; + try { + charset = this.#request.contentCharset; + } catch (e) { + // Accessing the charset sometimes throws NS_ERROR_NOT_AVAILABLE when + // reloading the page + } + if (!charset) { + charset = this.#httpActivity.charset; + } + lazy.NetworkHelper.loadFromCache( + this.#httpActivity.url, + charset, + this.#onComplete.bind(this) + ); + } else { + this.#onComplete(); + } + } + + /** + * Handler for when the response completes. This function cleans up the + * response listener. + * + * @param string [data] + * Optional, the received data coming from the response listener or + * from the cache. + */ + #onComplete(data) { + // Make sure all the security and response content info are sent + this.#getResponseContent(data); + this.#onSecurityInfo.then(() => this.#destroy()); + } + + /** + * Create the response object and send it to the client. + */ + #getResponseContent(data) { + const response = { + mimeType: "", + text: data || "", + }; + + response.bodySize = this.#bodySize; + response.decodedBodySize = this.#decodedBodySize; + // TODO: Stop exposing the decodedBodySize as `size` which is ambiguous. + // Consumers should use `decodedBodySize` instead. See Bug 1808560. + response.size = this.#decodedBodySize; + response.headersSize = this.#httpActivity.headersSize; + response.transferredSize = this.#bodySize + this.#httpActivity.headersSize; + + try { + response.mimeType = this.#request.contentType; + } catch (ex) { + // Ignore. + } + + if ( + !response.mimeType || + !lazy.NetworkHelper.isTextMimeType(response.mimeType) + ) { + response.encoding = "base64"; + try { + response.text = btoa(response.text); + } catch (err) { + // Ignore. + } + } + + if (response.mimeType && this.#request.contentCharset) { + response.mimeType += "; charset=" + this.#request.contentCharset; + } + + this.#receivedData = ""; + + // Check any errors or blocking scenarios which happen late in the cycle + // e.g If a host is not found (NS_ERROR_UNKNOWN_HOST) or CORS blocking. + const { blockingExtension, blockedReason } = + lazy.NetworkUtils.getBlockedReason(this.#httpActivity.channel); + + this.#httpActivity.owner.addResponseContent(response, { + discardResponseBody: this.#httpActivity.discardResponseBody, + truncated: this.#truncated, + blockedReason, + blockingExtension, + }); + } + + #destroy() { + this.#wrappedNotificationCallbacks = null; + this.#httpActivity = null; + this.#sink = null; + this.#inputStream = null; + this.#converter = null; + this.#request = null; + + this.#isDestroyed = true; + } + + /** + * The nsIInputStreamCallback for when the request input stream is ready - + * either it has more data or it is closed. + * + * @param nsIAsyncInputStream stream + * The sink input stream from which data is coming. + * @returns void + */ + onInputStreamReady(stream) { + if (!(stream instanceof Ci.nsIAsyncInputStream) || !this.#httpActivity) { + return; + } + + let available = -1; + try { + // This may throw if the stream is closed normally or due to an error. + available = stream.available(); + } catch (ex) { + // Ignore. + } + + if (available != -1) { + if (available != 0) { + if (this.#converter) { + this.#converter.onDataAvailable( + this.#request, + stream, + this.#offset, + available + ); + } else { + this.onDataAvailable(this.#request, stream, this.#offset, available); + } + } + this.#offset += available; + this.setAsyncListener(stream, this); + } else { + this.onStreamClose(); + this.#offset = 0; + } + } + + QueryInterface = ChromeUtils.generateQI([ + "nsIStreamListener", + "nsIInputStreamCallback", + "nsIRequestObserver", + "nsIInterfaceRequestor", + ]); +} |