summaryrefslogtreecommitdiffstats
path: root/devtools/shared/network-observer/NetworkResponseListener.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--devtools/shared/network-observer/NetworkResponseListener.sys.mjs609
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",
+ ]);
+}