summaryrefslogtreecommitdiffstats
path: root/devtools/shared/network-observer
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/shared/network-observer
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/shared/network-observer')
-rw-r--r--devtools/shared/network-observer/ChannelMap.sys.mjs129
-rw-r--r--devtools/shared/network-observer/NetworkAuthListener.sys.mjs185
-rw-r--r--devtools/shared/network-observer/NetworkHelper.sys.mjs913
-rw-r--r--devtools/shared/network-observer/NetworkObserver.sys.mjs1532
-rw-r--r--devtools/shared/network-observer/NetworkOverride.sys.mjs70
-rw-r--r--devtools/shared/network-observer/NetworkResponseListener.sys.mjs608
-rw-r--r--devtools/shared/network-observer/NetworkThrottleManager.sys.mjs495
-rw-r--r--devtools/shared/network-observer/NetworkUtils.sys.mjs693
-rw-r--r--devtools/shared/network-observer/README.md9
-rw-r--r--devtools/shared/network-observer/WildcardToRegexp.sys.mjs28
-rw-r--r--devtools/shared/network-observer/moz.build21
-rw-r--r--devtools/shared/network-observer/test/browser/browser.toml33
-rw-r--r--devtools/shared/network-observer/test/browser/browser_networkobserver.js72
-rw-r--r--devtools/shared/network-observer/test/browser/browser_networkobserver_auth_listener.js386
-rw-r--r--devtools/shared/network-observer/test/browser/browser_networkobserver_invalid_constructor.js49
-rw-r--r--devtools/shared/network-observer/test/browser/browser_networkobserver_override.js179
-rw-r--r--devtools/shared/network-observer/test/browser/browser_networkobserver_serviceworker.js113
-rw-r--r--devtools/shared/network-observer/test/browser/doc_network-observer-missing-service-worker.html32
-rw-r--r--devtools/shared/network-observer/test/browser/doc_network-observer.html49
-rw-r--r--devtools/shared/network-observer/test/browser/gzipped.sjs44
-rw-r--r--devtools/shared/network-observer/test/browser/head.js123
-rw-r--r--devtools/shared/network-observer/test/browser/override.html1
-rw-r--r--devtools/shared/network-observer/test/browser/override.js2
-rw-r--r--devtools/shared/network-observer/test/browser/serviceworker.js23
-rw-r--r--devtools/shared/network-observer/test/browser/sjs_network-auth-listener-test-server.sjs31
-rw-r--r--devtools/shared/network-observer/test/browser/sjs_network-observer-test-server.sjs196
-rw-r--r--devtools/shared/network-observer/test/xpcshell/head.js9
-rw-r--r--devtools/shared/network-observer/test/xpcshell/test_network_helper.js90
-rw-r--r--devtools/shared/network-observer/test/xpcshell/test_security-info-certificate.js84
-rw-r--r--devtools/shared/network-observer/test/xpcshell/test_security-info-parser.js54
-rw-r--r--devtools/shared/network-observer/test/xpcshell/test_security-info-protocol-version.js48
-rw-r--r--devtools/shared/network-observer/test/xpcshell/test_security-info-state.js122
-rw-r--r--devtools/shared/network-observer/test/xpcshell/test_security-info-static-hpkp.js41
-rw-r--r--devtools/shared/network-observer/test/xpcshell/test_security-info-weakness-reasons.js39
-rw-r--r--devtools/shared/network-observer/test/xpcshell/test_throttle.js162
-rw-r--r--devtools/shared/network-observer/test/xpcshell/xpcshell.toml22
36 files changed, 6687 insertions, 0 deletions
diff --git a/devtools/shared/network-observer/ChannelMap.sys.mjs b/devtools/shared/network-observer/ChannelMap.sys.mjs
new file mode 100644
index 0000000000..3c54b1171a
--- /dev/null
+++ b/devtools/shared/network-observer/ChannelMap.sys.mjs
@@ -0,0 +1,129 @@
+/* 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/. */
+
+/**
+ * FinalizationRegistry callback, see
+ * https://searchfox.org/mozilla-central/source/js/src/builtin/FinalizationRegistryObject.h
+ *
+ * Will be invoked when the channel corresponding to the weak reference is
+ * "destroyed", at which point we can cleanup the corresponding entry in our
+ * regular map.
+ */
+function deleteIdFromRefMap({ refMap, id }) {
+ refMap.delete(id);
+}
+
+/**
+ * This object implements iterable weak map for HTTP channels tracked by
+ * the network observer.
+ *
+ * We can't use Map() for storing HTTP channel references since we don't
+ * know when we should remove the entry in it (it's wrong to do it in
+ * 'onTransactionClose' since it doesn't have to be the last platform
+ * notification for a given channel). We want the map to auto update
+ * when the channel is garbage collected.
+ *
+ * We can't use WeakMap() since searching for a value by the channel object
+ * isn't reliable (there might be different objects representing the same
+ * channel). We need to search by channel ID, but ID can't be used as key
+ * in WeakMap().
+ *
+ * So, this custom map solves aforementioned issues.
+ */
+export class ChannelMap {
+ #finalizationRegistry;
+ #refMap;
+ #weakMap;
+
+ constructor() {
+ // See https://searchfox.org/mozilla-central/source/js/src/builtin/FinalizationRegistryObject.h
+ this.#finalizationRegistry = new FinalizationRegistry(deleteIdFromRefMap);
+
+ // Map of channel id to a channel weak reference.
+ this.#refMap = new Map();
+
+ /**
+ * WeakMap from nsIChannel instances to objects which encapsulate ChannelMap
+ * values with the following structure:
+ * @property {Object} value
+ * The actual value stored in this ChannelMap entry, which should relate
+ * to this channel.
+ * @property {WeakRef} ref
+ * Weak reference for the channel object which is the key of the entry.
+ */
+ this.#weakMap = new WeakMap();
+ }
+
+ /**
+ * Remove all entries from the ChannelMap.
+ */
+ clear() {
+ this.#refMap.clear();
+ }
+
+ /**
+ * Delete the entry for the provided channel from the underlying maps, if any.
+ * Note that this will only delete entries which were set for the exact same
+ * nsIChannel object, and will not attempt to look up entries by channel id.
+ *
+ * @param {nsIChannel} channel
+ * The key to delete from the ChannelMap.
+ *
+ * @return {boolean}
+ * True if an entry was deleted, false otherwise.
+ */
+ delete(channel) {
+ const entry = this.#weakMap.get(channel);
+ if (!entry) {
+ return false;
+ }
+
+ this.#weakMap.delete(channel);
+ this.#refMap.delete(channel.channelId);
+ this.#finalizationRegistry.unregister(entry.ref);
+ return true;
+ }
+
+ /**
+ * Retrieve a value stored in the ChannelMap by the provided channel.
+ *
+ * @param {nsIChannel} channel
+ * The key to delete from the ChannelMap.
+ *
+ * @return {Object|null}
+ * The value held for the provided channel.
+ * Null if the channel did not match any known key.
+ */
+ get(channel) {
+ const ref = this.#refMap.get(channel.channelId);
+ const key = ref ? ref.deref() : null;
+ if (!key) {
+ return null;
+ }
+ const channelInfo = this.#weakMap.get(key);
+ return channelInfo ? channelInfo.value : null;
+ }
+
+ /**
+ * Adds or updates an entry in the ChannelMap for the provided channel.
+ *
+ * @param {nsIChannel} channel
+ * The key of the entry to add or update.
+ * @param {Object} value
+ * The value to add or update.
+ */
+ set(channel, value) {
+ const ref = new WeakRef(channel);
+ this.#weakMap.set(channel, { value, ref });
+ this.#refMap.set(channel.channelId, ref);
+ this.#finalizationRegistry.register(
+ channel,
+ {
+ refMap: this.#refMap,
+ id: channel.channelId,
+ },
+ ref
+ );
+ }
+}
diff --git a/devtools/shared/network-observer/NetworkAuthListener.sys.mjs b/devtools/shared/network-observer/NetworkAuthListener.sys.mjs
new file mode 100644
index 0000000000..2ab5517aa1
--- /dev/null
+++ b/devtools/shared/network-observer/NetworkAuthListener.sys.mjs
@@ -0,0 +1,185 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+/**
+ * This class is a simplified version from the AuthRequestor used by the
+ * WebExtensions codebase at:
+ * https://searchfox.org/mozilla-central/rev/fd2325f5b2a5be8f8f2acf9307285f2b7de06582/toolkit/components/extensions/webrequest/WebRequest.sys.mjs#434-579
+ *
+ * The NetworkAuthListener will monitor the provided channel and will invoke the
+ * owner's `onAuthPrompt` end point whenever an auth challenge is requested.
+ *
+ * The owner will receive several callbacks to proceed with the prompt:
+ * - cancelAuthPrompt(): cancel the authentication attempt
+ * - forwardAuthPrompt(): forward the auth prompt request to the next
+ * notification callback. If no other custom callback is set, this will
+ * typically lead to show the auth prompt dialog in the browser UI.
+ * - provideAuthCredentials(username, password): attempt to authenticate with
+ * the provided username and password.
+ *
+ * Please note that the request will be blocked until the consumer calls one of
+ * the callbacks listed above. Make sure to eventually unblock the request if
+ * you implement `onAuthPrompt`.
+ *
+ * @param {nsIChannel} channel
+ * The channel to monitor.
+ * @param {object} owner
+ * The owner object, expected to implement `onAuthPrompt`.
+ */
+export class NetworkAuthListener {
+ constructor(channel, owner) {
+ this.notificationCallbacks = channel.notificationCallbacks;
+ this.loadGroupCallbacks =
+ channel.loadGroup && channel.loadGroup.notificationCallbacks;
+ this.owner = owner;
+
+ // Setup the channel's notificationCallbacks to be handled by this instance.
+ channel.notificationCallbacks = this;
+ }
+
+ // See https://searchfox.org/mozilla-central/source/netwerk/base/nsIAuthPrompt2.idl
+ asyncPromptAuth(channel, callback, context, level, authInfo) {
+ const isProxy = !!(authInfo.flags & authInfo.AUTH_PROXY);
+ const cancelAuthPrompt = () => {
+ if (channel.canceled) {
+ return;
+ }
+
+ try {
+ callback.onAuthCancelled(context, false);
+ } catch (e) {
+ console.error(`NetworkAuthListener failed to cancel auth prompt ${e}`);
+ }
+ };
+
+ const forwardAuthPrompt = () => {
+ if (channel.canceled) {
+ return;
+ }
+
+ const prompt = this.#getForwardPrompt(isProxy);
+ prompt.asyncPromptAuth(channel, callback, context, level, authInfo);
+ };
+
+ const provideAuthCredentials = (username, password) => {
+ if (channel.canceled) {
+ return;
+ }
+
+ authInfo.username = username;
+ authInfo.password = password;
+ try {
+ callback.onAuthAvailable(context, authInfo);
+ } catch (e) {
+ console.error(
+ `NetworkAuthListener failed to provide auth credentials ${e}`
+ );
+ }
+ };
+
+ const authDetails = {
+ isProxy,
+ realm: authInfo.realm,
+ scheme: authInfo.authenticationScheme,
+ };
+ const authCallbacks = {
+ cancelAuthPrompt,
+ forwardAuthPrompt,
+ provideAuthCredentials,
+ };
+
+ // The auth callbacks may only be called asynchronously after this method
+ // successfully returned.
+ lazy.setTimeout(() => this.#notifyOwner(authDetails, authCallbacks), 1);
+
+ return {
+ QueryInterface: ChromeUtils.generateQI(["nsICancelable"]),
+ cancel: cancelAuthPrompt,
+ };
+ }
+
+ getInterface(iid) {
+ if (iid.equals(Ci.nsIAuthPromptProvider) || iid.equals(Ci.nsIAuthPrompt2)) {
+ return this;
+ }
+ try {
+ return this.notificationCallbacks.getInterface(iid);
+ } catch (e) {}
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+
+ // See https://searchfox.org/mozilla-central/source/netwerk/base/nsIAuthPromptProvider.idl
+ getAuthPrompt(reason, iid) {
+ // This should never get called without getInterface having been called first.
+ if (iid.equals(Ci.nsIAuthPrompt2)) {
+ return this;
+ }
+ return this.#getForwardedInterface(Ci.nsIAuthPromptProvider).getAuthPrompt(
+ reason,
+ iid
+ );
+ }
+
+ // See https://searchfox.org/mozilla-central/source/netwerk/base/nsIAuthPrompt2.idl
+ promptAuth(channel, level, authInfo) {
+ this.#getForwardedInterface(Ci.nsIAuthPrompt2).promptAuth(
+ channel,
+ level,
+ authInfo
+ );
+ }
+
+ #getForwardedInterface(iid) {
+ try {
+ return this.notificationCallbacks.getInterface(iid);
+ } catch (e) {
+ return this.loadGroupCallbacks.getInterface(iid);
+ }
+ }
+
+ #getForwardPrompt(isProxy) {
+ const reason = isProxy
+ ? Ci.nsIAuthPromptProvider.PROMPT_PROXY
+ : Ci.nsIAuthPromptProvider.PROMPT_NORMAL;
+ for (const callbacks of [
+ this.notificationCallbacks,
+ this.loadGroupCallbacks,
+ ]) {
+ try {
+ return callbacks
+ .getInterface(Ci.nsIAuthPromptProvider)
+ .getAuthPrompt(reason, Ci.nsIAuthPrompt2);
+ } catch (e) {}
+ try {
+ return callbacks.getInterface(Ci.nsIAuthPrompt2);
+ } catch (e) {}
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+
+ #notifyOwner(authDetails, authCallbacks) {
+ if (typeof this.owner.onAuthPrompt == "function") {
+ this.owner.onAuthPrompt(authDetails, authCallbacks);
+ } else {
+ console.error(
+ "NetworkObserver owner enabled the auth prompt listener " +
+ "but does not implement 'onAuthPrompt'. " +
+ "Forwarding the auth prompt to the next notification callback."
+ );
+ authCallbacks.forwardAuthPrompt();
+ }
+ }
+}
+
+NetworkAuthListener.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIInterfaceRequestor",
+ "nsIAuthPromptProvider",
+ "nsIAuthPrompt2",
+]);
diff --git a/devtools/shared/network-observer/NetworkHelper.sys.mjs b/devtools/shared/network-observer/NetworkHelper.sys.mjs
new file mode 100644
index 0000000000..f225e51e08
--- /dev/null
+++ b/devtools/shared/network-observer/NetworkHelper.sys.mjs
@@ -0,0 +1,913 @@
+/* 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/. */
+
+/*
+ * Software License Agreement (BSD License)
+ *
+ * Copyright (c) 2007, Parakey Inc.
+ * All rights reserved.
+ *
+ * Redistribution and use of this software in source and binary forms,
+ * with or without modification, are permitted provided that the
+ * following conditions are met:
+ *
+ * * Redistributions of source code must retain the above
+ * copyright notice, this list of conditions and the
+ * following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the
+ * following disclaimer in the documentation and/or other
+ * materials provided with the distribution.
+ *
+ * * Neither the name of Parakey Inc. nor the names of its
+ * contributors may be used to endorse or promote products
+ * derived from this software without specific prior
+ * written permission of Parakey Inc.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ * OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/*
+ * Creator:
+ * Joe Hewitt
+ * Contributors
+ * John J. Barton (IBM Almaden)
+ * Jan Odvarko (Mozilla Corp.)
+ * Max Stepanov (Aptana Inc.)
+ * Rob Campbell (Mozilla Corp.)
+ * Hans Hillen (Paciello Group, Mozilla)
+ * Curtis Bartley (Mozilla Corp.)
+ * Mike Collins (IBM Almaden)
+ * Kevin Decker
+ * Mike Ratcliffe (Comartis AG)
+ * Hernan Rodríguez Colmeiro
+ * Austin Andrews
+ * Christoph Dorn
+ * Steven Roussey (AppCenter Inc, Network54)
+ * Mihai Sucan (Mozilla Corp.)
+ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DevToolsInfaillibleUtils:
+ "resource://devtools/shared/DevToolsInfaillibleUtils.sys.mjs",
+
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+});
+
+// It would make sense to put this in the above
+// ChromeUtils.defineESModuleGetters, but that doesn't seem to work.
+ChromeUtils.defineLazyGetter(lazy, "certDecoder", () => {
+ const { parse, pemToDER } = ChromeUtils.importESModule(
+ "chrome://global/content/certviewer/certDecoder.mjs"
+ );
+ return { parse, pemToDER };
+});
+
+// "Lax", "Strict" and "None" are special values of the SameSite cookie
+// attribute that should not be translated.
+const COOKIE_SAMESITE = {
+ LAX: "Lax",
+ STRICT: "Strict",
+ NONE: "None",
+};
+
+/**
+ * Helper object for networking stuff.
+ *
+ * Most of the following functions have been taken from the Firebug source. They
+ * have been modified to match the Firefox coding rules.
+ */
+export var NetworkHelper = {
+ /**
+ * Converts text with a given charset to unicode.
+ *
+ * @param string text
+ * Text to convert.
+ * @param string charset
+ * Charset to convert the text to.
+ * @returns string
+ * Converted text.
+ */
+ convertToUnicode(text, charset) {
+ // FIXME: We need to throw when text can't be converted e.g. the contents of
+ // an image. Until we have a way to do so with TextEncoder and TextDecoder
+ // we need to use nsIScriptableUnicodeConverter instead.
+ const conv = Cc[
+ "@mozilla.org/intl/scriptableunicodeconverter"
+ ].createInstance(Ci.nsIScriptableUnicodeConverter);
+ try {
+ conv.charset = charset || "UTF-8";
+ return conv.ConvertToUnicode(text);
+ } catch (ex) {
+ return text;
+ }
+ },
+
+ /**
+ * Reads all available bytes from stream and converts them to charset.
+ *
+ * @param nsIInputStream stream
+ * @param string charset
+ * @returns string
+ * UTF-16 encoded string based on the content of stream and charset.
+ */
+ readAndConvertFromStream(stream, charset) {
+ let text = null;
+ try {
+ text = lazy.NetUtil.readInputStreamToString(stream, stream.available());
+ return this.convertToUnicode(text, charset);
+ } catch (err) {
+ return text;
+ }
+ },
+
+ /**
+ * Reads the posted text from request.
+ *
+ * @param nsIHttpChannel request
+ * @param string charset
+ * The content document charset, used when reading the POSTed data.
+ * @returns string or null
+ * Returns the posted string if it was possible to read from request
+ * otherwise null.
+ */
+ readPostTextFromRequest(request, charset) {
+ if (request instanceof Ci.nsIUploadChannel) {
+ const iStream = request.uploadStream;
+
+ let isSeekableStream = false;
+ if (iStream instanceof Ci.nsISeekableStream) {
+ isSeekableStream = true;
+ }
+
+ let prevOffset;
+ if (isSeekableStream) {
+ prevOffset = iStream.tell();
+ iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0);
+ }
+
+ // Read data from the stream.
+ const text = this.readAndConvertFromStream(iStream, charset);
+
+ // Seek locks the file, so seek to the beginning only if necko hasn't
+ // read it yet, since necko doesn't seek to 0 before reading (at lest
+ // not till 459384 is fixed).
+ if (isSeekableStream && prevOffset == 0) {
+ iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0);
+ }
+ return text;
+ }
+ return null;
+ },
+
+ /**
+ * Reads the posted text from the page's cache.
+ *
+ * @param nsIDocShell docShell
+ * @param string charset
+ * @returns string or null
+ * Returns the posted string if it was possible to read from
+ * docShell otherwise null.
+ */
+ readPostTextFromPage(docShell, charset) {
+ const webNav = docShell.QueryInterface(Ci.nsIWebNavigation);
+ return this.readPostTextFromPageViaWebNav(webNav, charset);
+ },
+
+ /**
+ * Reads the posted text from the page's cache, given an nsIWebNavigation
+ * object.
+ *
+ * @param nsIWebNavigation webNav
+ * @param string charset
+ * @returns string or null
+ * Returns the posted string if it was possible to read from
+ * webNav, otherwise null.
+ */
+ readPostTextFromPageViaWebNav(webNav, charset) {
+ if (webNav instanceof Ci.nsIWebPageDescriptor) {
+ const descriptor = webNav.currentDescriptor;
+
+ if (
+ descriptor instanceof Ci.nsISHEntry &&
+ descriptor.postData &&
+ descriptor instanceof Ci.nsISeekableStream
+ ) {
+ descriptor.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0);
+
+ return this.readAndConvertFromStream(descriptor, charset);
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Gets the topFrameElement that is associated with request. This
+ * works in single-process and multiprocess contexts. It may cross
+ * the content/chrome boundary.
+ *
+ * @param nsIHttpChannel request
+ * @returns Element|null
+ * The top frame element for the given request.
+ */
+ getTopFrameForRequest(request) {
+ try {
+ return this.getRequestLoadContext(request).topFrameElement;
+ } catch (ex) {
+ // request loadContext is not always available.
+ }
+ return null;
+ },
+
+ /**
+ * Gets the nsIDOMWindow that is associated with request.
+ *
+ * @param nsIHttpChannel request
+ * @returns nsIDOMWindow or null
+ */
+ getWindowForRequest(request) {
+ try {
+ return this.getRequestLoadContext(request).associatedWindow;
+ } catch (ex) {
+ // On some request notificationCallbacks and loadGroup are both null,
+ // so that we can't retrieve any nsILoadContext interface.
+ // Fallback on nsILoadInfo to try to retrieve the request's window.
+ // (this is covered by test_network_get.html and its CSS request)
+ return request.loadInfo.loadingDocument?.defaultView;
+ }
+ },
+
+ /**
+ * Gets the nsILoadContext that is associated with request.
+ *
+ * @param nsIHttpChannel request
+ * @returns nsILoadContext or null
+ */
+ getRequestLoadContext(request) {
+ try {
+ if (request.loadInfo.workerAssociatedBrowsingContext) {
+ return request.loadInfo.workerAssociatedBrowsingContext;
+ }
+ } catch (ex) {
+ // Ignore.
+ }
+ try {
+ return request.notificationCallbacks.getInterface(Ci.nsILoadContext);
+ } catch (ex) {
+ // Ignore.
+ }
+
+ try {
+ return request.loadGroup.notificationCallbacks.getInterface(
+ Ci.nsILoadContext
+ );
+ } catch (ex) {
+ // Ignore.
+ }
+
+ return null;
+ },
+
+ /**
+ * Determines whether the request has been made for the top level document.
+ *
+ * @param nsIHttpChannel request
+ * @returns Boolean True if the request represents the top level document.
+ */
+ isTopLevelLoad(request) {
+ if (request instanceof Ci.nsIChannel) {
+ const loadInfo = request.loadInfo;
+ if (loadInfo?.isTopLevelLoad) {
+ return request.loadFlags & Ci.nsIChannel.LOAD_DOCUMENT_URI;
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * Loads the content of url from the cache.
+ *
+ * @param string url
+ * URL to load the cached content for.
+ * @param string charset
+ * Assumed charset of the cached content. Used if there is no charset
+ * on the channel directly.
+ * @param function callback
+ * Callback that is called with the loaded cached content if available
+ * or null if something failed while getting the cached content.
+ */
+ loadFromCache(url, charset, callback) {
+ const channel = lazy.NetUtil.newChannel({
+ uri: url,
+ loadUsingSystemPrincipal: true,
+ });
+
+ // Ensure that we only read from the cache and not the server.
+ channel.loadFlags =
+ Ci.nsIRequest.LOAD_FROM_CACHE |
+ Ci.nsICachingChannel.LOAD_ONLY_FROM_CACHE |
+ Ci.nsICachingChannel.LOAD_BYPASS_LOCAL_CACHE_IF_BUSY;
+
+ lazy.NetUtil.asyncFetch(channel, (inputStream, statusCode, request) => {
+ if (!Components.isSuccessCode(statusCode)) {
+ callback(null);
+ return;
+ }
+
+ // Try to get the encoding from the channel. If there is none, then use
+ // the passed assumed charset.
+ const requestChannel = request.QueryInterface(Ci.nsIChannel);
+ const contentCharset = requestChannel.contentCharset || charset;
+
+ // Read the content of the stream using contentCharset as encoding.
+ callback(this.readAndConvertFromStream(inputStream, contentCharset));
+ });
+ },
+
+ /**
+ * Parse a raw Cookie header value.
+ *
+ * @param string header
+ * The raw Cookie header value.
+ * @return array
+ * Array holding an object for each cookie. Each object holds the
+ * following properties: name and value.
+ */
+ parseCookieHeader(header) {
+ const cookies = header.split(";");
+ const result = [];
+
+ cookies.forEach(function (cookie) {
+ const equal = cookie.indexOf("=");
+ const name = cookie.substr(0, equal);
+ const value = cookie.substr(equal + 1);
+ result.push({
+ name: unescape(name.trim()),
+ value: unescape(value.trim()),
+ });
+ });
+
+ return result;
+ },
+
+ /**
+ * Parse a raw Set-Cookie header value.
+ *
+ * @param array headers
+ * Array of raw Set-Cookie header values.
+ * @return array
+ * Array holding an object for each cookie. Each object holds the
+ * following properties: name, value, secure (boolean), httpOnly
+ * (boolean), path, domain, samesite and expires (ISO date string).
+ */
+ parseSetCookieHeaders(headers) {
+ function parseSameSiteAttribute(attribute) {
+ attribute = attribute.toLowerCase();
+ switch (attribute) {
+ case COOKIE_SAMESITE.LAX.toLowerCase():
+ return COOKIE_SAMESITE.LAX;
+ case COOKIE_SAMESITE.STRICT.toLowerCase():
+ return COOKIE_SAMESITE.STRICT;
+ default:
+ return COOKIE_SAMESITE.NONE;
+ }
+ }
+
+ const cookies = [];
+
+ for (const header of headers) {
+ const rawCookies = header.split(/\r\n|\n|\r/);
+
+ rawCookies.forEach(function (cookie) {
+ const equal = cookie.indexOf("=");
+ const name = unescape(cookie.substr(0, equal).trim());
+ const parts = cookie.substr(equal + 1).split(";");
+ const value = unescape(parts.shift().trim());
+
+ cookie = { name, value };
+
+ parts.forEach(function (part) {
+ part = part.trim();
+ if (part.toLowerCase() == "secure") {
+ cookie.secure = true;
+ } else if (part.toLowerCase() == "httponly") {
+ cookie.httpOnly = true;
+ } else if (part.indexOf("=") > -1) {
+ const pair = part.split("=");
+ pair[0] = pair[0].toLowerCase();
+ if (pair[0] == "path" || pair[0] == "domain") {
+ cookie[pair[0]] = pair[1];
+ } else if (pair[0] == "samesite") {
+ cookie[pair[0]] = parseSameSiteAttribute(pair[1]);
+ } else if (pair[0] == "expires") {
+ try {
+ pair[1] = pair[1].replace(/-/g, " ");
+ cookie.expires = new Date(pair[1]).toISOString();
+ } catch (ex) {
+ // Ignore.
+ }
+ }
+ }
+ });
+
+ cookies.push(cookie);
+ });
+ }
+
+ return cookies;
+ },
+
+ // This is a list of all the mime category maps jviereck could find in the
+ // firebug code base.
+ mimeCategoryMap: {
+ "text/plain": "txt",
+ "text/html": "html",
+ "text/xml": "xml",
+ "text/xsl": "txt",
+ "text/xul": "txt",
+ "text/css": "css",
+ "text/sgml": "txt",
+ "text/rtf": "txt",
+ "text/x-setext": "txt",
+ "text/richtext": "txt",
+ "text/javascript": "js",
+ "text/jscript": "txt",
+ "text/tab-separated-values": "txt",
+ "text/rdf": "txt",
+ "text/xif": "txt",
+ "text/ecmascript": "js",
+ "text/vnd.curl": "txt",
+ "text/x-json": "json",
+ "text/x-js": "txt",
+ "text/js": "txt",
+ "text/vbscript": "txt",
+ "view-source": "txt",
+ "view-fragment": "txt",
+ "application/xml": "xml",
+ "application/xhtml+xml": "xml",
+ "application/atom+xml": "xml",
+ "application/rss+xml": "xml",
+ "application/vnd.mozilla.maybe.feed": "xml",
+ "application/javascript": "js",
+ "application/x-javascript": "js",
+ "application/x-httpd-php": "txt",
+ "application/rdf+xml": "xml",
+ "application/ecmascript": "js",
+ "application/http-index-format": "txt",
+ "application/json": "json",
+ "application/x-js": "txt",
+ "application/x-mpegurl": "txt",
+ "application/vnd.apple.mpegurl": "txt",
+ "multipart/mixed": "txt",
+ "multipart/x-mixed-replace": "txt",
+ "image/svg+xml": "svg",
+ "application/octet-stream": "bin",
+ "image/jpeg": "image",
+ "image/jpg": "image",
+ "image/gif": "image",
+ "image/png": "image",
+ "image/bmp": "image",
+ "application/x-shockwave-flash": "flash",
+ "video/x-flv": "flash",
+ "audio/mpeg3": "media",
+ "audio/x-mpeg-3": "media",
+ "video/mpeg": "media",
+ "video/x-mpeg": "media",
+ "video/vnd.mpeg.dash.mpd": "xml",
+ "audio/ogg": "media",
+ "application/ogg": "media",
+ "application/x-ogg": "media",
+ "application/x-midi": "media",
+ "audio/midi": "media",
+ "audio/x-mid": "media",
+ "audio/x-midi": "media",
+ "music/crescendo": "media",
+ "audio/wav": "media",
+ "audio/x-wav": "media",
+ "text/json": "json",
+ "application/x-json": "json",
+ "application/json-rpc": "json",
+ "application/x-web-app-manifest+json": "json",
+ "application/manifest+json": "json",
+ },
+
+ /**
+ * Check if the given MIME type is a text-only MIME type.
+ *
+ * @param string mimeType
+ * @return boolean
+ */
+ isTextMimeType(mimeType) {
+ if (mimeType.indexOf("text/") == 0) {
+ return true;
+ }
+
+ // XML and JSON often come with custom MIME types, so in addition to the
+ // standard "application/xml" and "application/json", we also look for
+ // variants like "application/x-bigcorp+xml". For JSON we allow "+json" and
+ // "-json" as suffixes.
+ if (/^application\/\w+(?:[\.-]\w+)*(?:\+xml|[-+]json)$/.test(mimeType)) {
+ return true;
+ }
+
+ const category = this.mimeCategoryMap[mimeType] || null;
+ switch (category) {
+ case "txt":
+ case "js":
+ case "json":
+ case "css":
+ case "html":
+ case "svg":
+ case "xml":
+ return true;
+
+ default:
+ return false;
+ }
+ },
+
+ /**
+ * Takes a securityInfo object of nsIRequest, the nsIRequest itself and
+ * extracts security information from them.
+ *
+ * @param object securityInfo
+ * The securityInfo object of a request. If null channel is assumed
+ * to be insecure.
+ * @param object originAttributes
+ * The OriginAttributes of the request.
+ * @param object httpActivity
+ * The httpActivity object for the request with at least members
+ * { private, hostname }.
+ * @param Map decodedCertificateCache
+ * A Map of certificate fingerprints to decoded certificates, to avoid
+ * repeatedly decoding previously-seen certificates.
+ *
+ * @return object
+ * Returns an object containing following members:
+ * - state: The security of the connection used to fetch this
+ * request. Has one of following string values:
+ * * "insecure": the connection was not secure (only http)
+ * * "weak": the connection has minor security issues
+ * * "broken": secure connection failed (e.g. expired cert)
+ * * "secure": the connection was properly secured.
+ * If state == broken:
+ * - errorMessage: error code string.
+ * If state == secure:
+ * - protocolVersion: one of TLSv1, TLSv1.1, TLSv1.2, TLSv1.3.
+ * - cipherSuite: the cipher suite used in this connection.
+ * - cert: information about certificate used in this connection.
+ * See parseCertificateInfo for the contents.
+ * - hsts: true if host uses Strict Transport Security,
+ * false otherwise
+ * - hpkp: true if host uses Public Key Pinning, false otherwise
+ * If state == weak: Same as state == secure and
+ * - weaknessReasons: list of reasons that cause the request to be
+ * considered weak. See getReasonsForWeakness.
+ */
+ async parseSecurityInfo(
+ securityInfo,
+ originAttributes,
+ httpActivity,
+ decodedCertificateCache
+ ) {
+ const info = {
+ state: "insecure",
+ };
+
+ // The request did not contain any security info.
+ if (!securityInfo) {
+ return info;
+ }
+
+ /**
+ * Different scenarios to consider here and how they are handled:
+ * - request is HTTP, the connection is not secure
+ * => securityInfo is null
+ * => state === "insecure"
+ *
+ * - request is HTTPS, the connection is secure
+ * => .securityState has STATE_IS_SECURE flag
+ * => state === "secure"
+ *
+ * - request is HTTPS, the connection has security issues
+ * => .securityState has STATE_IS_INSECURE flag
+ * => .errorCode is an NSS error code.
+ * => state === "broken"
+ *
+ * - request is HTTPS, the connection was terminated before the security
+ * could be validated
+ * => .securityState has STATE_IS_INSECURE flag
+ * => .errorCode is NOT an NSS error code.
+ * => .errorMessage is not available.
+ * => state === "insecure"
+ *
+ * - request is HTTPS but it uses a weak cipher or old protocol, see
+ * https://hg.mozilla.org/mozilla-central/annotate/def6ed9d1c1a/
+ * security/manager/ssl/nsNSSCallbacks.cpp#l1233
+ * - request is mixed content (which makes no sense whatsoever)
+ * => .securityState has STATE_IS_BROKEN flag
+ * => .errorCode is NOT an NSS error code
+ * => .errorMessage is not available
+ * => state === "weak"
+ */
+
+ const wpl = Ci.nsIWebProgressListener;
+ const NSSErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ if (!NSSErrorsService.isNSSErrorCode(securityInfo.errorCode)) {
+ const state = securityInfo.securityState;
+
+ let uri = null;
+ if (httpActivity.channel?.URI) {
+ uri = httpActivity.channel.URI;
+ }
+ if (uri && !uri.schemeIs("https") && !uri.schemeIs("wss")) {
+ // it is not enough to look at the transport security info -
+ // schemes other than https and wss are subject to
+ // downgrade/etc at the scheme level and should always be
+ // considered insecure
+ info.state = "insecure";
+ } else if (state & wpl.STATE_IS_SECURE) {
+ // The connection is secure if the scheme is sufficient
+ info.state = "secure";
+ } else if (state & wpl.STATE_IS_BROKEN) {
+ // The connection is not secure, there was no error but there's some
+ // minor security issues.
+ info.state = "weak";
+ info.weaknessReasons = this.getReasonsForWeakness(state);
+ } else if (state & wpl.STATE_IS_INSECURE) {
+ // This was most likely an https request that was aborted before
+ // validation. Return info as info.state = insecure.
+ return info;
+ } else {
+ lazy.DevToolsInfaillibleUtils.reportException(
+ "NetworkHelper.parseSecurityInfo",
+ "Security state " + state + " has no known STATE_IS_* flags."
+ );
+ return info;
+ }
+
+ // Cipher suite.
+ info.cipherSuite = securityInfo.cipherName;
+
+ // Key exchange group name.
+ info.keaGroupName = securityInfo.keaGroupName;
+
+ // Certificate signature scheme.
+ info.signatureSchemeName = securityInfo.signatureSchemeName;
+
+ // Protocol version.
+ info.protocolVersion = this.formatSecurityProtocol(
+ securityInfo.protocolVersion
+ );
+
+ // Certificate.
+ info.cert = await this.parseCertificateInfo(
+ securityInfo.serverCert,
+ decodedCertificateCache
+ );
+
+ // Certificate transparency status.
+ info.certificateTransparency = securityInfo.certificateTransparencyStatus;
+
+ // HSTS and HPKP if available.
+ if (httpActivity.hostname) {
+ const sss = Cc["@mozilla.org/ssservice;1"].getService(
+ Ci.nsISiteSecurityService
+ );
+ const pkps = Cc[
+ "@mozilla.org/security/publickeypinningservice;1"
+ ].getService(Ci.nsIPublicKeyPinningService);
+
+ if (!uri) {
+ // isSecureURI only cares about the host, not the scheme.
+ const host = httpActivity.hostname;
+ uri = Services.io.newURI("https://" + host);
+ }
+
+ info.hsts = sss.isSecureURI(uri, originAttributes);
+ info.hpkp = pkps.hostHasPins(uri);
+ } else {
+ lazy.DevToolsInfaillibleUtils.reportException(
+ "NetworkHelper.parseSecurityInfo",
+ "Could not get HSTS/HPKP status as hostname is not available."
+ );
+ info.hsts = false;
+ info.hpkp = false;
+ }
+ } else {
+ // The connection failed.
+ info.state = "broken";
+ info.errorMessage = securityInfo.errorCodeString;
+ }
+
+ // These values can be unset in rare cases, e.g. when stashed connection
+ // data is deseralized from an older version of Firefox.
+ try {
+ info.usedEch = securityInfo.isAcceptedEch;
+ } catch {
+ info.usedEch = false;
+ }
+ try {
+ info.usedDelegatedCredentials = securityInfo.isDelegatedCredential;
+ } catch {
+ info.usedDelegatedCredentials = false;
+ }
+ info.usedOcsp = securityInfo.madeOCSPRequests;
+ info.usedPrivateDns = securityInfo.usedPrivateDNS;
+
+ return info;
+ },
+
+ /**
+ * Takes an nsIX509Cert and returns an object with certificate information.
+ *
+ * @param nsIX509Cert cert
+ * The certificate to extract the information from.
+ * @param Map decodedCertificateCache
+ * A Map of certificate fingerprints to decoded certificates, to avoid
+ * repeatedly decoding previously-seen certificates.
+ * @return object
+ * An object with following format:
+ * {
+ * subject: { commonName, organization, organizationalUnit },
+ * issuer: { commonName, organization, organizationUnit },
+ * validity: { start, end },
+ * fingerprint: { sha1, sha256 }
+ * }
+ */
+ async parseCertificateInfo(cert, decodedCertificateCache) {
+ function getDNComponent(dn, componentType) {
+ for (const [type, value] of dn.entries) {
+ if (type == componentType) {
+ return value;
+ }
+ }
+ return undefined;
+ }
+
+ const info = {};
+ if (cert) {
+ const certHash = cert.sha256Fingerprint;
+ let parsedCert = decodedCertificateCache.get(certHash);
+ if (!parsedCert) {
+ parsedCert = await lazy.certDecoder.parse(
+ lazy.certDecoder.pemToDER(cert.getBase64DERString())
+ );
+ decodedCertificateCache.set(certHash, parsedCert);
+ }
+ info.subject = {
+ commonName: getDNComponent(parsedCert.subject, "Common Name"),
+ organization: getDNComponent(parsedCert.subject, "Organization"),
+ organizationalUnit: getDNComponent(
+ parsedCert.subject,
+ "Organizational Unit"
+ ),
+ };
+
+ info.issuer = {
+ commonName: getDNComponent(parsedCert.issuer, "Common Name"),
+ organization: getDNComponent(parsedCert.issuer, "Organization"),
+ organizationUnit: getDNComponent(
+ parsedCert.issuer,
+ "Organizational Unit"
+ ),
+ };
+
+ info.validity = {
+ start: parsedCert.notBeforeUTC,
+ end: parsedCert.notAfterUTC,
+ };
+
+ info.fingerprint = {
+ sha1: parsedCert.fingerprint.sha1,
+ sha256: parsedCert.fingerprint.sha256,
+ };
+ } else {
+ lazy.DevToolsInfaillibleUtils.reportException(
+ "NetworkHelper.parseCertificateInfo",
+ "Secure connection established without certificate."
+ );
+ }
+
+ return info;
+ },
+
+ /**
+ * Takes protocolVersion of TransportSecurityInfo object and returns
+ * human readable description.
+ *
+ * @param Number version
+ * One of nsITransportSecurityInfo version constants.
+ * @return string
+ * One of TLSv1, TLSv1.1, TLSv1.2, TLSv1.3 if @param version
+ * is valid, Unknown otherwise.
+ */
+ formatSecurityProtocol(version) {
+ switch (version) {
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1:
+ return "TLSv1";
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1_1:
+ return "TLSv1.1";
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1_2:
+ return "TLSv1.2";
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1_3:
+ return "TLSv1.3";
+ default:
+ lazy.DevToolsInfaillibleUtils.reportException(
+ "NetworkHelper.formatSecurityProtocol",
+ "protocolVersion " + version + " is unknown."
+ );
+ return "Unknown";
+ }
+ },
+
+ /**
+ * Takes the securityState bitfield and returns reasons for weak connection
+ * as an array of strings.
+ *
+ * @param Number state
+ * nsITransportSecurityInfo.securityState.
+ *
+ * @return Array[String]
+ * List of weakness reasons. A subset of { cipher } where
+ * * cipher: The cipher suite is consireded to be weak (RC4).
+ */
+ getReasonsForWeakness(state) {
+ const wpl = Ci.nsIWebProgressListener;
+
+ // If there's non-fatal security issues the request has STATE_IS_BROKEN
+ // flag set. See https://hg.mozilla.org/mozilla-central/file/44344099d119
+ // /security/manager/ssl/nsNSSCallbacks.cpp#l1233
+ const reasons = [];
+
+ if (state & wpl.STATE_IS_BROKEN) {
+ const isCipher = state & wpl.STATE_USES_WEAK_CRYPTO;
+
+ if (isCipher) {
+ reasons.push("cipher");
+ }
+
+ if (!isCipher) {
+ lazy.DevToolsInfaillibleUtils.reportException(
+ "NetworkHelper.getReasonsForWeakness",
+ "STATE_IS_BROKEN without a known reason. Full state was: " + state
+ );
+ }
+ }
+
+ return reasons;
+ },
+
+ /**
+ * Parse a url's query string into its components
+ *
+ * @param string queryString
+ * The query part of a url
+ * @return array
+ * Array of query params {name, value}
+ */
+ parseQueryString(queryString) {
+ // Make sure there's at least one param available.
+ // Be careful here, params don't necessarily need to have values, so
+ // no need to verify the existence of a "=".
+ if (!queryString) {
+ return null;
+ }
+
+ // Turn the params string into an array containing { name: value } tuples.
+ const paramsArray = queryString
+ .replace(/^[?&]/, "")
+ .split("&")
+ .map(e => {
+ const param = e.split("=");
+ return {
+ name: param[0]
+ ? NetworkHelper.convertToUnicode(unescape(param[0]))
+ : "",
+ value: param[1]
+ ? NetworkHelper.convertToUnicode(unescape(param[1]))
+ : "",
+ };
+ });
+
+ return paramsArray;
+ },
+};
diff --git a/devtools/shared/network-observer/NetworkObserver.sys.mjs b/devtools/shared/network-observer/NetworkObserver.sys.mjs
new file mode 100644
index 0000000000..35e66c9d5b
--- /dev/null
+++ b/devtools/shared/network-observer/NetworkObserver.sys.mjs
@@ -0,0 +1,1532 @@
+/* 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/. */
+
+/**
+ * NetworkObserver is the main class in DevTools to observe network requests
+ * out of many events fired by the platform code.
+ */
+
+// Enable logging all platform events this module listen to
+const DEBUG_PLATFORM_EVENTS = false;
+// Enables defining criteria to filter the logs
+const DEBUG_PLATFORM_EVENTS_FILTER = (eventName, channel) => {
+ // e.g return eventName == "HTTP_TRANSACTION:REQUEST_HEADER" && channel.URI.spec == "http://foo.com";
+ return true;
+};
+
+const lazy = {};
+
+import { DevToolsInfaillibleUtils } from "resource://devtools/shared/DevToolsInfaillibleUtils.sys.mjs";
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ChannelMap: "resource://devtools/shared/network-observer/ChannelMap.sys.mjs",
+ NetworkAuthListener:
+ "resource://devtools/shared/network-observer/NetworkAuthListener.sys.mjs",
+ NetworkHelper:
+ "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs",
+ NetworkOverride:
+ "resource://devtools/shared/network-observer/NetworkOverride.sys.mjs",
+ NetworkResponseListener:
+ "resource://devtools/shared/network-observer/NetworkResponseListener.sys.mjs",
+ NetworkThrottleManager:
+ "resource://devtools/shared/network-observer/NetworkThrottleManager.sys.mjs",
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+ wildcardToRegExp:
+ "resource://devtools/shared/network-observer/WildcardToRegexp.sys.mjs",
+});
+
+const gActivityDistributor = Cc[
+ "@mozilla.org/network/http-activity-distributor;1"
+].getService(Ci.nsIHttpActivityDistributor);
+
+function logPlatformEvent(eventName, channel, message = "") {
+ if (!DEBUG_PLATFORM_EVENTS) {
+ return;
+ }
+ if (DEBUG_PLATFORM_EVENTS_FILTER(eventName, channel)) {
+ dump(
+ `[netmonitor] ${channel.channelId} - ${eventName} ${message} - ${channel.URI.spec}\n`
+ );
+ }
+}
+
+// The maximum uint32 value.
+const PR_UINT32_MAX = 4294967295;
+
+const HTTP_TRANSACTION_CODES = {
+ 0x5001: "REQUEST_HEADER",
+ 0x5002: "REQUEST_BODY_SENT",
+ 0x5003: "RESPONSE_START",
+ 0x5004: "RESPONSE_HEADER",
+ 0x5005: "RESPONSE_COMPLETE",
+ 0x5006: "TRANSACTION_CLOSE",
+
+ 0x4b0003: "STATUS_RESOLVING",
+ 0x4b000b: "STATUS_RESOLVED",
+ 0x4b0007: "STATUS_CONNECTING_TO",
+ 0x4b0004: "STATUS_CONNECTED_TO",
+ 0x4b0005: "STATUS_SENDING_TO",
+ 0x4b000a: "STATUS_WAITING_FOR",
+ 0x4b0006: "STATUS_RECEIVING_FROM",
+ 0x4b000c: "STATUS_TLS_STARTING",
+ 0x4b000d: "STATUS_TLS_ENDING",
+};
+
+const HTTP_DOWNLOAD_ACTIVITIES = [
+ gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_START,
+ gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_HEADER,
+ gActivityDistributor.ACTIVITY_SUBTYPE_PROXY_RESPONSE_HEADER,
+ gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE,
+ gActivityDistributor.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE,
+];
+
+/**
+ * The network monitor uses the nsIHttpActivityDistributor to monitor network
+ * requests. The nsIObserverService is also used for monitoring
+ * http-on-examine-response notifications. All network request information is
+ * routed to the remote Web Console.
+ *
+ * @constructor
+ * @param {Object} options
+ * @param {Function(nsIChannel): boolean} options.ignoreChannelFunction
+ * This function will be called for every detected channel to decide if it
+ * should be monitored or not.
+ * @param {Function(NetworkEvent): owner} options.onNetworkEvent
+ * This method is invoked once for every new network request with two
+ * arguments:
+ * - {Object} networkEvent: object created by NetworkUtils:createNetworkEvent,
+ * containing initial network request information as an argument.
+ * - {nsIChannel} channel: the channel for which the request was detected
+ *
+ * `onNetworkEvent()` must return an "owner" object which holds several add*()
+ * methods which are used to add further network request/response information.
+ */
+export class NetworkObserver {
+ /**
+ * Map of URL patterns to RegExp
+ *
+ * @type {Map}
+ */
+ #blockedURLs = new Map();
+
+ /**
+ * Map of URL to local file path in order to redirect URL
+ * to local file overrides.
+ *
+ * This will replace the content of some request with the content of local files.
+ */
+ #overrides = new Map();
+
+ /**
+ * Used by NetworkHelper.parseSecurityInfo to skip decoding known certificates.
+ *
+ * @type {Map}
+ */
+ #decodedCertificateCache = new Map();
+ /**
+ * Whether the consumer supports listening and handling auth prompts.
+ *
+ * @type {boolean}
+ */
+ #authPromptListenerEnabled = false;
+ /**
+ * See constructor argument of the same name.
+ *
+ * @type {Function}
+ */
+ #ignoreChannelFunction;
+ /**
+ * Used to store channels intercepted for service-worker requests.
+ *
+ * @type {WeakSet}
+ */
+ #interceptedChannels = new WeakSet();
+ /**
+ * Explicit flag to check if this observer was already destroyed.
+ *
+ * @type {boolean}
+ */
+ #isDestroyed = false;
+ /**
+ * See constructor argument of the same name.
+ *
+ * @type {Function}
+ */
+ #onNetworkEvent;
+ /**
+ * Object that holds the activity objects for ongoing requests.
+ *
+ * @type {ChannelMap}
+ */
+ #openRequests = new lazy.ChannelMap();
+ /**
+ * Network response bodies are piped through a buffer of the given size
+ * (in bytes).
+ *
+ * @type {Number}
+ */
+ #responsePipeSegmentSize = Services.prefs.getIntPref(
+ "network.buffer.cache.size"
+ );
+ /**
+ * Whether to save the bodies of network requests and responses.
+ *
+ * @type {boolean}
+ */
+ #saveRequestAndResponseBodies = true;
+ /**
+ * Throttling configuration, see constructor of NetworkThrottleManager
+ *
+ * @type {Object}
+ */
+ #throttleData = null;
+ /**
+ * NetworkThrottleManager instance, created when a valid throttleData is set.
+ * @type {NetworkThrottleManager}
+ */
+ #throttler = null;
+
+ constructor(options = {}) {
+ const { ignoreChannelFunction, onNetworkEvent } = options;
+ if (typeof ignoreChannelFunction !== "function") {
+ throw new Error(
+ `Expected "ignoreChannelFunction" to be a function, got ${ignoreChannelFunction} (${typeof ignoreChannelFunction})`
+ );
+ }
+
+ if (typeof onNetworkEvent !== "function") {
+ throw new Error(
+ `Expected "onNetworkEvent" to be a function, got ${onNetworkEvent} (${typeof onNetworkEvent})`
+ );
+ }
+
+ this.#ignoreChannelFunction = ignoreChannelFunction;
+ this.#onNetworkEvent = onNetworkEvent;
+
+ // Start all platform observers.
+ if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT) {
+ gActivityDistributor.addObserver(this);
+ gActivityDistributor.observeProxyResponse = true;
+
+ Services.obs.addObserver(
+ this.#httpResponseExaminer,
+ "http-on-examine-response"
+ );
+ Services.obs.addObserver(
+ this.#httpResponseExaminer,
+ "http-on-examine-cached-response"
+ );
+ Services.obs.addObserver(
+ this.#httpModifyExaminer,
+ "http-on-modify-request"
+ );
+ Services.obs.addObserver(
+ this.#fileChannelExaminer,
+ "file-channel-opened"
+ );
+ Services.obs.addObserver(this.#httpStopRequest, "http-on-stop-request");
+ } else {
+ Services.obs.addObserver(
+ this.#httpFailedOpening,
+ "http-on-failed-opening-request"
+ );
+ }
+ // In child processes, only watch for service worker requests
+ // everything else only happens in the parent process
+ Services.obs.addObserver(
+ this.#serviceWorkerRequest,
+ "service-worker-synthesized-response"
+ );
+ }
+
+ setAuthPromptListenerEnabled(enabled) {
+ this.#authPromptListenerEnabled = enabled;
+ }
+
+ setSaveRequestAndResponseBodies(save) {
+ this.#saveRequestAndResponseBodies = save;
+ }
+
+ getThrottleData() {
+ return this.#throttleData;
+ }
+
+ setThrottleData(value) {
+ this.#throttleData = value;
+ // Clear out any existing throttlers
+ this.#throttler = null;
+ }
+
+ #getThrottler() {
+ if (this.#throttleData !== null && this.#throttler === null) {
+ this.#throttler = new lazy.NetworkThrottleManager(this.#throttleData);
+ }
+ return this.#throttler;
+ }
+
+ #serviceWorkerRequest = DevToolsInfaillibleUtils.makeInfallible(
+ (subject, topic, data) => {
+ const channel = subject.QueryInterface(Ci.nsIHttpChannel);
+
+ if (this.#ignoreChannelFunction(channel)) {
+ return;
+ }
+
+ logPlatformEvent(topic, channel);
+
+ this.#interceptedChannels.add(subject);
+
+ // Service workers never fire http-on-examine-cached-response, so fake one.
+ this.#httpResponseExaminer(channel, "http-on-examine-cached-response");
+ }
+ );
+
+ /**
+ * Observes for http-on-failed-opening-request notification to catch any
+ * channels for which asyncOpen has synchronously failed. This is the only
+ * place to catch early security check failures.
+ */
+ #httpFailedOpening = DevToolsInfaillibleUtils.makeInfallible(
+ (subject, topic) => {
+ if (
+ this.#isDestroyed ||
+ topic != "http-on-failed-opening-request" ||
+ !(subject instanceof Ci.nsIHttpChannel)
+ ) {
+ return;
+ }
+
+ const channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ if (this.#ignoreChannelFunction(channel)) {
+ return;
+ }
+
+ logPlatformEvent(topic, channel);
+
+ // Ignore preload requests to avoid duplicity request entries in
+ // the Network panel. If a preload fails (for whatever reason)
+ // then the platform kicks off another 'real' request.
+ if (lazy.NetworkUtils.isPreloadRequest(channel)) {
+ return;
+ }
+
+ this.#httpResponseExaminer(subject, topic);
+ }
+ );
+
+ #httpStopRequest = DevToolsInfaillibleUtils.makeInfallible(
+ (subject, topic) => {
+ if (
+ this.#isDestroyed ||
+ topic != "http-on-stop-request" ||
+ !(subject instanceof Ci.nsIHttpChannel)
+ ) {
+ return;
+ }
+
+ const channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ if (this.#ignoreChannelFunction(channel)) {
+ return;
+ }
+
+ logPlatformEvent(topic, channel);
+
+ const httpActivity = this.#createOrGetActivityObject(channel);
+ const serverTimings = this.#extractServerTimings(channel);
+
+ if (httpActivity.owner) {
+ // Try extracting server timings. Note that they will be sent to the client
+ // in the `_onTransactionClose` method together with network event timings.
+ httpActivity.owner.addServerTimings(serverTimings);
+
+ // If the owner isn't set we need to create the network event and send
+ // it to the client. This happens in case where:
+ // - the request has been blocked (e.g. CORS) and "http-on-stop-request" is the first notification.
+ // - the NetworkObserver is start *after* the request started and we only receive the http-stop notification,
+ // but that doesn't mean the request is blocked, so check for its status.
+ } else if (Components.isSuccessCode(channel.status)) {
+ // Do not pass any blocked reason, as this request is just fine.
+ // Bug 1489217 - Prevent watching for this request response content,
+ // as this request is already running, this is too late to watch for it.
+ this.#createNetworkEvent(subject, { inProgressRequest: true });
+ } else {
+ // Handles any early blockings e.g by Web Extensions or by CORS
+ const { blockingExtension, blockedReason } =
+ lazy.NetworkUtils.getBlockedReason(channel, httpActivity.fromCache);
+ this.#createNetworkEvent(subject, { blockedReason, blockingExtension });
+ }
+ }
+ );
+
+ /**
+ * Check if the current channel has its content being overriden
+ * by the content of some local file.
+ */
+ #checkForContentOverride(channel) {
+ const overridePath = this.#overrides.get(channel.URI.spec);
+ if (!overridePath) {
+ return false;
+ }
+
+ dump(" Override " + channel.URI.spec + " to " + overridePath + "\n");
+ try {
+ lazy.NetworkOverride.overrideChannelWithFilePath(channel, overridePath);
+ } catch (e) {
+ dump("Exception while trying to override request content: " + e + "\n");
+ }
+
+ return true;
+ }
+
+ /**
+ * Observe notifications for the http-on-examine-response topic, coming from
+ * the nsIObserverService.
+ *
+ * @private
+ * @param nsIHttpChannel subject
+ * @param string topic
+ * @returns void
+ */
+ #httpResponseExaminer = DevToolsInfaillibleUtils.makeInfallible(
+ (subject, topic) => {
+ // The httpResponseExaminer is used to retrieve the uncached response
+ // headers.
+ if (
+ this.#isDestroyed ||
+ (topic != "http-on-examine-response" &&
+ topic != "http-on-examine-cached-response" &&
+ topic != "http-on-failed-opening-request") ||
+ !(subject instanceof Ci.nsIHttpChannel) ||
+ !(subject instanceof Ci.nsIClassifiedChannel)
+ ) {
+ return;
+ }
+
+ const blockedOrFailed = topic === "http-on-failed-opening-request";
+
+ subject.QueryInterface(Ci.nsIClassifiedChannel);
+ const channel = subject.QueryInterface(Ci.nsIHttpChannel);
+
+ if (this.#ignoreChannelFunction(channel)) {
+ return;
+ }
+
+ logPlatformEvent(
+ topic,
+ subject,
+ blockedOrFailed
+ ? "blockedOrFailed:" + channel.loadInfo.requestBlockingReason
+ : channel.responseStatus
+ );
+
+ this.#checkForContentOverride(channel);
+
+ channel.QueryInterface(Ci.nsIHttpChannelInternal);
+
+ let httpActivity = this.#createOrGetActivityObject(channel);
+ if (topic === "http-on-examine-cached-response") {
+ // Service worker requests emits cached-response notification on non-e10s,
+ // and we fake one on e10s.
+ const fromServiceWorker = this.#interceptedChannels.has(channel);
+ this.#interceptedChannels.delete(channel);
+
+ // If this is a cached response (which are also emitted by service worker requests),
+ // there never was a request event so we need to construct one here
+ // so the frontend gets all the expected events.
+ if (!httpActivity.owner) {
+ httpActivity = this.#createNetworkEvent(channel, {
+ fromCache: !fromServiceWorker,
+ fromServiceWorker,
+ });
+ }
+
+ // We need to send the request body to the frontend for
+ // the faked (cached/service worker request) event.
+ this.#prepareRequestBody(httpActivity);
+ this.#sendRequestBody(httpActivity);
+
+ // There also is never any timing events, so we can fire this
+ // event with zeroed out values.
+ const timings = this.#setupHarTimings(httpActivity);
+ const serverTimings = this.#extractServerTimings(httpActivity.channel);
+ const serviceWorkerTimings =
+ this.#extractServiceWorkerTimings(httpActivity);
+
+ httpActivity.owner.addServerTimings(serverTimings);
+ httpActivity.owner.addServiceWorkerTimings(serviceWorkerTimings);
+ httpActivity.owner.addEventTimings(
+ timings.total,
+ timings.timings,
+ timings.offsets
+ );
+ } else if (topic === "http-on-failed-opening-request") {
+ const { blockedReason } = lazy.NetworkUtils.getBlockedReason(
+ channel,
+ httpActivity.fromCache
+ );
+ this.#createNetworkEvent(channel, { blockedReason });
+ }
+
+ if (httpActivity.owner) {
+ httpActivity.owner.addResponseStart({
+ channel: httpActivity.channel,
+ fromCache: httpActivity.fromCache || httpActivity.fromServiceWorker,
+ rawHeaders: httpActivity.responseRawHeaders,
+ proxyResponseRawHeaders: httpActivity.proxyResponseRawHeaders,
+ });
+ }
+ }
+ );
+
+ /**
+ * Observe notifications for the http-on-modify-request topic, coming from
+ * the nsIObserverService.
+ *
+ * @private
+ * @param nsIHttpChannel aSubject
+ * @returns void
+ */
+ #httpModifyExaminer = DevToolsInfaillibleUtils.makeInfallible(subject => {
+ const throttler = this.#getThrottler();
+ if (throttler) {
+ const channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ if (this.#ignoreChannelFunction(channel)) {
+ return;
+ }
+ logPlatformEvent("http-on-modify-request", channel);
+
+ // Read any request body here, before it is throttled.
+ const httpActivity = this.#createOrGetActivityObject(channel);
+ this.#prepareRequestBody(httpActivity);
+ throttler.manageUpload(channel);
+ }
+ });
+
+ /**
+ * Observe notifications for the file-channel-opened topic
+ *
+ * @private
+ * @param nsIFileChannel subject
+ * @param string topic
+ * @returns void
+ */
+ #fileChannelExaminer = DevToolsInfaillibleUtils.makeInfallible(
+ (subject, topic) => {
+ if (
+ this.#isDestroyed ||
+ topic != "file-channel-opened" ||
+ !(subject instanceof Ci.nsIFileChannel)
+ ) {
+ return;
+ }
+ const channel = subject.QueryInterface(Ci.nsIFileChannel);
+ channel.QueryInterface(Ci.nsIIdentChannel);
+ channel.QueryInterface(Ci.nsIChannel);
+
+ if (this.#ignoreChannelFunction(channel)) {
+ return;
+ }
+
+ logPlatformEvent(topic, channel);
+
+ const fileActivity = this.#createOrGetActivityObject(channel);
+
+ this.#createNetworkEvent(subject, {});
+
+ if (fileActivity.owner) {
+ fileActivity.owner.addResponseStart({
+ channel: fileActivity.channel,
+ fromCache: fileActivity.fromCache || fileActivity.fromServiceWorker,
+ rawHeaders: fileActivity.responseRawHeaders,
+ proxyResponseRawHeaders: fileActivity.proxyResponseRawHeaders,
+ });
+ }
+ }
+ );
+
+ /**
+ * A helper function for observeActivity. This does whatever work
+ * is required by a particular http activity event. Arguments are
+ * the same as for observeActivity.
+ */
+ #dispatchActivity(
+ httpActivity,
+ channel,
+ activityType,
+ activitySubtype,
+ timestamp,
+ extraSizeData,
+ extraStringData
+ ) {
+ // Store the time information for this activity subtype.
+ if (activitySubtype in HTTP_TRANSACTION_CODES) {
+ const stage = HTTP_TRANSACTION_CODES[activitySubtype];
+ if (stage in httpActivity.timings) {
+ httpActivity.timings[stage].last = timestamp;
+ } else {
+ httpActivity.timings[stage] = {
+ first: timestamp,
+ last: timestamp,
+ };
+ }
+ }
+
+ switch (activitySubtype) {
+ case gActivityDistributor.ACTIVITY_SUBTYPE_REQUEST_BODY_SENT:
+ this.#prepareRequestBody(httpActivity);
+ this.#sendRequestBody(httpActivity);
+ break;
+ case gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_HEADER:
+ httpActivity.responseRawHeaders = extraStringData;
+ httpActivity.headersSize = extraStringData.length;
+ break;
+ case gActivityDistributor.ACTIVITY_SUBTYPE_PROXY_RESPONSE_HEADER:
+ httpActivity.proxyResponseRawHeaders = extraStringData;
+ break;
+ case gActivityDistributor.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE:
+ this.#onTransactionClose(httpActivity);
+ break;
+ default:
+ break;
+ }
+ }
+
+ getActivityTypeString(activityType, activitySubtype) {
+ if (
+ activityType === Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_SOCKET_TRANSPORT
+ ) {
+ for (const name in Ci.nsISocketTransport) {
+ if (Ci.nsISocketTransport[name] === activitySubtype) {
+ return "SOCKET_TRANSPORT:" + name;
+ }
+ }
+ } else if (
+ activityType === Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION
+ ) {
+ for (const name in Ci.nsIHttpActivityObserver) {
+ if (Ci.nsIHttpActivityObserver[name] === activitySubtype) {
+ return "HTTP_TRANSACTION:" + name.replace("ACTIVITY_SUBTYPE_", "");
+ }
+ }
+ }
+ return "unexpected-activity-types:" + activityType + ":" + activitySubtype;
+ }
+
+ /**
+ * Begin observing HTTP traffic that originates inside the current tab.
+ *
+ * @see https://developer.mozilla.org/en/XPCOM_Interface_Reference/nsIHttpActivityObserver
+ *
+ * @param nsIHttpChannel channel
+ * @param number activityType
+ * @param number activitySubtype
+ * @param number timestamp
+ * @param number extraSizeData
+ * @param string extraStringData
+ */
+ observeActivity = DevToolsInfaillibleUtils.makeInfallible(function (
+ channel,
+ activityType,
+ activitySubtype,
+ timestamp,
+ extraSizeData,
+ extraStringData
+ ) {
+ if (
+ this.#isDestroyed ||
+ (activityType != gActivityDistributor.ACTIVITY_TYPE_HTTP_TRANSACTION &&
+ activityType != gActivityDistributor.ACTIVITY_TYPE_SOCKET_TRANSPORT)
+ ) {
+ return;
+ }
+
+ if (
+ !(channel instanceof Ci.nsIHttpChannel) ||
+ !(channel instanceof Ci.nsIClassifiedChannel)
+ ) {
+ return;
+ }
+
+ channel = channel.QueryInterface(Ci.nsIHttpChannel);
+ channel = channel.QueryInterface(Ci.nsIClassifiedChannel);
+
+ if (DEBUG_PLATFORM_EVENTS) {
+ logPlatformEvent(
+ this.getActivityTypeString(activityType, activitySubtype),
+ channel
+ );
+ }
+
+ if (
+ activitySubtype == gActivityDistributor.ACTIVITY_SUBTYPE_REQUEST_HEADER
+ ) {
+ this.#onRequestHeader(channel, timestamp, extraStringData);
+ return;
+ }
+
+ // Iterate over all currently ongoing requests. If channel can't
+ // be found within them, then exit this function.
+ const httpActivity = this.#findActivityObject(channel);
+ if (!httpActivity) {
+ return;
+ }
+
+ // If we're throttling, we must not report events as they arrive
+ // from platform, but instead let the throttler emit the events
+ // after some time has elapsed.
+ if (
+ httpActivity.downloadThrottle &&
+ HTTP_DOWNLOAD_ACTIVITIES.includes(activitySubtype)
+ ) {
+ const callback = this.#dispatchActivity.bind(this);
+ httpActivity.downloadThrottle.addActivityCallback(
+ callback,
+ httpActivity,
+ channel,
+ activityType,
+ activitySubtype,
+ timestamp,
+ extraSizeData,
+ extraStringData
+ );
+ } else {
+ this.#dispatchActivity(
+ httpActivity,
+ channel,
+ activityType,
+ activitySubtype,
+ timestamp,
+ extraSizeData,
+ extraStringData
+ );
+ }
+ });
+
+ /**
+ * Craft the "event" object passed to the Watcher class in order
+ * to instantiate the NetworkEventActor.
+ *
+ * /!\ This method does many other important things:
+ * - Cancel requests blocked by DevTools
+ * - Fetch request headers/cookies
+ * - Set a few attributes on http activity object
+ * - Set a few attributes on file activity object
+ * - Register listener to record response content
+ */
+ #createNetworkEvent(
+ channel,
+ {
+ timestamp,
+ rawHeaders,
+ fromCache,
+ fromServiceWorker,
+ blockedReason,
+ blockingExtension,
+ inProgressRequest,
+ }
+ ) {
+ if (channel instanceof Ci.nsIFileChannel) {
+ const fileActivity = this.#createOrGetActivityObject(channel);
+
+ if (timestamp) {
+ fileActivity.timings.REQUEST_HEADER = {
+ first: timestamp,
+ last: timestamp,
+ };
+ }
+
+ fileActivity.owner = this.#onNetworkEvent({}, channel);
+
+ return fileActivity;
+ }
+
+ const httpActivity = this.#createOrGetActivityObject(channel);
+
+ if (timestamp) {
+ httpActivity.timings.REQUEST_HEADER = {
+ first: timestamp,
+ last: timestamp,
+ };
+ }
+
+ if (blockedReason === undefined && this.#shouldBlockChannel(channel)) {
+ // Check the request URL with ones manually blocked by the user in DevTools.
+ // If it's meant to be blocked, we cancel the request and annotate the event.
+ channel.cancel(Cr.NS_BINDING_ABORTED);
+ blockedReason = "devtools";
+ }
+
+ httpActivity.owner = this.#onNetworkEvent(
+ {
+ timestamp,
+ fromCache,
+ fromServiceWorker,
+ rawHeaders,
+ blockedReason,
+ blockingExtension,
+ discardRequestBody: !this.#saveRequestAndResponseBodies,
+ discardResponseBody: !this.#saveRequestAndResponseBodies,
+ },
+ channel
+ );
+ httpActivity.fromCache = fromCache;
+ httpActivity.fromServiceWorker = fromServiceWorker;
+
+ // Bug 1489217 - Avoid watching for response content for blocked or in-progress requests
+ // as it can't be observed and would throw if we try.
+ if (blockedReason === undefined && !inProgressRequest) {
+ this.#setupResponseListener(httpActivity, {
+ fromCache,
+ fromServiceWorker,
+ });
+ }
+
+ if (this.#authPromptListenerEnabled) {
+ new lazy.NetworkAuthListener(httpActivity.channel, httpActivity.owner);
+ }
+
+ return httpActivity;
+ }
+
+ /**
+ * Handler for ACTIVITY_SUBTYPE_REQUEST_HEADER. When a request starts the
+ * headers are sent to the server. This method creates the |httpActivity|
+ * object where we store the request and response information that is
+ * collected through its lifetime.
+ *
+ * @private
+ * @param nsIHttpChannel channel
+ * @param number timestamp
+ * @param string rawHeaders
+ * @return void
+ */
+ #onRequestHeader(channel, timestamp, rawHeaders) {
+ if (this.#ignoreChannelFunction(channel)) {
+ return;
+ }
+
+ this.#createNetworkEvent(channel, {
+ timestamp,
+ rawHeaders,
+ });
+ }
+
+ /**
+ * Check if the provided channel should be blocked given the current
+ * blocked URLs configured for this network observer.
+ */
+ #shouldBlockChannel(channel) {
+ for (const regexp of this.#blockedURLs.values()) {
+ if (regexp.test(channel.URI.spec)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Find an HTTP activity object for the channel.
+ *
+ * @param nsIHttpChannel channel
+ * The HTTP channel whose activity object we want to find.
+ * @return object
+ * The HTTP activity object, or null if it is not found.
+ */
+ #findActivityObject(channel) {
+ return this.#openRequests.get(channel);
+ }
+
+ /**
+ * Find an existing activity object, or create a new one. This
+ * object is used for storing all the request and response
+ * information.
+ *
+ * This is a HAR-like object. Conformance to the spec is not guaranteed at
+ * this point.
+ *
+ * @see http://www.softwareishard.com/blog/har-12-spec
+ * @param {(nsIHttpChannel|nsIFileChannel)} channel
+ * The HTTP channel for which the HTTP activity object is created.
+ * @return object
+ * The new HTTP activity object.
+ */
+ #createOrGetActivityObject(channel) {
+ let activity = this.#findActivityObject(channel);
+ if (!activity) {
+ const isHttpChannel = channel instanceof Ci.nsIHttpChannel;
+
+ if (isHttpChannel) {
+ // Most of the data needed from the channel is only available via the
+ // nsIHttpChannelInternal interface.
+ channel.QueryInterface(Ci.nsIHttpChannelInternal);
+ } else {
+ channel.QueryInterface(Ci.nsIChannel);
+ }
+
+ activity = {
+ // The nsIChannel for which this activity object was created.
+ channel,
+ // See #prepareRequestBody()
+ charset: isHttpChannel ? lazy.NetworkUtils.getCharset(channel) : null,
+ // The postData sent by this request.
+ sentBody: null,
+ // The URL for the current channel.
+ url: channel.URI.spec,
+ // The encoded response body size.
+ bodySize: 0,
+ // The response headers size.
+ headersSize: 0,
+ // needed for host specific security info but file urls do not have hostname
+ hostname: isHttpChannel ? channel.URI.host : null,
+ discardRequestBody: isHttpChannel
+ ? !this.#saveRequestAndResponseBodies
+ : false,
+ discardResponseBody: isHttpChannel
+ ? !this.#saveRequestAndResponseBodies
+ : false,
+ // internal timing information, see observeActivity()
+ timings: {},
+ // the activity owner which is notified when changes happen
+ owner: null,
+ };
+
+ this.#openRequests.set(channel, activity);
+ }
+
+ return activity;
+ }
+
+ /**
+ * Block a request based on certain filtering options.
+ *
+ * Currently, exact URL match or URL patterns are supported.
+ */
+ blockRequest(filter) {
+ if (!filter || !filter.url) {
+ // In the future, there may be other types of filters, such as domain.
+ // For now, ignore anything other than URL.
+ return;
+ }
+
+ this.#addBlockedUrl(filter.url);
+ }
+
+ /**
+ * Unblock a request based on certain filtering options.
+ *
+ * Currently, exact URL match or URL patterns are supported.
+ */
+ unblockRequest(filter) {
+ if (!filter || !filter.url) {
+ // In the future, there may be other types of filters, such as domain.
+ // For now, ignore anything other than URL.
+ return;
+ }
+
+ this.#blockedURLs.delete(filter.url);
+ }
+
+ /**
+ * Updates the list of blocked request strings
+ *
+ * This match will be a (String).includes match, not an exact URL match
+ */
+ setBlockedUrls(urls) {
+ urls = urls || [];
+ this.#blockedURLs = new Map();
+ urls.forEach(url => this.#addBlockedUrl(url));
+ }
+
+ #addBlockedUrl(url) {
+ this.#blockedURLs.set(url, lazy.wildcardToRegExp(url));
+ }
+
+ /**
+ * Returns a list of blocked requests
+ * Useful as blockedURLs is mutated by both console & netmonitor
+ */
+ getBlockedUrls() {
+ return this.#blockedURLs.keys();
+ }
+
+ override(url, path) {
+ this.#overrides.set(url, path);
+ }
+
+ removeOverride(url) {
+ this.#overrides.delete(url);
+ }
+
+ /**
+ * Setup the network response listener for the given HTTP activity. The
+ * NetworkResponseListener is responsible for storing the response body.
+ *
+ * @private
+ * @param object httpActivity
+ * The HTTP activity object we are tracking.
+ */
+ #setupResponseListener(httpActivity, { fromCache, fromServiceWorker }) {
+ const channel = httpActivity.channel;
+ channel.QueryInterface(Ci.nsITraceableChannel);
+
+ if (!fromCache) {
+ const throttler = this.#getThrottler();
+ if (throttler) {
+ httpActivity.downloadThrottle = throttler.manage(channel);
+ }
+ }
+
+ // The response will be written into the outputStream of this pipe.
+ // This allows us to buffer the data we are receiving and read it
+ // asynchronously.
+ // Both ends of the pipe must be blocking.
+ const sink = Cc["@mozilla.org/pipe;1"].createInstance(Ci.nsIPipe);
+
+ // The streams need to be blocking because this is required by the
+ // stream tee.
+ sink.init(false, false, this.#responsePipeSegmentSize, PR_UINT32_MAX, null);
+
+ // Add listener for the response body.
+ const newListener = new lazy.NetworkResponseListener(
+ httpActivity,
+ this.#decodedCertificateCache,
+ fromServiceWorker
+ );
+
+ // Remember the input stream, so it isn't released by GC.
+ newListener.inputStream = sink.inputStream;
+ newListener.sink = sink;
+
+ const tee = Cc["@mozilla.org/network/stream-listener-tee;1"].createInstance(
+ Ci.nsIStreamListenerTee
+ );
+
+ const originalListener = channel.setNewListener(tee);
+
+ tee.init(originalListener, sink.outputStream, newListener);
+ }
+
+ /**
+ * Handler for ACTIVITY_SUBTYPE_REQUEST_BODY_SENT. Read and record the request
+ * body here. It will be available in addResponseStart.
+ *
+ * @private
+ * @param object httpActivity
+ * The HTTP activity object we are working with.
+ */
+ #prepareRequestBody(httpActivity) {
+ // Return early if we don't need the request body, or if we've
+ // already found it.
+ if (httpActivity.discardRequestBody || httpActivity.sentBody !== null) {
+ return;
+ }
+
+ let sentBody = lazy.NetworkHelper.readPostTextFromRequest(
+ httpActivity.channel,
+ httpActivity.charset
+ );
+
+ if (
+ sentBody !== null &&
+ this.window &&
+ httpActivity.url == this.window.location.href
+ ) {
+ // If the request URL is the same as the current page URL, then
+ // we can try to get the posted text from the page directly.
+ // This check is necessary as otherwise the
+ // lazy.NetworkHelper.readPostTextFromPageViaWebNav()
+ // function is called for image requests as well but these
+ // are not web pages and as such don't store the posted text
+ // in the cache of the webpage.
+ const webNav = this.window.docShell.QueryInterface(Ci.nsIWebNavigation);
+ sentBody = lazy.NetworkHelper.readPostTextFromPageViaWebNav(
+ webNav,
+ httpActivity.charset
+ );
+ }
+
+ if (sentBody !== null) {
+ httpActivity.sentBody = sentBody;
+ }
+ }
+
+ /**
+ * Handler for ACTIVITY_SUBTYPE_TRANSACTION_CLOSE. This method updates the HAR
+ * timing information on the HTTP activity object and clears the request
+ * from the list of known open requests.
+ *
+ * @private
+ * @param object httpActivity
+ * The HTTP activity object we work with.
+ */
+ #onTransactionClose(httpActivity) {
+ if (httpActivity.owner) {
+ const result = this.#setupHarTimings(httpActivity);
+ const serverTimings = this.#extractServerTimings(httpActivity.channel);
+
+ httpActivity.owner.addServerTimings(serverTimings);
+ httpActivity.owner.addEventTimings(
+ result.total,
+ result.timings,
+ result.offsets
+ );
+ }
+ }
+
+ #getBlockedTiming(timings) {
+ if (timings.STATUS_RESOLVING && timings.STATUS_CONNECTING_TO) {
+ return timings.STATUS_RESOLVING.first - timings.REQUEST_HEADER.first;
+ } else if (timings.STATUS_SENDING_TO) {
+ return timings.STATUS_SENDING_TO.first - timings.REQUEST_HEADER.first;
+ }
+
+ return -1;
+ }
+
+ #getDnsTiming(timings) {
+ if (timings.STATUS_RESOLVING && timings.STATUS_RESOLVED) {
+ return timings.STATUS_RESOLVED.last - timings.STATUS_RESOLVING.first;
+ }
+
+ return -1;
+ }
+
+ #getConnectTiming(timings) {
+ if (timings.STATUS_CONNECTING_TO && timings.STATUS_CONNECTED_TO) {
+ return (
+ timings.STATUS_CONNECTED_TO.last - timings.STATUS_CONNECTING_TO.first
+ );
+ }
+
+ return -1;
+ }
+
+ #getReceiveTiming(timings) {
+ if (timings.RESPONSE_START && timings.RESPONSE_COMPLETE) {
+ return timings.RESPONSE_COMPLETE.last - timings.RESPONSE_START.first;
+ }
+
+ return -1;
+ }
+
+ #getWaitTiming(timings) {
+ if (timings.RESPONSE_START) {
+ return (
+ timings.RESPONSE_START.first -
+ (timings.REQUEST_BODY_SENT || timings.STATUS_SENDING_TO).last
+ );
+ }
+
+ return -1;
+ }
+
+ #getSslTiming(timings) {
+ if (timings.STATUS_TLS_STARTING && timings.STATUS_TLS_ENDING) {
+ return timings.STATUS_TLS_ENDING.last - timings.STATUS_TLS_STARTING.first;
+ }
+
+ return -1;
+ }
+
+ #getSendTiming(timings) {
+ if (timings.STATUS_SENDING_TO) {
+ return timings.STATUS_SENDING_TO.last - timings.STATUS_SENDING_TO.first;
+ } else if (timings.REQUEST_HEADER && timings.REQUEST_BODY_SENT) {
+ return timings.REQUEST_BODY_SENT.last - timings.REQUEST_HEADER.first;
+ }
+
+ return -1;
+ }
+
+ #getDataFromTimedChannel(timedChannel) {
+ const lookUpArr = [
+ "tcpConnectEndTime",
+ "connectStartTime",
+ "connectEndTime",
+ "secureConnectionStartTime",
+ "domainLookupEndTime",
+ "domainLookupStartTime",
+ ];
+
+ return lookUpArr.reduce((prev, prop) => {
+ const propName = prop + "Tc";
+ return {
+ ...prev,
+ [propName]: (() => {
+ if (!timedChannel) {
+ return 0;
+ }
+
+ const value = timedChannel[prop];
+
+ if (
+ value != 0 &&
+ timedChannel.asyncOpenTime &&
+ value < timedChannel.asyncOpenTime
+ ) {
+ return 0;
+ }
+
+ return value;
+ })(),
+ };
+ }, {});
+ }
+
+ #getSecureConnectionStartTimeInfo(timings) {
+ let secureConnectionStartTime = 0;
+ let secureConnectionStartTimeRelative = false;
+
+ if (timings.STATUS_TLS_STARTING && timings.STATUS_TLS_ENDING) {
+ if (timings.STATUS_CONNECTING_TO) {
+ secureConnectionStartTime =
+ timings.STATUS_TLS_STARTING.first -
+ timings.STATUS_CONNECTING_TO.first;
+ }
+
+ if (secureConnectionStartTime < 0) {
+ secureConnectionStartTime = 0;
+ }
+ secureConnectionStartTimeRelative = true;
+ }
+
+ return {
+ secureConnectionStartTime,
+ secureConnectionStartTimeRelative,
+ };
+ }
+
+ #getStartSendingTimeInfo(timings, connectStartTimeTc) {
+ let startSendingTime = 0;
+ let startSendingTimeRelative = false;
+
+ if (timings.STATUS_SENDING_TO) {
+ if (timings.STATUS_CONNECTING_TO) {
+ startSendingTime =
+ timings.STATUS_SENDING_TO.first - timings.STATUS_CONNECTING_TO.first;
+ startSendingTimeRelative = true;
+ } else if (connectStartTimeTc != 0) {
+ startSendingTime = timings.STATUS_SENDING_TO.first - connectStartTimeTc;
+ startSendingTimeRelative = true;
+ }
+
+ if (startSendingTime < 0) {
+ startSendingTime = 0;
+ }
+ }
+ return { startSendingTime, startSendingTimeRelative };
+ }
+
+ /**
+ * Update the HTTP activity object to include timing information as in the HAR
+ * spec. The HTTP activity object holds the raw timing information in
+ * |timings| - these are timings stored for each activity notification. The
+ * HAR timing information is constructed based on these lower level
+ * data.
+ *
+ * @param {Object} httpActivity
+ * The HTTP activity object we are working with.
+ * @return {Object}
+ * This object holds three properties:
+ * - {Object} offsets: the timings computed as offsets from the initial
+ * request start time.
+ * - {Object} timings: the HAR timings object
+ * - {number} total: the total time for all of the request and response
+ */
+ #setupHarTimings(httpActivity) {
+ if (httpActivity.fromCache) {
+ // If it came from the browser cache, we have no timing
+ // information and these should all be 0
+ return {
+ total: 0,
+ timings: {
+ blocked: 0,
+ dns: 0,
+ ssl: 0,
+ connect: 0,
+ send: 0,
+ wait: 0,
+ receive: 0,
+ },
+ offsets: {
+ blocked: 0,
+ dns: 0,
+ ssl: 0,
+ connect: 0,
+ send: 0,
+ wait: 0,
+ receive: 0,
+ },
+ };
+ }
+
+ const timings = httpActivity.timings;
+ const harTimings = {};
+ // If the TCP Fast Open option or tls1.3 0RTT is used tls and data can
+ // be dispatched in SYN packet and not after tcp socket is connected.
+ // To demostrate this properly we will calculated TLS and send start time
+ // relative to CONNECTING_TO.
+ // Similary if 0RTT is used, data can be sent as soon as a TLS handshake
+ // starts.
+
+ harTimings.blocked = this.#getBlockedTiming(timings);
+ // DNS timing information is available only in when the DNS record is not
+ // cached.
+ harTimings.dns = this.#getDnsTiming(timings);
+ harTimings.connect = this.#getConnectTiming(timings);
+ harTimings.ssl = this.#getSslTiming(timings);
+
+ let { secureConnectionStartTime, secureConnectionStartTimeRelative } =
+ this.#getSecureConnectionStartTimeInfo(timings);
+
+ // sometimes the connection information events are attached to a speculative
+ // channel instead of this one, but necko might glue them back together in the
+ // nsITimedChannel interface used by Resource and Navigation Timing
+ const timedChannel = httpActivity.channel.QueryInterface(
+ Ci.nsITimedChannel
+ );
+
+ const {
+ tcpConnectEndTimeTc,
+ connectStartTimeTc,
+ connectEndTimeTc,
+ secureConnectionStartTimeTc,
+ domainLookupEndTimeTc,
+ domainLookupStartTimeTc,
+ } = this.#getDataFromTimedChannel(timedChannel);
+
+ if (
+ harTimings.connect <= 0 &&
+ timedChannel &&
+ tcpConnectEndTimeTc != 0 &&
+ connectStartTimeTc != 0
+ ) {
+ harTimings.connect = tcpConnectEndTimeTc - connectStartTimeTc;
+ if (secureConnectionStartTimeTc != 0) {
+ harTimings.ssl = connectEndTimeTc - secureConnectionStartTimeTc;
+ secureConnectionStartTime =
+ secureConnectionStartTimeTc - connectStartTimeTc;
+ secureConnectionStartTimeRelative = true;
+ } else {
+ harTimings.ssl = -1;
+ }
+ } else if (
+ timedChannel &&
+ timings.STATUS_TLS_STARTING &&
+ secureConnectionStartTimeTc != 0
+ ) {
+ // It can happen that TCP Fast Open actually have not sent any data and
+ // timings.STATUS_TLS_STARTING.first value will be corrected in
+ // timedChannel.secureConnectionStartTime
+ if (secureConnectionStartTimeTc > timings.STATUS_TLS_STARTING.first) {
+ // TCP Fast Open actually did not sent any data.
+ harTimings.ssl = connectEndTimeTc - secureConnectionStartTimeTc;
+ secureConnectionStartTimeRelative = false;
+ }
+ }
+
+ if (
+ harTimings.dns <= 0 &&
+ timedChannel &&
+ domainLookupEndTimeTc != 0 &&
+ domainLookupStartTimeTc != 0
+ ) {
+ harTimings.dns = domainLookupEndTimeTc - domainLookupStartTimeTc;
+ }
+
+ harTimings.send = this.#getSendTiming(timings);
+ harTimings.wait = this.#getWaitTiming(timings);
+ harTimings.receive = this.#getReceiveTiming(timings);
+ let { startSendingTime, startSendingTimeRelative } =
+ this.#getStartSendingTimeInfo(timings, connectStartTimeTc);
+
+ if (secureConnectionStartTimeRelative) {
+ const time = Math.max(Math.round(secureConnectionStartTime / 1000), -1);
+ secureConnectionStartTime = time;
+ }
+ if (startSendingTimeRelative) {
+ const time = Math.max(Math.round(startSendingTime / 1000), -1);
+ startSendingTime = time;
+ }
+
+ const ot = this.#calculateOffsetAndTotalTime(
+ harTimings,
+ secureConnectionStartTime,
+ startSendingTimeRelative,
+ secureConnectionStartTimeRelative,
+ startSendingTime
+ );
+ return {
+ total: ot.total,
+ timings: harTimings,
+ offsets: ot.offsets,
+ };
+ }
+
+ #extractServerTimings(channel) {
+ if (!channel || !channel.serverTiming) {
+ return null;
+ }
+
+ const serverTimings = new Array(channel.serverTiming.length);
+
+ for (let i = 0; i < channel.serverTiming.length; ++i) {
+ const { name, duration, description } =
+ channel.serverTiming.queryElementAt(i, Ci.nsIServerTiming);
+ serverTimings[i] = { name, duration, description };
+ }
+
+ return serverTimings;
+ }
+
+ #extractServiceWorkerTimings({ fromServiceWorker, channel }) {
+ if (!fromServiceWorker) {
+ return null;
+ }
+ const timedChannel = channel.QueryInterface(Ci.nsITimedChannel);
+
+ return {
+ launchServiceWorker:
+ timedChannel.launchServiceWorkerEndTime -
+ timedChannel.launchServiceWorkerStartTime,
+ requestToServiceWorker:
+ timedChannel.dispatchFetchEventEndTime -
+ timedChannel.dispatchFetchEventStartTime,
+ handledByServiceWorker:
+ timedChannel.handleFetchEventEndTime -
+ timedChannel.handleFetchEventStartTime,
+ };
+ }
+
+ #convertTimeToMs(timing) {
+ return Math.max(Math.round(timing / 1000), -1);
+ }
+
+ #calculateOffsetAndTotalTime(
+ harTimings,
+ secureConnectionStartTime,
+ startSendingTimeRelative,
+ secureConnectionStartTimeRelative,
+ startSendingTime
+ ) {
+ let totalTime = 0;
+ for (const timing in harTimings) {
+ const time = this.#convertTimeToMs(harTimings[timing]);
+ harTimings[timing] = time;
+ if (time > -1 && timing != "connect" && timing != "ssl") {
+ totalTime += time;
+ }
+ }
+
+ // connect, ssl and send times can be overlapped.
+ if (startSendingTimeRelative) {
+ totalTime += startSendingTime;
+ } else if (secureConnectionStartTimeRelative) {
+ totalTime += secureConnectionStartTime;
+ totalTime += harTimings.ssl;
+ }
+
+ const offsets = {};
+ offsets.blocked = 0;
+ offsets.dns = harTimings.blocked;
+ offsets.connect = offsets.dns + harTimings.dns;
+ if (secureConnectionStartTimeRelative) {
+ offsets.ssl = offsets.connect + secureConnectionStartTime;
+ } else {
+ offsets.ssl = offsets.connect + harTimings.connect;
+ }
+ if (startSendingTimeRelative) {
+ offsets.send = offsets.connect + startSendingTime;
+ if (!secureConnectionStartTimeRelative) {
+ offsets.ssl = offsets.send - harTimings.ssl;
+ }
+ } else {
+ offsets.send = offsets.ssl + harTimings.ssl;
+ }
+ offsets.wait = offsets.send + harTimings.send;
+ offsets.receive = offsets.wait + harTimings.wait;
+
+ return {
+ total: totalTime,
+ offsets,
+ };
+ }
+
+ #sendRequestBody(httpActivity) {
+ if (httpActivity.sentBody !== null) {
+ const limit = Services.prefs.getIntPref(
+ "devtools.netmonitor.requestBodyLimit"
+ );
+ const size = httpActivity.sentBody.length;
+ if (size > limit && limit > 0) {
+ httpActivity.sentBody = httpActivity.sentBody.substr(0, limit);
+ }
+ httpActivity.owner.addRequestPostData({
+ text: httpActivity.sentBody,
+ size,
+ });
+ httpActivity.sentBody = null;
+ }
+ }
+
+ /*
+ * Clears the open requests channel map.
+ */
+ clear() {
+ this.#openRequests.clear();
+ }
+
+ /**
+ * Suspend observer activity. This is called when the Network monitor actor stops
+ * listening.
+ */
+ destroy() {
+ if (this.#isDestroyed) {
+ return;
+ }
+
+ if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT) {
+ gActivityDistributor.removeObserver(this);
+ Services.obs.removeObserver(
+ this.#httpResponseExaminer,
+ "http-on-examine-response"
+ );
+ Services.obs.removeObserver(
+ this.#httpResponseExaminer,
+ "http-on-examine-cached-response"
+ );
+ Services.obs.removeObserver(
+ this.#httpModifyExaminer,
+ "http-on-modify-request"
+ );
+ Services.obs.removeObserver(
+ this.#fileChannelExaminer,
+ "file-channel-opened"
+ );
+ Services.obs.removeObserver(
+ this.#httpStopRequest,
+ "http-on-stop-request"
+ );
+ } else {
+ Services.obs.removeObserver(
+ this.#httpFailedOpening,
+ "http-on-failed-opening-request"
+ );
+ }
+
+ Services.obs.removeObserver(
+ this.#serviceWorkerRequest,
+ "service-worker-synthesized-response"
+ );
+
+ this.#ignoreChannelFunction = null;
+ this.#onNetworkEvent = null;
+ this.#throttler = null;
+ this.#decodedCertificateCache.clear();
+ this.clear();
+
+ this.#isDestroyed = true;
+ }
+}
diff --git a/devtools/shared/network-observer/NetworkOverride.sys.mjs b/devtools/shared/network-observer/NetworkOverride.sys.mjs
new file mode 100644
index 0000000000..1b9ef6c873
--- /dev/null
+++ b/devtools/shared/network-observer/NetworkOverride.sys.mjs
@@ -0,0 +1,70 @@
+/* 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/. */
+
+/**
+ * This modules focuses on redirecting requests to a particular local file.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "mimeService",
+ "@mozilla.org/mime;1",
+ "nsIMIMEService"
+);
+
+function readFile(file) {
+ const fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(file, -1, 0, 0);
+ const data = NetUtil.readInputStreamToString(fstream, fstream.available());
+ fstream.close();
+ return data;
+}
+
+/**
+ * Given an in-flight channel, we will force to replace the content of this request
+ * with the content of a local file.
+ *
+ * @param {nsIHttpChannel} channel
+ * The request to replace content for.
+ * @param {String} path
+ * The absolute path to the local file to read content from.
+ */
+function overrideChannelWithFilePath(channel, path) {
+ // For JS it isn't important, but for HTML we ought to set the right content type on the data URI.
+ let mimeType = "";
+ try {
+ // getTypeFromURI will throw if there is no extension at the end of the URI
+ mimeType = lazy.mimeService.getTypeFromURI(channel.URI);
+ } catch (e) {}
+
+ // Redirect to a data: URI as we can't redirect to file:// URI
+ // without many security issues. We are leveraging the `allowInsecureRedirectToDataURI`
+ // attribute used by WebExtension.
+ const file = lazy.FileUtils.File(path);
+ const data = readFile(file);
+ const redirectURI = Services.io.newURI(
+ `data:${mimeType};base64,${btoa(data)}`
+ );
+
+ channel.redirectTo(redirectURI);
+
+ // Prevents having CORS exception and various issues because of redirecting to data: URI.
+ channel.loadInfo.allowInsecureRedirectToDataURI = true;
+}
+
+export const NetworkOverride = {
+ overrideChannelWithFilePath,
+};
diff --git a/devtools/shared/network-observer/NetworkResponseListener.sys.mjs b/devtools/shared/network-observer/NetworkResponseListener.sys.mjs
new file mode 100644
index 0000000000..642773c8b2
--- /dev/null
+++ b/devtools/shared/network-observer/NetworkResponseListener.sys.mjs
@@ -0,0 +1,608 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+ NetworkHelper:
+ "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs",
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+ getResponseCacheObject:
+ "resource://devtools/shared/platform/CacheEntry.sys.mjs",
+});
+
+// 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 &&
+ !channel.hasContentDecompressed
+ ) {
+ 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.fromCache
+ );
+
+ 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",
+ ]);
+}
diff --git a/devtools/shared/network-observer/NetworkThrottleManager.sys.mjs b/devtools/shared/network-observer/NetworkThrottleManager.sys.mjs
new file mode 100644
index 0000000000..a643861004
--- /dev/null
+++ b/devtools/shared/network-observer/NetworkThrottleManager.sys.mjs
@@ -0,0 +1,495 @@
+/* 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/. */
+
+const ArrayBufferInputStream = Components.Constructor(
+ "@mozilla.org/io/arraybuffer-input-stream;1",
+ "nsIArrayBufferInputStream"
+);
+const BinaryInputStream = Components.Constructor(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gActivityDistributor",
+ "@mozilla.org/network/http-activity-distributor;1",
+ "nsIHttpActivityDistributor"
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+class NetworkThrottleListener {
+ #activities;
+ #offset;
+ #originalListener;
+ #pendingData;
+ #pendingException;
+ #queue;
+ #responseStarted;
+
+ /**
+ * Construct a new nsIStreamListener that buffers data and provides a
+ * method to notify another listener when data is available. This is
+ * used to throttle network data on a per-channel basis.
+ *
+ * After construction, @see setOriginalListener must be called on the
+ * new object.
+ *
+ * @param {NetworkThrottleQueue} queue the NetworkThrottleQueue to
+ * which status changes should be reported
+ */
+ constructor(queue) {
+ this.#activities = {};
+ this.#offset = 0;
+ this.#pendingData = [];
+ this.#pendingException = null;
+ this.#queue = queue;
+ this.#responseStarted = false;
+ }
+
+ /**
+ * Set the original listener for this object. The original listener
+ * will receive requests from this object when the queue allows data
+ * through.
+ *
+ * @param {nsIStreamListener} originalListener the original listener
+ * for the channel, to which all requests will be sent
+ */
+ setOriginalListener(originalListener) {
+ this.#originalListener = originalListener;
+ }
+
+ /**
+ * @see nsIStreamListener.onStartRequest.
+ */
+ onStartRequest(request) {
+ this.#originalListener.onStartRequest(request);
+ this.#queue.start(this);
+ }
+
+ /**
+ * @see nsIStreamListener.onStopRequest.
+ */
+ onStopRequest(request, statusCode) {
+ this.#pendingData.push({ request, statusCode });
+ this.#queue.dataAvailable(this);
+ }
+
+ /**
+ * @see nsIStreamListener.onDataAvailable.
+ */
+ onDataAvailable(request, inputStream, offset, count) {
+ if (this.#pendingException) {
+ throw this.#pendingException;
+ }
+
+ const bin = new BinaryInputStream(inputStream);
+ const bytes = new ArrayBuffer(count);
+ bin.readArrayBuffer(count, bytes);
+
+ const stream = new ArrayBufferInputStream();
+ stream.setData(bytes, 0, count);
+
+ this.#pendingData.push({ request, stream, count });
+ this.#queue.dataAvailable(this);
+ }
+
+ /**
+ * Allow some buffered data from this object to be forwarded to this
+ * object's originalListener.
+ *
+ * @param {Number} bytesPermitted The maximum number of bytes
+ * permitted to be sent.
+ * @return {Object} an object of the form {length, done}, where
+ * |length| is the number of bytes actually forwarded, and
+ * |done| is a boolean indicating whether this particular
+ * request has been completed. (A NetworkThrottleListener
+ * may be queued multiple times, so this does not mean that
+ * all available data has been sent.)
+ */
+ sendSomeData(bytesPermitted) {
+ if (this.#pendingData.length === 0) {
+ // Shouldn't happen.
+ return { length: 0, done: true };
+ }
+
+ const { request, stream, count, statusCode } = this.#pendingData[0];
+
+ if (statusCode !== undefined) {
+ this.#pendingData.shift();
+ this.#originalListener.onStopRequest(request, statusCode);
+ return { length: 0, done: true };
+ }
+
+ if (bytesPermitted > count) {
+ bytesPermitted = count;
+ }
+
+ try {
+ this.#originalListener.onDataAvailable(
+ request,
+ stream,
+ this.#offset,
+ bytesPermitted
+ );
+ } catch (e) {
+ this.#pendingException = e;
+ }
+
+ let done = false;
+ if (bytesPermitted === count) {
+ this.#pendingData.shift();
+ done = true;
+ } else {
+ this.#pendingData[0].count -= bytesPermitted;
+ }
+
+ this.#offset += bytesPermitted;
+ // Maybe our state has changed enough to emit an event.
+ this.#maybeEmitEvents();
+
+ return { length: bytesPermitted, done };
+ }
+
+ /**
+ * Return the number of pending data requests available for this
+ * listener.
+ */
+ pendingCount() {
+ return this.#pendingData.length;
+ }
+
+ /**
+ * This is called when an http activity event is delivered. This
+ * object delays the event until the appropriate moment.
+ */
+ addActivityCallback(
+ callback,
+ httpActivity,
+ channel,
+ activityType,
+ activitySubtype,
+ timestamp,
+ extraSizeData,
+ extraStringData
+ ) {
+ const datum = {
+ callback,
+ httpActivity,
+ channel,
+ activityType,
+ activitySubtype,
+ extraSizeData,
+ extraStringData,
+ };
+ this.#activities[activitySubtype] = datum;
+
+ if (
+ activitySubtype ===
+ lazy.gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE
+ ) {
+ this.totalSize = extraSizeData;
+ }
+
+ this.#maybeEmitEvents();
+ }
+
+ /**
+ * This is called for a download throttler when the latency timeout
+ * has ended.
+ */
+ responseStart() {
+ this.#responseStarted = true;
+ this.#maybeEmitEvents();
+ }
+
+ /**
+ * Check our internal state and emit any http activity events as
+ * needed. Note that we wait until both our internal state has
+ * changed and we've received the real http activity event from
+ * platform. This approach ensures we can both pass on the correct
+ * data from the original event, and update the reported time to be
+ * consistent with the delay we're introducing.
+ */
+ #maybeEmitEvents() {
+ if (this.#responseStarted) {
+ this.#maybeEmit(
+ lazy.gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_START
+ );
+ this.#maybeEmit(
+ lazy.gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_HEADER
+ );
+ }
+
+ if (this.totalSize !== undefined && this.#offset >= this.totalSize) {
+ this.#maybeEmit(
+ lazy.gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE
+ );
+ this.#maybeEmit(
+ lazy.gActivityDistributor.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE
+ );
+ }
+ }
+
+ /**
+ * Emit an event for |code|, if the appropriate entry in
+ * |activities| is defined.
+ */
+ #maybeEmit(code) {
+ if (this.#activities[code] !== undefined) {
+ const {
+ callback,
+ httpActivity,
+ channel,
+ activityType,
+ activitySubtype,
+ extraSizeData,
+ extraStringData,
+ } = this.#activities[code];
+ const now = Date.now() * 1000;
+ callback(
+ httpActivity,
+ channel,
+ activityType,
+ activitySubtype,
+ now,
+ extraSizeData,
+ extraStringData
+ );
+ this.#activities[code] = undefined;
+ }
+ }
+
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIInterfaceRequestor",
+ ]);
+}
+
+class NetworkThrottleQueue {
+ #downloadQueue;
+ #latencyMax;
+ #latencyMean;
+ #maxBPS;
+ #meanBPS;
+ #pendingRequests;
+ #previousReads;
+ #pumping;
+
+ /**
+ * Construct a new queue that can be used to throttle the network for
+ * a group of related network requests.
+ *
+ * meanBPS {Number} Mean bytes per second.
+ * maxBPS {Number} Maximum bytes per second.
+ * latencyMean {Number} Mean latency in milliseconds.
+ * latencyMax {Number} Maximum latency in milliseconds.
+ */
+ constructor(meanBPS, maxBPS, latencyMean, latencyMax) {
+ this.#meanBPS = meanBPS;
+ this.#maxBPS = maxBPS;
+ this.#latencyMean = latencyMean;
+ this.#latencyMax = latencyMax;
+
+ this.#pendingRequests = new Set();
+ this.#downloadQueue = [];
+ this.#previousReads = [];
+
+ this.#pumping = false;
+ }
+
+ /**
+ * A helper function that lets the indicating listener start sending
+ * data. This is called after the initial round trip time for the
+ * listener has elapsed.
+ */
+ #allowDataFrom(throttleListener) {
+ throttleListener.responseStart();
+ this.#pendingRequests.delete(throttleListener);
+ const count = throttleListener.pendingCount();
+ for (let i = 0; i < count; ++i) {
+ this.#downloadQueue.push(throttleListener);
+ }
+ this.#pump();
+ }
+
+ /**
+ * An internal function that permits individual listeners to send
+ * data.
+ */
+ #pump() {
+ // A redirect will cause two NetworkThrottleListeners to be on a
+ // listener chain. In this case, we might recursively call into
+ // this method. Avoid infinite recursion here.
+ if (this.#pumping) {
+ return;
+ }
+ this.#pumping = true;
+
+ const now = Date.now();
+ const oneSecondAgo = now - 1000;
+
+ while (
+ this.#previousReads.length &&
+ this.#previousReads[0].when < oneSecondAgo
+ ) {
+ this.#previousReads.shift();
+ }
+
+ const totalBytes = this.#previousReads.reduce((sum, elt) => {
+ return sum + elt.numBytes;
+ }, 0);
+
+ let thisSliceBytes = this.#random(this.#meanBPS, this.#maxBPS);
+ if (totalBytes < thisSliceBytes) {
+ thisSliceBytes -= totalBytes;
+ let readThisTime = 0;
+ while (thisSliceBytes > 0 && this.#downloadQueue.length) {
+ const { length, done } =
+ this.#downloadQueue[0].sendSomeData(thisSliceBytes);
+ thisSliceBytes -= length;
+ readThisTime += length;
+ if (done) {
+ this.#downloadQueue.shift();
+ }
+ }
+ this.#previousReads.push({ when: now, numBytes: readThisTime });
+ }
+
+ // If there is more data to download, then schedule ourselves for
+ // one second after the oldest previous read.
+ if (this.#downloadQueue.length) {
+ const when = this.#previousReads[0].when + 1000;
+ lazy.setTimeout(this.#pump.bind(this), when - now);
+ }
+
+ this.#pumping = false;
+ }
+
+ /**
+ * A helper function that, given a mean and a maximum, returns a
+ * random integer between (mean - (max - mean)) and max.
+ */
+ #random(mean, max) {
+ return mean - (max - mean) + Math.floor(2 * (max - mean) * Math.random());
+ }
+
+ /**
+ * Notice a new listener object. This is called by the
+ * NetworkThrottleListener when the request has started. Initially
+ * a new listener object is put into a "pending" state, until the
+ * round-trip time has elapsed. This is used to simulate latency.
+ *
+ * @param {NetworkThrottleListener} throttleListener the new listener
+ */
+ start(throttleListener) {
+ this.#pendingRequests.add(throttleListener);
+ const delay = this.#random(this.#latencyMean, this.#latencyMax);
+ if (delay > 0) {
+ lazy.setTimeout(() => this.#allowDataFrom(throttleListener), delay);
+ } else {
+ this.#allowDataFrom(throttleListener);
+ }
+ }
+
+ /**
+ * Note that new data is available for a given listener. Each time
+ * data is available, the listener will be re-queued.
+ *
+ * @param {NetworkThrottleListener} throttleListener the listener
+ * which has data available.
+ */
+ dataAvailable(throttleListener) {
+ if (!this.#pendingRequests.has(throttleListener)) {
+ this.#downloadQueue.push(throttleListener);
+ this.#pump();
+ }
+ }
+}
+
+/**
+ * Construct a new object that can be used to throttle the network for
+ * a group of related network requests.
+ *
+ * @param {Object} An object with the following attributes:
+ * latencyMean {Number} Mean latency in milliseconds.
+ * latencyMax {Number} Maximum latency in milliseconds.
+ * downloadBPSMean {Number} Mean bytes per second for downloads.
+ * downloadBPSMax {Number} Maximum bytes per second for downloads.
+ * uploadBPSMean {Number} Mean bytes per second for uploads.
+ * uploadBPSMax {Number} Maximum bytes per second for uploads.
+ *
+ * Download throttling will not be done if downloadBPSMean and
+ * downloadBPSMax are <= 0. Upload throttling will not be done if
+ * uploadBPSMean and uploadBPSMax are <= 0.
+ */
+export class NetworkThrottleManager {
+ #downloadQueue;
+
+ constructor({
+ latencyMean,
+ latencyMax,
+ downloadBPSMean,
+ downloadBPSMax,
+ uploadBPSMean,
+ uploadBPSMax,
+ }) {
+ if (downloadBPSMax <= 0 && downloadBPSMean <= 0) {
+ this.#downloadQueue = null;
+ } else {
+ this.#downloadQueue = new NetworkThrottleQueue(
+ downloadBPSMean,
+ downloadBPSMax,
+ latencyMean,
+ latencyMax
+ );
+ }
+ if (uploadBPSMax <= 0 && uploadBPSMean <= 0) {
+ this.uploadQueue = null;
+ } else {
+ this.uploadQueue = Cc[
+ "@mozilla.org/network/throttlequeue;1"
+ ].createInstance(Ci.nsIInputChannelThrottleQueue);
+ this.uploadQueue.init(uploadBPSMean, uploadBPSMax);
+ }
+ }
+
+ /**
+ * Create a new NetworkThrottleListener for a given channel and
+ * install it using |setNewListener|.
+ *
+ * @param {nsITraceableChannel} channel the channel to manage
+ * @return {NetworkThrottleListener} the new listener, or null if
+ * download throttling is not being done.
+ */
+ manage(channel) {
+ if (this.#downloadQueue) {
+ const listener = new NetworkThrottleListener(this.#downloadQueue);
+ const originalListener = channel.setNewListener(listener);
+ listener.setOriginalListener(originalListener);
+ return listener;
+ }
+ return null;
+ }
+
+ /**
+ * Throttle uploads taking place on the given channel.
+ *
+ * @param {nsITraceableChannel} channel the channel to manage
+ */
+ manageUpload(channel) {
+ if (this.uploadQueue) {
+ channel = channel.QueryInterface(Ci.nsIThrottledInputChannel);
+ channel.throttleQueue = this.uploadQueue;
+ }
+ }
+}
diff --git a/devtools/shared/network-observer/NetworkUtils.sys.mjs b/devtools/shared/network-observer/NetworkUtils.sys.mjs
new file mode 100644
index 0000000000..6f564a9b1a
--- /dev/null
+++ b/devtools/shared/network-observer/NetworkUtils.sys.mjs
@@ -0,0 +1,693 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkHelper:
+ "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "tpFlagsMask", () => {
+ const trackingProtectionLevel2Enabled = Services.prefs
+ .getStringPref("urlclassifier.trackingTable")
+ .includes("content-track-digest256");
+
+ return trackingProtectionLevel2Enabled
+ ? ~Ci.nsIClassifiedChannel.CLASSIFIED_ANY_BASIC_TRACKING &
+ ~Ci.nsIClassifiedChannel.CLASSIFIED_ANY_STRICT_TRACKING
+ : ~Ci.nsIClassifiedChannel.CLASSIFIED_ANY_BASIC_TRACKING &
+ Ci.nsIClassifiedChannel.CLASSIFIED_ANY_STRICT_TRACKING;
+});
+
+/**
+ * Convert a nsIContentPolicy constant to a display string
+ */
+const LOAD_CAUSE_STRINGS = {
+ [Ci.nsIContentPolicy.TYPE_INVALID]: "invalid",
+ [Ci.nsIContentPolicy.TYPE_OTHER]: "other",
+ [Ci.nsIContentPolicy.TYPE_SCRIPT]: "script",
+ [Ci.nsIContentPolicy.TYPE_IMAGE]: "img",
+ [Ci.nsIContentPolicy.TYPE_STYLESHEET]: "stylesheet",
+ [Ci.nsIContentPolicy.TYPE_OBJECT]: "object",
+ [Ci.nsIContentPolicy.TYPE_DOCUMENT]: "document",
+ [Ci.nsIContentPolicy.TYPE_SUBDOCUMENT]: "subdocument",
+ [Ci.nsIContentPolicy.TYPE_PING]: "ping",
+ [Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST]: "xhr",
+ [Ci.nsIContentPolicy.TYPE_OBJECT_SUBREQUEST]: "objectSubdoc",
+ [Ci.nsIContentPolicy.TYPE_DTD]: "dtd",
+ [Ci.nsIContentPolicy.TYPE_FONT]: "font",
+ [Ci.nsIContentPolicy.TYPE_MEDIA]: "media",
+ [Ci.nsIContentPolicy.TYPE_WEBSOCKET]: "websocket",
+ [Ci.nsIContentPolicy.TYPE_CSP_REPORT]: "csp",
+ [Ci.nsIContentPolicy.TYPE_XSLT]: "xslt",
+ [Ci.nsIContentPolicy.TYPE_BEACON]: "beacon",
+ [Ci.nsIContentPolicy.TYPE_FETCH]: "fetch",
+ [Ci.nsIContentPolicy.TYPE_IMAGESET]: "imageset",
+ [Ci.nsIContentPolicy.TYPE_WEB_MANIFEST]: "webManifest",
+ [Ci.nsIContentPolicy.TYPE_WEB_IDENTITY]: "webidentity",
+};
+
+function causeTypeToString(causeType, loadFlags, internalContentPolicyType) {
+ let prefix = "";
+ if (
+ (causeType == Ci.nsIContentPolicy.TYPE_IMAGESET ||
+ internalContentPolicyType == Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE) &&
+ loadFlags & Ci.nsIRequest.LOAD_BACKGROUND
+ ) {
+ prefix = "lazy-";
+ }
+
+ return prefix + LOAD_CAUSE_STRINGS[causeType] || "unknown";
+}
+
+function stringToCauseType(value) {
+ return Object.keys(LOAD_CAUSE_STRINGS).find(
+ key => LOAD_CAUSE_STRINGS[key] === value
+ );
+}
+
+function isChannelFromSystemPrincipal(channel) {
+ let principal = null;
+ let browsingContext = channel.loadInfo.browsingContext;
+ if (!browsingContext) {
+ const topFrame = lazy.NetworkHelper.getTopFrameForRequest(channel);
+ if (topFrame) {
+ browsingContext = topFrame.browsingContext;
+ } else {
+ // Fallback to the triggering principal when browsingContext and topFrame is null
+ // e.g some chrome requests
+ principal = channel.loadInfo.triggeringPrincipal;
+ }
+ }
+
+ // When in the parent process, we can get the documentPrincipal from the
+ // WindowGlobal which is available on the BrowsingContext
+ if (!principal) {
+ principal = CanonicalBrowsingContext.isInstance(browsingContext)
+ ? browsingContext.currentWindowGlobal.documentPrincipal
+ : browsingContext.window.document.nodePrincipal;
+ }
+ return principal.isSystemPrincipal;
+}
+
+/**
+ * Get the browsing context id for the channel.
+ *
+ * @param {*} channel
+ * @returns {number}
+ */
+function getChannelBrowsingContextID(channel) {
+ // `frameBrowsingContextID` is non-0 if the channel is loading an iframe.
+ // If available, use it instead of `browsingContextID` which is exceptionally
+ // set to the parent's BrowsingContext id for such channels.
+ if (channel.loadInfo.frameBrowsingContextID) {
+ return channel.loadInfo.frameBrowsingContextID;
+ }
+
+ if (channel.loadInfo.browsingContextID) {
+ return channel.loadInfo.browsingContextID;
+ }
+ // At least WebSocket channel aren't having a browsingContextID set on their loadInfo
+ // We fallback on top frame element, which works, but will be wrong for WebSocket
+ // in same-process iframes...
+ const topFrame = lazy.NetworkHelper.getTopFrameForRequest(channel);
+ // topFrame is typically null for some chrome requests like favicons
+ if (topFrame && topFrame.browsingContext) {
+ return topFrame.browsingContext.id;
+ }
+ return null;
+}
+
+/**
+ * Get the innerWindowId for the channel.
+ *
+ * @param {*} channel
+ * @returns {number}
+ */
+function getChannelInnerWindowId(channel) {
+ if (channel.loadInfo.innerWindowID) {
+ return channel.loadInfo.innerWindowID;
+ }
+ // At least WebSocket channel aren't having a browsingContextID set on their loadInfo
+ // We fallback on top frame element, which works, but will be wrong for WebSocket
+ // in same-process iframes...
+ const topFrame = lazy.NetworkHelper.getTopFrameForRequest(channel);
+ // topFrame is typically null for some chrome requests like favicons
+ if (topFrame?.browsingContext?.currentWindowGlobal) {
+ return topFrame.browsingContext.currentWindowGlobal.innerWindowId;
+ }
+ return null;
+}
+
+/**
+ * Does this channel represent a Preload request.
+ *
+ * @param {*} channel
+ * @returns {boolean}
+ */
+function isPreloadRequest(channel) {
+ const type = channel.loadInfo.internalContentPolicyType;
+ return (
+ type == Ci.nsIContentPolicy.TYPE_INTERNAL_SCRIPT_PRELOAD ||
+ type == Ci.nsIContentPolicy.TYPE_INTERNAL_MODULE_PRELOAD ||
+ type == Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_PRELOAD ||
+ type == Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET_PRELOAD ||
+ type == Ci.nsIContentPolicy.TYPE_INTERNAL_FONT_PRELOAD
+ );
+}
+
+/**
+ * Get the channel cause details.
+ *
+ * @param {nsIChannel} channel
+ * @returns {Object}
+ * - loadingDocumentUri {string} uri of the document which created the
+ * channel
+ * - type {string} cause type as string
+ */
+function getCauseDetails(channel) {
+ // Determine the cause and if this is an XHR request.
+ let causeType = Ci.nsIContentPolicy.TYPE_OTHER;
+ let causeUri = null;
+
+ if (channel.loadInfo) {
+ causeType = channel.loadInfo.externalContentPolicyType;
+ const { loadingPrincipal } = channel.loadInfo;
+ if (loadingPrincipal) {
+ causeUri = loadingPrincipal.spec;
+ }
+ }
+
+ return {
+ loadingDocumentUri: causeUri,
+ type: causeTypeToString(
+ causeType,
+ channel.loadFlags,
+ channel.loadInfo.internalContentPolicyType
+ ),
+ };
+}
+
+/**
+ * Get the channel priority. Priority is a number which typically ranges from
+ * -20 (lowest priority) to 20 (highest priority). Can be null if the channel
+ * does not implement nsISupportsPriority.
+ *
+ * @param {nsIChannel} channel
+ * @returns {number|undefined}
+ */
+function getChannelPriority(channel) {
+ if (channel instanceof Ci.nsISupportsPriority) {
+ return channel.priority;
+ }
+
+ return null;
+}
+
+/**
+ * Get the channel HTTP version as an uppercase string starting with "HTTP/"
+ * (eg "HTTP/2").
+ *
+ * @param {nsIChannel} channel
+ * @returns {string}
+ */
+function getHttpVersion(channel) {
+ // Determine the HTTP version.
+ const httpVersionMaj = {};
+ const httpVersionMin = {};
+
+ channel.QueryInterface(Ci.nsIHttpChannelInternal);
+ channel.getResponseVersion(httpVersionMaj, httpVersionMin);
+
+ // The official name HTTP version 2.0 and 3.0 are HTTP/2 and HTTP/3, omit the
+ // trailing `.0`.
+ if (httpVersionMin.value == 0) {
+ return "HTTP/" + httpVersionMaj.value;
+ }
+
+ return "HTTP/" + httpVersionMaj.value + "." + httpVersionMin.value;
+}
+
+const UNKNOWN_PROTOCOL_STRINGS = ["", "unknown"];
+const HTTP_PROTOCOL_STRINGS = ["http", "https"];
+/**
+ * Get the protocol for the provided httpActivity. Either the ALPN negotiated
+ * protocol or as a fallback a protocol computed from the scheme and the
+ * response status.
+ *
+ * TODO: The `protocol` is similar to another response property called
+ * `httpVersion`. `httpVersion` is uppercase and purely computed from the
+ * response status, whereas `protocol` uses nsIHttpChannel.protocolVersion by
+ * default and otherwise falls back on `httpVersion`. Ideally we should merge
+ * the two properties.
+ *
+ * @param {Object} httpActivity
+ * The httpActivity object for which we need to get the protocol.
+ *
+ * @returns {string}
+ * The protocol as a string.
+ */
+function getProtocol(channel) {
+ let protocol = "";
+ try {
+ const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
+ // protocolVersion corresponds to ALPN negotiated protocol.
+ protocol = httpChannel.protocolVersion;
+ } catch (e) {
+ // Ignore errors reading protocolVersion.
+ }
+
+ if (UNKNOWN_PROTOCOL_STRINGS.includes(protocol)) {
+ protocol = channel.URI.scheme;
+ const httpVersion = getHttpVersion(channel);
+ if (
+ typeof httpVersion == "string" &&
+ HTTP_PROTOCOL_STRINGS.includes(protocol)
+ ) {
+ protocol = httpVersion.toLowerCase();
+ }
+ }
+
+ return protocol;
+}
+
+/**
+ * Get the channel referrer policy as a string
+ * (eg "strict-origin-when-cross-origin").
+ *
+ * @param {nsIChannel} channel
+ * @returns {string}
+ */
+function getReferrerPolicy(channel) {
+ return channel.referrerInfo
+ ? channel.referrerInfo.getReferrerPolicyString()
+ : "";
+}
+
+/**
+ * Check if the channel is private.
+ *
+ * @param {nsIChannel} channel
+ * @returns {boolean}
+ */
+function isChannelPrivate(channel) {
+ channel.QueryInterface(Ci.nsIPrivateBrowsingChannel);
+ return channel.isChannelPrivate;
+}
+
+/**
+ * Check if the channel data is loaded from the cache or not.
+ *
+ * @param {nsIChannel} channel
+ * The channel for which we need to check the cache status.
+ *
+ * @returns {boolean}
+ * True if the channel data is loaded from the cache, false otherwise.
+ */
+function isFromCache(channel) {
+ if (channel instanceof Ci.nsICacheInfoChannel) {
+ return channel.isFromCache();
+ }
+
+ return false;
+}
+
+const REDIRECT_STATES = [
+ 301, // HTTP Moved Permanently
+ 302, // HTTP Found
+ 303, // HTTP See Other
+ 307, // HTTP Temporary Redirect
+];
+/**
+ * Check if the channel's status corresponds to a known redirect status.
+ *
+ * @param {nsIChannel} channel
+ * The channel for which we need to check the redirect status.
+ *
+ * @returns {boolean}
+ * True if the channel data is a redirect, false otherwise.
+ */
+function isRedirectedChannel(channel) {
+ try {
+ return REDIRECT_STATES.includes(channel.responseStatus);
+ } catch (e) {
+ // Throws NS_ERROR_NOT_AVAILABLE if the request was not sent yet.
+ }
+ return false;
+}
+
+/**
+ * isNavigationRequest is true for the one request used to load a new top level
+ * document of a given tab, or top level window. It will typically be false for
+ * navigation requests of iframes, i.e. the request loading another document in
+ * an iframe.
+ *
+ * @param {nsIChannel} channel
+ * @return {boolean}
+ */
+function isNavigationRequest(channel) {
+ return channel.isMainDocumentChannel && channel.loadInfo.isTopLevelLoad;
+}
+
+/**
+ * Returns true if the channel has been processed by URL-Classifier features
+ * and is considered third-party with the top window URI, and if it has loaded
+ * a resource that is classified as a tracker.
+ *
+ * @param {nsIChannel} channel
+ * @return {boolean}
+ */
+function isThirdPartyTrackingResource(channel) {
+ // Only consider channels classified as level-1 to be trackers if our preferences
+ // would not cause such channels to be blocked in strict content blocking mode.
+ // Make sure the value produced here is a boolean.
+ return !!(
+ channel instanceof Ci.nsIClassifiedChannel &&
+ channel.isThirdPartyTrackingResource() &&
+ (channel.thirdPartyClassificationFlags & lazy.tpFlagsMask) == 0
+ );
+}
+
+/**
+ * Retrieve the websocket channel for the provided channel, if available.
+ * Returns null otherwise.
+ *
+ * @param {nsIChannel} channel
+ * @returns {nsIWebSocketChannel|null}
+ */
+function getWebSocketChannel(channel) {
+ let wsChannel = null;
+ if (channel.notificationCallbacks) {
+ try {
+ wsChannel = channel.notificationCallbacks.QueryInterface(
+ Ci.nsIWebSocketChannel
+ );
+ } catch (e) {
+ // Not all channels implement nsIWebSocketChannel.
+ }
+ }
+ return wsChannel;
+}
+
+/**
+ * For a given channel, fetch the request's headers and cookies.
+ *
+ * @param {nsIChannel} channel
+ * @return {Object}
+ * An object with two properties:
+ * @property {Array<Object>} cookies
+ * Array of { name, value } objects.
+ * @property {Array<Object>} headers
+ * Array of { name, value } objects.
+ */
+function fetchRequestHeadersAndCookies(channel) {
+ const headers = [];
+ let cookies = [];
+ let cookieHeader = null;
+
+ // Copy the request header data.
+ channel.visitRequestHeaders({
+ visitHeader(name, value) {
+ // The `Proxy-Authorization` header even though it appears on the channel is not
+ // actually sent to the server for non CONNECT requests after the HTTP/HTTPS tunnel
+ // is setup by the proxy.
+ if (name == "Proxy-Authorization") {
+ return;
+ }
+ if (name == "Cookie") {
+ cookieHeader = value;
+ }
+ headers.push({ name, value });
+ },
+ });
+
+ if (cookieHeader) {
+ cookies = lazy.NetworkHelper.parseCookieHeader(cookieHeader);
+ }
+
+ return { cookies, headers };
+}
+
+/**
+ * For a given channel, fetch the response's headers and cookies.
+ *
+ * @param {nsIChannel} channel
+ * @return {Object}
+ * An object with two properties:
+ * @property {Array<Object>} cookies
+ * Array of { name, value } objects.
+ * @property {Array<Object>} headers
+ * Array of { name, value } objects.
+ */
+function fetchResponseHeadersAndCookies(channel) {
+ // Read response headers and cookies.
+ const headers = [];
+ const setCookieHeaders = [];
+
+ const SET_COOKIE_REGEXP = /set-cookie/i;
+ channel.visitOriginalResponseHeaders({
+ visitHeader(name, value) {
+ if (SET_COOKIE_REGEXP.test(name)) {
+ setCookieHeaders.push(value);
+ }
+ headers.push({ name, value });
+ },
+ });
+
+ return {
+ cookies: lazy.NetworkHelper.parseSetCookieHeaders(setCookieHeaders),
+ headers,
+ };
+}
+
+/**
+ * Check if a given network request should be logged by a network monitor
+ * based on the specified filters.
+ *
+ * @param {(nsIHttpChannel|nsIFileChannel)} channel
+ * Request to check.
+ * @param filters
+ * NetworkObserver filters to match against. An object with one of the following attributes:
+ * - sessionContext: When inspecting requests from the parent process, pass the WatcherActor's session context.
+ * This helps know what is the overall debugged scope.
+ * See watcher actor constructor for more info.
+ * - targetActor: When inspecting requests from the content process, pass the WindowGlobalTargetActor.
+ * This helps know what exact subset of request we should accept.
+ * This is especially useful to behave correctly regarding EFT, where we should include or not
+ * iframes requests.
+ * - browserId, addonId, window: All these attributes are legacy.
+ * Only browserId attribute is still used by the legacy WebConsoleActor startListener API.
+ * @return boolean
+ * True if the network request should be logged, false otherwise.
+ */
+function matchRequest(channel, filters) {
+ // NetworkEventWatcher should now pass a session context for the parent process codepath
+ if (filters.sessionContext) {
+ const { type } = filters.sessionContext;
+ if (type == "all") {
+ return true;
+ }
+
+ // Ignore requests from chrome or add-on code when we don't monitor the whole browser
+ if (
+ channel.loadInfo?.loadingDocument === null &&
+ (channel.loadInfo.loadingPrincipal ===
+ Services.scriptSecurityManager.getSystemPrincipal() ||
+ channel.loadInfo.isInDevToolsContext)
+ ) {
+ return false;
+ }
+
+ if (type == "browser-element") {
+ if (!channel.loadInfo.browsingContext) {
+ const topFrame = lazy.NetworkHelper.getTopFrameForRequest(channel);
+ // `topFrame` is typically null for some chrome requests like favicons
+ // And its `browsingContext` attribute might be null if the request happened
+ // while the tab is being closed.
+ return (
+ topFrame?.browsingContext?.browserId ==
+ filters.sessionContext.browserId
+ );
+ }
+ return (
+ channel.loadInfo.browsingContext.browserId ==
+ filters.sessionContext.browserId
+ );
+ }
+ if (type == "webextension") {
+ return (
+ channel.loadInfo?.loadingPrincipal?.addonId ===
+ filters.sessionContext.addonId
+ );
+ }
+ throw new Error("Unsupported session context type: " + type);
+ }
+
+ // NetworkEventContentWatcher and NetworkEventStackTraces pass a target actor instead, from the content processes
+ // Because of EFT, we can't use session context as we have to know what exact windows the target actor covers.
+ if (filters.targetActor) {
+ // Bug 1769982 the target actor might be destroying and accessing windows will throw.
+ // Ignore all further request when this happens.
+ let windows;
+ try {
+ windows = filters.targetActor.windows;
+ } catch (e) {
+ return false;
+ }
+ const win = lazy.NetworkHelper.getWindowForRequest(channel);
+ return windows.includes(win);
+ }
+
+ // This is fallback code for the legacy WebConsole.startListeners codepath,
+ // which may still pass individual browserId/window/addonId attributes.
+ // This should be removable once we drop the WebConsole codepath for network events
+ // (bug 1721592 and followups)
+ return legacyMatchRequest(channel, filters);
+}
+
+function legacyMatchRequest(channel, filters) {
+ // Log everything if no filter is specified
+ if (!filters.browserId && !filters.window && !filters.addonId) {
+ return true;
+ }
+
+ // Ignore requests from chrome or add-on code when we are monitoring
+ // content.
+ if (
+ channel.loadInfo?.loadingDocument === null &&
+ (channel.loadInfo.loadingPrincipal ===
+ Services.scriptSecurityManager.getSystemPrincipal() ||
+ channel.loadInfo.isInDevToolsContext)
+ ) {
+ return false;
+ }
+
+ if (filters.window) {
+ let win = lazy.NetworkHelper.getWindowForRequest(channel);
+ if (filters.matchExactWindow) {
+ return win == filters.window;
+ }
+
+ // Since frames support, this.window may not be the top level content
+ // frame, so that we can't only compare with win.top.
+ while (win) {
+ if (win == filters.window) {
+ return true;
+ }
+ if (win.parent == win) {
+ break;
+ }
+ win = win.parent;
+ }
+ return false;
+ }
+
+ if (filters.browserId) {
+ const topFrame = lazy.NetworkHelper.getTopFrameForRequest(channel);
+ // `topFrame` is typically null for some chrome requests like favicons
+ // And its `browsingContext` attribute might be null if the request happened
+ // while the tab is being closed.
+ if (topFrame?.browsingContext?.browserId == filters.browserId) {
+ return true;
+ }
+
+ // If we couldn't get the top frame BrowsingContext from the loadContext,
+ // look for it on channel.loadInfo instead.
+ if (channel.loadInfo?.browsingContext?.browserId == filters.browserId) {
+ return true;
+ }
+ }
+
+ if (
+ filters.addonId &&
+ channel.loadInfo?.loadingPrincipal?.addonId === filters.addonId
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+function getBlockedReason(channel, fromCache = false) {
+ let blockingExtension, blockedReason;
+ const { status } = channel;
+
+ try {
+ const request = channel.QueryInterface(Ci.nsIHttpChannel);
+ const properties = request.QueryInterface(Ci.nsIPropertyBag);
+
+ blockedReason = request.loadInfo.requestBlockingReason;
+ blockingExtension = properties.getProperty("cancelledByExtension");
+
+ // WebExtensionPolicy is not available for workers
+ if (typeof WebExtensionPolicy !== "undefined") {
+ blockingExtension = WebExtensionPolicy.getByID(blockingExtension).name;
+ }
+ } catch (err) {
+ // "cancelledByExtension" doesn't have to be available.
+ }
+ // These are platform errors which are not exposed to the users,
+ // usually the requests (with these errors) might be displayed with various
+ // other status codes.
+ const ignoreList = [
+ // These are emited when the request is already in the cache.
+ "NS_ERROR_PARSED_DATA_CACHED",
+ // This is emited when there is some issues around images e.g When the img.src
+ // links to a non existent url. This is typically shown as a 404 request.
+ "NS_IMAGELIB_ERROR_FAILURE",
+ // This is emited when there is a redirect. They are shown as 301 requests.
+ "NS_BINDING_REDIRECTED",
+ // E.g Emited by send beacon requests.
+ "NS_ERROR_ABORT",
+ ];
+
+ // NS_BINDING_ABORTED are emmited when request are abruptly halted, these are valid and should not be ignored.
+ // They can also be emmited for requests already cache which have the `cached` status, these should be ignored.
+ if (fromCache) {
+ ignoreList.push("NS_BINDING_ABORTED");
+ }
+
+ // If the request has not failed or is not blocked by a web extension, check for
+ // any errors not on the ignore list. e.g When a host is not found (NS_ERROR_UNKNOWN_HOST).
+ if (
+ blockedReason == 0 &&
+ !Components.isSuccessCode(status) &&
+ !ignoreList.includes(ChromeUtils.getXPCOMErrorName(status))
+ ) {
+ blockedReason = ChromeUtils.getXPCOMErrorName(status);
+ }
+
+ return { blockingExtension, blockedReason };
+}
+
+function getCharset(channel) {
+ const win = lazy.NetworkHelper.getWindowForRequest(channel);
+ return win ? win.document.characterSet : null;
+}
+
+export const NetworkUtils = {
+ causeTypeToString,
+ fetchRequestHeadersAndCookies,
+ fetchResponseHeadersAndCookies,
+ getCauseDetails,
+ getChannelBrowsingContextID,
+ getChannelInnerWindowId,
+ getChannelPriority,
+ getHttpVersion,
+ getProtocol,
+ getReferrerPolicy,
+ getWebSocketChannel,
+ isChannelFromSystemPrincipal,
+ isChannelPrivate,
+ isFromCache,
+ isNavigationRequest,
+ isPreloadRequest,
+ isRedirectedChannel,
+ isThirdPartyTrackingResource,
+ matchRequest,
+ stringToCauseType,
+ getBlockedReason,
+ getCharset,
+};
diff --git a/devtools/shared/network-observer/README.md b/devtools/shared/network-observer/README.md
new file mode 100644
index 0000000000..7c2d41a959
--- /dev/null
+++ b/devtools/shared/network-observer/README.md
@@ -0,0 +1,9 @@
+# Network Observer modules
+
+The NetworkObserver module and associated helpers allow to:
+- monitor network events (requests and responses)
+- block requests
+- throttle responses
+
+The DevTools network-observer modules are used both by DevTools and by WebDriver BiDi, found under /remote.
+Breaking changes should be discussed and reviewed both by devtools and webdriver peers.
diff --git a/devtools/shared/network-observer/WildcardToRegexp.sys.mjs b/devtools/shared/network-observer/WildcardToRegexp.sys.mjs
new file mode 100644
index 0000000000..4c495fdd78
--- /dev/null
+++ b/devtools/shared/network-observer/WildcardToRegexp.sys.mjs
@@ -0,0 +1,28 @@
+/* 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/. */
+
+/**
+ * Converts a URL-like string which might include the `*` character as a wildcard
+ * to a regular expression. They are used to match against actual URLs for the
+ * request blocking feature from DevTools.
+ *
+ * The returned regular expression is case insensitive.
+ *
+ * @param {string} url
+ * A URL-like string which can contain one or several `*` as wildcard
+ * characters.
+ * @return {RegExp}
+ * A regular expression which can be used to match URLs compatible with the
+ * provided url "template".
+ */
+export function wildcardToRegExp(url) {
+ return new RegExp(url.split("*").map(regExpEscape).join(".*"), "i");
+}
+
+/**
+ * Escapes all special RegExp characters in the given string.
+ */
+const regExpEscape = s => {
+ return s.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
+};
diff --git a/devtools/shared/network-observer/moz.build b/devtools/shared/network-observer/moz.build
new file mode 100644
index 0000000000..3c782ea9aa
--- /dev/null
+++ b/devtools/shared/network-observer/moz.build
@@ -0,0 +1,21 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"]
+
+DevToolsModules(
+ "ChannelMap.sys.mjs",
+ "NetworkAuthListener.sys.mjs",
+ "NetworkHelper.sys.mjs",
+ "NetworkObserver.sys.mjs",
+ "NetworkOverride.sys.mjs",
+ "NetworkResponseListener.sys.mjs",
+ "NetworkThrottleManager.sys.mjs",
+ "NetworkUtils.sys.mjs",
+ "WildcardToRegexp.sys.mjs",
+)
diff --git a/devtools/shared/network-observer/test/browser/browser.toml b/devtools/shared/network-observer/test/browser/browser.toml
new file mode 100644
index 0000000000..3b3b44aaae
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/browser.toml
@@ -0,0 +1,33 @@
+[DEFAULT]
+tags = "devtools"
+subsuite = "devtools"
+support-files = [
+ "head.js",
+ "doc_network-observer-missing-service-worker.html",
+ "doc_network-observer.html",
+ "gzipped.sjs",
+ "override.html",
+ "override.js",
+ "serviceworker.js",
+ "sjs_network-auth-listener-test-server.sjs",
+ "sjs_network-observer-test-server.sjs",
+]
+
+["browser_networkobserver.js"]
+skip-if = [
+ "http3", # Bug 1829298
+ "http2",
+]
+
+["browser_networkobserver_auth_listener.js"]
+skip-if = [
+ "debug", # Disabled for frequent leaks in Bug 1873571.
+ "asan",
+]
+
+["browser_networkobserver_invalid_constructor.js"]
+
+["browser_networkobserver_override.js"]
+
+["browser_networkobserver_serviceworker.js"]
+fail-if = ["true"] # Disabled until Bug 1267119 and Bug 1246289
diff --git a/devtools/shared/network-observer/test/browser/browser_networkobserver.js b/devtools/shared/network-observer/test/browser/browser_networkobserver.js
new file mode 100644
index 0000000000..8f81ef6f86
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/browser_networkobserver.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_URL = URL_ROOT + "doc_network-observer.html";
+const REQUEST_URL =
+ URL_ROOT + `sjs_network-observer-test-server.sjs?sts=200&fmt=html`;
+
+// Check that the NetworkObserver can detect basic requests and calls the
+// onNetworkEvent callback when expected.
+add_task(async function testSingleRequest() {
+ await addTab(TEST_URL);
+
+ const onNetworkEvents = waitForNetworkEvents(REQUEST_URL, 1);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [REQUEST_URL], _url => {
+ content.wrappedJSObject.fetch(_url);
+ });
+
+ const events = await onNetworkEvents;
+ is(events.length, 1, "Received the expected number of network events");
+});
+
+add_task(async function testMultipleRequests() {
+ await addTab(TEST_URL);
+ const EXPECTED_REQUESTS_COUNT = 5;
+
+ const onNetworkEvents = waitForNetworkEvents(
+ REQUEST_URL,
+ EXPECTED_REQUESTS_COUNT
+ );
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [REQUEST_URL, EXPECTED_REQUESTS_COUNT],
+ (_url, _count) => {
+ for (let i = 0; i < _count; i++) {
+ content.wrappedJSObject.fetch(_url);
+ }
+ }
+ );
+
+ const events = await onNetworkEvents;
+ is(
+ events.length,
+ EXPECTED_REQUESTS_COUNT,
+ "Received the expected number of network events"
+ );
+});
+
+add_task(async function testOnNetworkEventArguments() {
+ await addTab(TEST_URL);
+
+ const onNetworkEvent = new Promise(resolve => {
+ const networkObserver = new NetworkObserver({
+ ignoreChannelFunction: () => false,
+ onNetworkEvent: (...args) => {
+ resolve(args);
+ return createNetworkEventOwner();
+ },
+ });
+ registerCleanupFunction(() => networkObserver.destroy());
+ });
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [REQUEST_URL], _url => {
+ content.wrappedJSObject.fetch(_url);
+ });
+
+ const args = await onNetworkEvent;
+ is(args.length, 2, "Received two arguments");
+ is(typeof args[0], "object", "First argument is an object");
+ ok(args[1] instanceof Ci.nsIChannel, "Second argument is a channel");
+});
diff --git a/devtools/shared/network-observer/test/browser/browser_networkobserver_auth_listener.js b/devtools/shared/network-observer/test/browser/browser_networkobserver_auth_listener.js
new file mode 100644
index 0000000000..e3492c10ad
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/browser_networkobserver_auth_listener.js
@@ -0,0 +1,386 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PromptTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromptTestUtils.sys.mjs"
+);
+
+const TEST_URL = URL_ROOT + "doc_network-observer.html";
+const AUTH_URL = URL_ROOT + `sjs_network-auth-listener-test-server.sjs`;
+
+// Correct credentials for sjs_network-auth-listener-test-server.sjs.
+const USERNAME = "guest";
+const PASSWORD = "guest";
+const BAD_PASSWORD = "bad";
+
+// NetworkEventOwner which will cancel all auth prompt requests.
+class AuthCancellingOwner extends NetworkEventOwner {
+ hasAuthPrompt = false;
+
+ onAuthPrompt(authDetails, authCallbacks) {
+ this.hasAuthPrompt = true;
+ authCallbacks.cancelAuthPrompt();
+ }
+}
+
+// NetworkEventOwner which will forward all auth prompt requests to the browser.
+class AuthForwardingOwner extends NetworkEventOwner {
+ hasAuthPrompt = false;
+
+ onAuthPrompt(authDetails, authCallbacks) {
+ this.hasAuthPrompt = true;
+ authCallbacks.forwardAuthPrompt();
+ }
+}
+
+// NetworkEventOwner which will answer provided credentials to auth prompts.
+class AuthCredentialsProvidingOwner extends NetworkEventOwner {
+ hasAuthPrompt = false;
+
+ constructor(channel, username, password) {
+ super();
+
+ this.channel = channel;
+ this.username = username;
+ this.password = password;
+ }
+
+ async onAuthPrompt(authDetails, authCallbacks) {
+ this.hasAuthPrompt = true;
+
+ // Providing credentials immediately can lead to intermittent failures.
+ // TODO: Investigate and remove.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 100));
+
+ await authCallbacks.provideAuthCredentials(this.username, this.password);
+ }
+
+ addResponseContent(content) {
+ super.addResponseContent();
+ this.responseContent = content.text;
+ }
+}
+
+add_task(async function testAuthRequestWithoutListener() {
+ cleanupAuthManager();
+ const tab = await addTab(TEST_URL);
+
+ const events = [];
+ const networkObserver = new NetworkObserver({
+ ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL,
+ onNetworkEvent: event => {
+ const owner = new AuthForwardingOwner();
+ events.push(owner);
+ return owner;
+ },
+ });
+ registerCleanupFunction(() => networkObserver.destroy());
+
+ const onAuthPrompt = waitForAuthPrompt(tab);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => {
+ content.wrappedJSObject.fetch(_url);
+ });
+
+ info("Wait for a network event to be created");
+ await BrowserTestUtils.waitForCondition(() => events.length >= 1);
+ is(events.length, 1, "Received the expected number of network events");
+
+ info("Wait for the auth prompt to be displayed");
+ await onAuthPrompt;
+ Assert.equal(
+ getTabAuthPrompts(tab).length,
+ 1,
+ "The auth prompt was not blocked by the network observer"
+ );
+
+ // The event owner should have been called for ResponseStart and EventTimings
+ assertEventOwner(events[0], {
+ hasResponseStart: true,
+ hasEventTimings: true,
+ hasServerTimings: true,
+ });
+
+ networkObserver.destroy();
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function testAuthRequestWithForwardingListener() {
+ cleanupAuthManager();
+ const tab = await addTab(TEST_URL);
+
+ const events = [];
+ const networkObserver = new NetworkObserver({
+ ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL,
+ onNetworkEvent: event => {
+ info("waitForNetworkEvents received a new event");
+ const owner = new AuthForwardingOwner();
+ events.push(owner);
+ return owner;
+ },
+ });
+ registerCleanupFunction(() => networkObserver.destroy());
+
+ info("Enable the auth prompt listener for this network observer");
+ networkObserver.setAuthPromptListenerEnabled(true);
+
+ const onAuthPrompt = waitForAuthPrompt(tab);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => {
+ content.wrappedJSObject.fetch(_url);
+ });
+
+ info("Wait for a network event to be received");
+ await BrowserTestUtils.waitForCondition(() => events.length >= 1);
+ is(events.length, 1, "Received the expected number of network events");
+
+ // The auth prompt should still be displayed since the network event owner
+ // forwards the auth notification immediately.
+ info("Wait for the auth prompt to be displayed");
+ await onAuthPrompt;
+ Assert.equal(
+ getTabAuthPrompts(tab).length,
+ 1,
+ "The auth prompt was not blocked by the network observer"
+ );
+
+ // The event owner should have been called for ResponseStart, EventTimings and
+ // AuthPrompt
+ assertEventOwner(events[0], {
+ hasResponseStart: true,
+ hasEventTimings: true,
+ hasAuthPrompt: true,
+ hasServerTimings: true,
+ });
+
+ networkObserver.destroy();
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function testAuthRequestWithCancellingListener() {
+ cleanupAuthManager();
+ const tab = await addTab(TEST_URL);
+
+ const events = [];
+ const networkObserver = new NetworkObserver({
+ ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL,
+ onNetworkEvent: event => {
+ const owner = new AuthCancellingOwner();
+ events.push(owner);
+ return owner;
+ },
+ });
+ registerCleanupFunction(() => networkObserver.destroy());
+
+ info("Enable the auth prompt listener for this network observer");
+ networkObserver.setAuthPromptListenerEnabled(true);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => {
+ content.wrappedJSObject.fetch(_url);
+ });
+
+ info("Wait for a network event to be received");
+ await BrowserTestUtils.waitForCondition(() => events.length >= 1);
+ is(events.length, 1, "Received the expected number of network events");
+
+ await BrowserTestUtils.waitForCondition(
+ () => events[0].hasResponseContent && events[0].hasSecurityInfo
+ );
+
+ // The auth prompt should not be displayed since the authentication was
+ // cancelled.
+ ok(
+ !getTabAuthPrompts(tab).length,
+ "The auth prompt was cancelled by the network event owner"
+ );
+
+ assertEventOwner(events[0], {
+ hasResponseStart: true,
+ hasResponseContent: true,
+ hasEventTimings: true,
+ hasServerTimings: true,
+ hasAuthPrompt: true,
+ hasSecurityInfo: true,
+ });
+
+ networkObserver.destroy();
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function testAuthRequestWithWrongCredentialsListener() {
+ cleanupAuthManager();
+ const tab = await addTab(TEST_URL);
+
+ const events = [];
+ const networkObserver = new NetworkObserver({
+ ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL,
+ onNetworkEvent: (event, channel) => {
+ const owner = new AuthCredentialsProvidingOwner(
+ channel,
+ USERNAME,
+ BAD_PASSWORD
+ );
+ events.push(owner);
+ return owner;
+ },
+ });
+ registerCleanupFunction(() => networkObserver.destroy());
+
+ info("Enable the auth prompt listener for this network observer");
+ networkObserver.setAuthPromptListenerEnabled(true);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => {
+ content.wrappedJSObject.fetch(_url);
+ });
+
+ info("Wait for all network events to be received");
+ await BrowserTestUtils.waitForCondition(() => events.length >= 1);
+ is(events.length, 1, "Received the expected number of network events");
+
+ // Wait for authPrompt to be handled
+ await BrowserTestUtils.waitForCondition(() => events[0].hasAuthPrompt);
+
+ // The auth prompt should not be displayed since the authentication was
+ // fulfilled.
+ ok(
+ !getTabAuthPrompts(tab).length,
+ "The auth prompt was handled by the network event owner"
+ );
+
+ assertEventOwner(events[0], {
+ hasAuthPrompt: true,
+ hasResponseStart: true,
+ hasEventTimings: true,
+ hasServerTimings: true,
+ });
+
+ networkObserver.destroy();
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function testAuthRequestWithCredentialsListener() {
+ cleanupAuthManager();
+ const tab = await addTab(TEST_URL);
+
+ const events = [];
+ const networkObserver = new NetworkObserver({
+ ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL,
+ onNetworkEvent: (event, channel) => {
+ const owner = new AuthCredentialsProvidingOwner(
+ channel,
+ USERNAME,
+ PASSWORD
+ );
+ events.push(owner);
+ return owner;
+ },
+ });
+ registerCleanupFunction(() => networkObserver.destroy());
+
+ info("Enable the auth prompt listener for this network observer");
+ networkObserver.setAuthPromptListenerEnabled(true);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => {
+ content.wrappedJSObject.fetch(_url);
+ });
+
+ // TODO: At the moment, providing credentials will result in additional
+ // network events collected by the NetworkObserver, whereas we would expect
+ // to keep the same event.
+ // For successful auth prompts, we receive an additional event.
+ // The last event will contain the responseContent flag.
+ info("Wait for all network events to be received");
+ await BrowserTestUtils.waitForCondition(() => events.length >= 2);
+ is(events.length, 2, "Received the expected number of network events");
+
+ // Since the auth prompt was canceled we should also receive the security
+ // information and the response content.
+ await BrowserTestUtils.waitForCondition(
+ () => events[1].hasResponseContent && events[1].hasSecurityInfo
+ );
+
+ // The auth prompt should not be displayed since the authentication was
+ // fulfilled.
+ ok(
+ !getTabAuthPrompts(tab).length,
+ "The auth prompt was handled by the network event owner"
+ );
+
+ assertEventOwner(events[1], {
+ hasResponseStart: true,
+ hasEventTimings: true,
+ hasSecurityInfo: true,
+ hasServerTimings: true,
+ hasResponseContent: true,
+ });
+
+ is(events[1].responseContent, "success", "Auth prompt was successful");
+
+ networkObserver.destroy();
+ gBrowser.removeTab(tab);
+});
+
+function assertEventOwner(event, expectedFlags) {
+ is(
+ event.hasResponseStart,
+ !!expectedFlags.hasResponseStart,
+ "network event has the expected ResponseStart flag"
+ );
+ is(
+ event.hasEventTimings,
+ !!expectedFlags.hasEventTimings,
+ "network event has the expected EventTimings flag"
+ );
+ is(
+ event.hasAuthPrompt,
+ !!expectedFlags.hasAuthPrompt,
+ "network event has the expected AuthPrompt flag"
+ );
+ is(
+ event.hasResponseCache,
+ !!expectedFlags.hasResponseCache,
+ "network event has the expected ResponseCache flag"
+ );
+ is(
+ event.hasResponseContent,
+ !!expectedFlags.hasResponseContent,
+ "network event has the expected ResponseContent flag"
+ );
+ is(
+ event.hasSecurityInfo,
+ !!expectedFlags.hasSecurityInfo,
+ "network event has the expected SecurityInfo flag"
+ );
+ is(
+ event.hasServerTimings,
+ !!expectedFlags.hasServerTimings,
+ "network event has the expected ServerTimings flag"
+ );
+}
+
+function getTabAuthPrompts(tab) {
+ const tabDialogBox = gBrowser.getTabDialogBox(tab.linkedBrowser);
+ return tabDialogBox
+ .getTabDialogManager()
+ ._dialogs.filter(
+ d => d.frameContentWindow?.Dialog.args.promptType == "promptUserAndPass"
+ );
+}
+
+function waitForAuthPrompt(tab) {
+ return PromptTestUtils.waitForPrompt(tab.linkedBrowser, {
+ modalType: Services.prompt.MODAL_TYPE_TAB,
+ promptType: "promptUserAndPass",
+ });
+}
+
+// Cleanup potentially stored credentials before running any test.
+function cleanupAuthManager() {
+ const authManager = SpecialPowers.Cc[
+ "@mozilla.org/network/http-auth-manager;1"
+ ].getService(SpecialPowers.Ci.nsIHttpAuthManager);
+ authManager.clearAll();
+}
diff --git a/devtools/shared/network-observer/test/browser/browser_networkobserver_invalid_constructor.js b/devtools/shared/network-observer/test/browser/browser_networkobserver_invalid_constructor.js
new file mode 100644
index 0000000000..76a93d938a
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/browser_networkobserver_invalid_constructor.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that the NetworkObserver constructor validates its arguments.
+add_task(async function testInvalidConstructorArguments() {
+ Assert.throws(
+ () => new NetworkObserver(),
+ /Expected "ignoreChannelFunction" to be a function, got undefined/,
+ "NetworkObserver constructor should throw if no argument was provided"
+ );
+
+ Assert.throws(
+ () => new NetworkObserver({}),
+ /Expected "ignoreChannelFunction" to be a function, got undefined/,
+ "NetworkObserver constructor should throw if ignoreChannelFunction was not provided"
+ );
+
+ const invalidValues = [null, true, false, 12, "str", ["arr"], { obj: "obj" }];
+ for (const invalidValue of invalidValues) {
+ Assert.throws(
+ () => new NetworkObserver({ ignoreChannelFunction: invalidValue }),
+ /Expected "ignoreChannelFunction" to be a function, got/,
+ `NetworkObserver constructor should throw if a(n) ${typeof invalidValue} was provided for ignoreChannelFunction`
+ );
+ }
+
+ const EMPTY_FN = () => {};
+ Assert.throws(
+ () => new NetworkObserver({ ignoreChannelFunction: EMPTY_FN }),
+ /Expected "onNetworkEvent" to be a function, got undefined/,
+ "NetworkObserver constructor should throw if onNetworkEvent was not provided"
+ );
+
+ // Now we will pass a function for `ignoreChannelFunction`, and will do the
+ // same tests for onNetworkEvent
+ for (const invalidValue of invalidValues) {
+ Assert.throws(
+ () =>
+ new NetworkObserver({
+ ignoreChannelFunction: EMPTY_FN,
+ onNetworkEvent: invalidValue,
+ }),
+ /Expected "onNetworkEvent" to be a function, got/,
+ `NetworkObserver constructor should throw if a(n) ${typeof invalidValue} was provided for onNetworkEvent`
+ );
+ }
+});
diff --git a/devtools/shared/network-observer/test/browser/browser_networkobserver_override.js b/devtools/shared/network-observer/test/browser/browser_networkobserver_override.js
new file mode 100644
index 0000000000..3b00c4b2e9
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/browser_networkobserver_override.js
@@ -0,0 +1,179 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_URL = URL_ROOT + "doc_network-observer.html";
+const REQUEST_URL =
+ URL_ROOT + `sjs_network-observer-test-server.sjs?sts=200&fmt=html`;
+const GZIPPED_REQUEST_URL = URL_ROOT + `gzipped.sjs`;
+const OVERRIDE_FILENAME = "override.js";
+const OVERRIDE_HTML_FILENAME = "override.html";
+
+add_task(async function testLocalOverride() {
+ await addTab(TEST_URL);
+
+ let eventsCount = 0;
+ const networkObserver = new NetworkObserver({
+ ignoreChannelFunction: channel => channel.URI.spec !== REQUEST_URL,
+ onNetworkEvent: event => {
+ info("received a network event");
+ eventsCount++;
+ return createNetworkEventOwner(event);
+ },
+ });
+
+ const overrideFile = getChromeDir(getResolvedURI(gTestPath));
+ overrideFile.append(OVERRIDE_FILENAME);
+ info(" override " + REQUEST_URL + " to " + overrideFile.path + "\n");
+ networkObserver.override(REQUEST_URL, overrideFile.path);
+
+ info("Assert that request and cached request are overriden");
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [REQUEST_URL],
+ async _url => {
+ const request = await content.wrappedJSObject.fetch(_url);
+ const requestcontent = await request.text();
+ is(
+ requestcontent,
+ `"use strict";\ndocument.title = "evaluated";\n`,
+ "the request content has been overriden"
+ );
+ const secondRequest = await content.wrappedJSObject.fetch(_url);
+ const secondRequestcontent = await secondRequest.text();
+ is(
+ secondRequestcontent,
+ `"use strict";\ndocument.title = "evaluated";\n`,
+ "the cached request content has been overriden"
+ );
+ }
+ );
+
+ info("Assert that JS scripts can be overriden");
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [REQUEST_URL],
+ async _url => {
+ const script = await content.document.createElement("script");
+ const onLoad = new Promise(resolve =>
+ script.addEventListener("load", resolve, { once: true })
+ );
+ script.src = _url;
+ content.document.body.appendChild(script);
+ await onLoad;
+ is(
+ content.document.title,
+ "evaluated",
+ "The <script> tag content has been overriden and correctly evaluated"
+ );
+ }
+ );
+
+ await BrowserTestUtils.waitForCondition(() => eventsCount >= 1);
+
+ networkObserver.destroy();
+});
+
+add_task(async function testHtmlFileOverride() {
+ let eventsCount = 0;
+ const networkObserver = new NetworkObserver({
+ ignoreChannelFunction: channel => channel.URI.spec !== TEST_URL,
+ onNetworkEvent: event => {
+ info("received a network event");
+ eventsCount++;
+ return createNetworkEventOwner(event);
+ },
+ });
+
+ const overrideFile = getChromeDir(getResolvedURI(gTestPath));
+ overrideFile.append(OVERRIDE_HTML_FILENAME);
+ info(" override " + TEST_URL + " to " + overrideFile.path + "\n");
+ networkObserver.override(TEST_URL, overrideFile.path);
+
+ await addTab(TEST_URL);
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [TEST_URL],
+ async pageUrl => {
+ is(
+ content.document.documentElement.outerHTML,
+ "<html><head></head><body>Overriden!\n</body></html>",
+ "The content of the HTML has been overriden"
+ );
+ // For now, all overriden request have their location changed to an internal data: URI
+ // Bug xxx aims at keeping the original URI.
+ todo_is(
+ content.location.href,
+ pageUrl,
+ "The location of the page is still the original one"
+ );
+ }
+ );
+ await BrowserTestUtils.waitForCondition(() => eventsCount >= 1);
+ networkObserver.destroy();
+});
+
+// Exact same test, but with a gzipped request, which requires very special treatment
+add_task(async function testLocalOverrideGzipped() {
+ await addTab(TEST_URL);
+
+ let eventsCount = 0;
+ const networkObserver = new NetworkObserver({
+ ignoreChannelFunction: channel => channel.URI.spec !== GZIPPED_REQUEST_URL,
+ onNetworkEvent: event => {
+ info("received a network event");
+ eventsCount++;
+ return createNetworkEventOwner(event);
+ },
+ });
+
+ const overrideFile = getChromeDir(getResolvedURI(gTestPath));
+ overrideFile.append(OVERRIDE_FILENAME);
+ info(" override " + GZIPPED_REQUEST_URL + " to " + overrideFile.path + "\n");
+ networkObserver.override(GZIPPED_REQUEST_URL, overrideFile.path);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [GZIPPED_REQUEST_URL],
+ async _url => {
+ const request = await content.wrappedJSObject.fetch(_url);
+ const requestcontent = await request.text();
+ is(
+ requestcontent,
+ `"use strict";\ndocument.title = "evaluated";\n`,
+ "the request content has been overriden"
+ );
+ const secondRequest = await content.wrappedJSObject.fetch(_url);
+ const secondRequestcontent = await secondRequest.text();
+ is(
+ secondRequestcontent,
+ `"use strict";\ndocument.title = "evaluated";\n`,
+ "the cached request content has been overriden"
+ );
+ }
+ );
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [GZIPPED_REQUEST_URL],
+ async _url => {
+ const script = await content.document.createElement("script");
+ const onLoad = new Promise(resolve =>
+ script.addEventListener("load", resolve, { once: true })
+ );
+ script.src = _url;
+ content.document.body.appendChild(script);
+ await onLoad;
+ is(
+ content.document.title,
+ "evaluated",
+ "The <script> tag content has been overriden and correctly evaluated"
+ );
+ }
+ );
+
+ await BrowserTestUtils.waitForCondition(() => eventsCount >= 1);
+
+ networkObserver.destroy();
+});
diff --git a/devtools/shared/network-observer/test/browser/browser_networkobserver_serviceworker.js b/devtools/shared/network-observer/test/browser/browser_networkobserver_serviceworker.js
new file mode 100644
index 0000000000..1680dc6005
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/browser_networkobserver_serviceworker.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests that all the expected service worker requests are received
+// by the network observer.
+add_task(async function testServiceWorkerSuccessRequests() {
+ await addTab(URL_ROOT + "doc_network-observer.html");
+
+ const REQUEST_URL =
+ URL_ROOT + `sjs_network-observer-test-server.sjs?sts=200&fmt=`;
+
+ const EXPECTED_REQUESTS = [
+ // The main service worker script request
+ `https://example.com/browser/devtools/shared/network-observer/test/browser/serviceworker.js`,
+ // The requests intercepted by the service worker
+ REQUEST_URL + "js",
+ REQUEST_URL + "css",
+ // The request initiated by the service worker
+ REQUEST_URL + "json",
+ ];
+
+ const onNetworkEvents = waitForNetworkEvents(null, 4);
+
+ info("Register the service worker and send requests...");
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [REQUEST_URL],
+ async url => {
+ await content.wrappedJSObject.registerServiceWorker();
+ content.wrappedJSObject.fetch(url + "js");
+ content.wrappedJSObject.fetch(url + "css");
+ }
+ );
+ const events = await onNetworkEvents;
+
+ is(events.length, 4, "Received the expected number of network events");
+ for (const { options, channel } of events) {
+ info(`Assert the info for the request from ${channel.URI.spec}`);
+ ok(
+ EXPECTED_REQUESTS.includes(channel.URI.spec),
+ `The request for ${channel.URI.spec} is an expected service worker request`
+ );
+ Assert.notStrictEqual(
+ channel.loadInfo.browsingContextID,
+ 0,
+ `The request for ${channel.URI.spec} has a Browsing Context ID of ${channel.loadInfo.browsingContextID}`
+ );
+ // The main service worker script request is not from the service worker
+ if (channel.URI.spec.includes("serviceworker.js")) {
+ ok(
+ !options.fromServiceWorker,
+ `The request for ${channel.URI.spec} is not from the service worker\n`
+ );
+ } else {
+ ok(
+ options.fromServiceWorker,
+ `The request for ${channel.URI.spec} is from the service worker\n`
+ );
+ }
+ }
+
+ info("Unregistering the service worker...");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ await content.wrappedJSObject.unregisterServiceWorker();
+ });
+});
+
+// Tests that the expected failed service worker request is received by the network observer.
+add_task(async function testServiceWorkerFailedRequests() {
+ await addTab(URL_ROOT + "doc_network-observer-missing-service-worker.html");
+
+ const REQUEST_URL =
+ URL_ROOT + `sjs_network-observer-test-server.sjs?sts=200&fmt=js`;
+
+ const EXPECTED_REQUESTS = [
+ // The main service worker script request which should be missing
+ "https://example.com/browser/devtools/shared/network-observer/test/browser/serviceworker-missing.js",
+ // A notrmal request
+ "https://example.com/browser/devtools/shared/network-observer/test/browser/sjs_network-observer-test-server.sjs?sts=200&fmt=js",
+ ];
+
+ const onNetworkEvents = waitForNetworkEvents(null, 2);
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [REQUEST_URL],
+ async url => {
+ await content.wrappedJSObject.registerServiceWorker();
+ content.wrappedJSObject.fetch(url);
+ }
+ );
+
+ const events = await onNetworkEvents;
+ is(events.length, 2, "Received the expected number of network events");
+
+ for (const { options, channel } of events) {
+ info(`Assert the info for the request from ${channel.URI.spec}`);
+ ok(
+ EXPECTED_REQUESTS.includes(channel.URI.spec),
+ `The request for ${channel.URI.spec} is an expected request`
+ );
+ Assert.notStrictEqual(
+ channel.loadInfo.browsingContextID,
+ 0,
+ `The request for ${channel.URI.spec} has a Browsing Context ID of ${channel.loadInfo.browsingContextID}`
+ );
+ ok(
+ !options.fromServiceWorker,
+ `The request for ${channel.URI.spec} is not from the service worker\n`
+ );
+ }
+});
diff --git a/devtools/shared/network-observer/test/browser/doc_network-observer-missing-service-worker.html b/devtools/shared/network-observer/test/browser/doc_network-observer-missing-service-worker.html
new file mode 100644
index 0000000000..396e51677c
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/doc_network-observer-missing-service-worker.html
@@ -0,0 +1,32 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
+ <meta http-equiv="Pragma" content="no-cache" />
+ <meta http-equiv="Expires" content="0" />
+ <title>Network Observer missing service worker test page</title>
+ </head>
+
+ <body>
+ <p>Network Observer test page</p>
+ <script type="text/javascript">
+ /* exported registerServiceWorker */
+ "use strict";
+
+ function registerServiceWorker() {
+ const sw = navigator.serviceWorker;
+ // NOTE: This service worker file does not exist which enables testing
+ // that a 404 requests is received.
+ return sw.register("serviceworker-missing.js")
+ .then(registration => {
+ throw new Error("The Service Worker file should not exist");
+ }).catch(err => {
+ console.log("Registration failed as expected");
+ });
+ }
+ </script>
+ </body>
+</html>
diff --git a/devtools/shared/network-observer/test/browser/doc_network-observer.html b/devtools/shared/network-observer/test/browser/doc_network-observer.html
new file mode 100644
index 0000000000..2ca400e0ae
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/doc_network-observer.html
@@ -0,0 +1,49 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
+ <meta http-equiv="Pragma" content="no-cache" />
+ <meta http-equiv="Expires" content="0" />
+ <title>Network Observer test page</title>
+ </head>
+ <body>
+ <p>Network Observer test page</p>
+ <script type="text/javascript">
+ /* exported registerServiceWorker, unregisterServiceWorker */
+ "use strict";
+
+ let swRegistration;
+
+ function registerServiceWorker() {
+ const sw = navigator.serviceWorker;
+ return sw.register("serviceworker.js")
+ .then(registration => {
+ swRegistration = registration;
+ console.log("Registered, scope is:", registration.scope);
+ return sw.ready;
+ }).then(() => {
+ // wait until the page is controlled
+ return new Promise(resolve => {
+ if (sw.controller) {
+ resolve();
+ } else {
+ sw.addEventListener("controllerchange", function () {
+ resolve();
+ }, { once: true });
+ }
+ });
+ }).catch(err => {
+ console.error("Registration failed");
+ });
+ }
+
+ function unregisterServiceWorker() {
+ return swRegistration.unregister();
+ }
+ </script>
+ </body>
+</html>
diff --git a/devtools/shared/network-observer/test/browser/gzipped.sjs b/devtools/shared/network-observer/test/browser/gzipped.sjs
new file mode 100644
index 0000000000..09d0b249b1
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/gzipped.sjs
@@ -0,0 +1,44 @@
+"use strict";
+
+function gzipCompressString(string, obs) {
+ const scs = Cc["@mozilla.org/streamConverters;1"].getService(
+ Ci.nsIStreamConverterService
+ );
+ const listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+ listener.init(obs);
+ const converter = scs.asyncConvertData(
+ "uncompressed",
+ "gzip",
+ listener,
+ null
+ );
+ const stringStream = Cc[
+ "@mozilla.org/io/string-input-stream;1"
+ ].createInstance(Ci.nsIStringInputStream);
+ stringStream.data = string;
+ converter.onStartRequest(null, null);
+ converter.onDataAvailable(null, stringStream, 0, string.length);
+ converter.onStopRequest(null, null, null);
+}
+
+const ORIGINAL_JS_CONTENT = `console.log("original javascript content");`;
+
+function handleRequest(request, response) {
+ response.processAsync();
+
+ // Generate data
+ response.setHeader("Content-Type", "application/javascript", false);
+ response.setHeader("Content-Encoding", "gzip", false);
+
+ const observer = {
+ onStreamComplete(loader, context, status, length, result) {
+ const buffer = String.fromCharCode.apply(this, result);
+ response.setHeader("Content-Length", "" + buffer.length, false);
+ response.write(buffer);
+ response.finish();
+ },
+ };
+ gzipCompressString(ORIGINAL_JS_CONTENT, observer);
+}
diff --git a/devtools/shared/network-observer/test/browser/head.js b/devtools/shared/network-observer/test/browser/head.js
new file mode 100644
index 0000000000..deb7becff6
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/head.js
@@ -0,0 +1,123 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ NetworkObserver:
+ "resource://devtools/shared/network-observer/NetworkObserver.sys.mjs",
+});
+
+const TEST_DIR = gTestPath.substr(0, gTestPath.lastIndexOf("/"));
+const CHROME_URL_ROOT = TEST_DIR + "/";
+const URL_ROOT = CHROME_URL_ROOT.replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+
+/**
+ * Load the provided url in an existing browser.
+ * Returns a promise which will resolve when the page is loaded.
+ *
+ * @param {Browser} browser
+ * The browser element where the URL should be loaded.
+ * @param {String} url
+ * The URL to load in the new tab
+ */
+async function loadURL(browser, url) {
+ const loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.startLoadingURIString(browser, url);
+ return loaded;
+}
+
+/**
+ * Create a new foreground tab loading the provided url.
+ * Returns a promise which will resolve when the page is loaded.
+ *
+ * @param {String} url
+ * The URL to load in the new tab
+ */
+async function addTab(url) {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ registerCleanupFunction(() => {
+ gBrowser.removeTab(tab);
+ });
+ return tab;
+}
+
+/**
+ * Base network event owner class implementing all mandatory callbacks and
+ * keeping track of which callbacks have been called.
+ */
+class NetworkEventOwner {
+ hasEventTimings = false;
+ hasResponseCache = false;
+ hasResponseContent = false;
+ hasResponseStart = false;
+ hasSecurityInfo = false;
+ hasServerTimings = false;
+
+ addEventTimings() {
+ this.hasEventTimings = true;
+ }
+ addResponseCache() {
+ this.hasResponseCache = true;
+ }
+ addResponseContent() {
+ this.hasResponseContent = true;
+ }
+ addResponseStart() {
+ this.hasResponseStart = true;
+ }
+ addSecurityInfo() {
+ this.hasSecurityInfo = true;
+ }
+ addServerTimings() {
+ this.hasServerTimings = true;
+ }
+ addServiceWorkerTimings() {
+ this.hasServiceWorkerTimings = true;
+ }
+}
+
+/**
+ * Create a simple network event owner, with mock implementations of all
+ * the expected APIs for a NetworkEventOwner.
+ */
+function createNetworkEventOwner(event) {
+ return new NetworkEventOwner();
+}
+
+/**
+ * Wait for network events matching the provided URL, until the count reaches
+ * the provided expected count.
+ *
+ * @param {string|null} expectedUrl
+ * The URL which should be monitored by the NetworkObserver.If set to null watch for
+ * all requests
+ * @param {number} expectedRequestsCount
+ * How many different events (requests) are expected.
+ * @returns {Promise}
+ * A promise which will resolve with an array of network event owners, when
+ * the expected event count is reached.
+ */
+async function waitForNetworkEvents(expectedUrl = null, expectedRequestsCount) {
+ const events = [];
+ const networkObserver = new NetworkObserver({
+ ignoreChannelFunction: channel =>
+ expectedUrl ? channel.URI.spec !== expectedUrl : false,
+ onNetworkEvent: () => {
+ info("waitForNetworkEvents received a new event");
+ const owner = createNetworkEventOwner();
+ events.push(owner);
+ return owner;
+ },
+ });
+ registerCleanupFunction(() => networkObserver.destroy());
+
+ info("Wait until the events count reaches " + expectedRequestsCount);
+ await BrowserTestUtils.waitForCondition(
+ () => events.length >= expectedRequestsCount
+ );
+ return events;
+}
diff --git a/devtools/shared/network-observer/test/browser/override.html b/devtools/shared/network-observer/test/browser/override.html
new file mode 100644
index 0000000000..0e3878e313
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/override.html
@@ -0,0 +1 @@
+<html><head></head><body>Overriden!</body></html>
diff --git a/devtools/shared/network-observer/test/browser/override.js b/devtools/shared/network-observer/test/browser/override.js
new file mode 100644
index 0000000000..7b000fcd0f
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/override.js
@@ -0,0 +1,2 @@
+"use strict";
+document.title = "evaluated";
diff --git a/devtools/shared/network-observer/test/browser/serviceworker.js b/devtools/shared/network-observer/test/browser/serviceworker.js
new file mode 100644
index 0000000000..3389581fb0
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/serviceworker.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+self.addEventListener("activate", async event => {
+ (
+ await fetch(
+ "https://example.com/browser/devtools/shared/network-observer/test/browser/sjs_network-observer-test-server.sjs?sts=200&fmt=json"
+ )
+ )
+ .json()
+ .then(() => console.log("json downloaded"));
+ // start controlling the already loaded page
+ event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener("fetch", event => {
+ const response = new Response("Service worker response", {
+ statusText: "OK",
+ });
+ event.respondWith(response);
+});
diff --git a/devtools/shared/network-observer/test/browser/sjs_network-auth-listener-test-server.sjs b/devtools/shared/network-observer/test/browser/sjs_network-auth-listener-test-server.sjs
new file mode 100644
index 0000000000..028a26ebfe
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/sjs_network-auth-listener-test-server.sjs
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function handleRequest(request, response) {
+ let body;
+
+ // Expect guest/guest as correct credentials, but `btoa` is unavailable in sjs
+ // "Z3Vlc3Q6Z3Vlc3Q=" == btoa("guest:guest")
+ const expectedHeader = "Basic Z3Vlc3Q6Z3Vlc3Q=";
+
+ // correct login credentials provided
+ if (
+ request.hasHeader("Authorization") &&
+ request.getHeader("Authorization") == expectedHeader
+ ) {
+ response.setStatusLine(request.httpVersion, 200, "OK, authorized");
+ response.setHeader("Content-Type", "text", false);
+
+ body = "success";
+ } else {
+ // incorrect credentials
+ response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
+ response.setHeader("Content-Type", "text", false);
+
+ body = "failed";
+ }
+ response.bodyOutputStream.write(body, body.length);
+}
diff --git a/devtools/shared/network-observer/test/browser/sjs_network-observer-test-server.sjs b/devtools/shared/network-observer/test/browser/sjs_network-observer-test-server.sjs
new file mode 100644
index 0000000000..b0947cadd1
--- /dev/null
+++ b/devtools/shared/network-observer/test/browser/sjs_network-observer-test-server.sjs
@@ -0,0 +1,196 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Simple server which can handle several response types and states.
+// Trimmed down from devtools/client/netmonitor/test/sjs_content-type-test-server.sjs
+// Additional features can be ported if needed.
+function handleRequest(request, response) {
+ response.processAsync();
+
+ const params = request.queryString.split("&");
+ const format = (params.filter(s => s.includes("fmt="))[0] || "").split(
+ "="
+ )[1];
+ const status =
+ (params.filter(s => s.includes("sts="))[0] || "").split("=")[1] || 200;
+
+ const cacheExpire = 60; // seconds
+
+ function setCacheHeaders() {
+ if (status != 304) {
+ response.setHeader(
+ "Cache-Control",
+ "no-cache, no-store, must-revalidate"
+ );
+ response.setHeader("Pragma", "no-cache");
+ response.setHeader("Expires", "0");
+ return;
+ }
+
+ response.setHeader("Expires", Date(Date.now() + cacheExpire * 1000), false);
+ }
+
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ timer.initWithCallback(
+ // eslint-disable-next-line complexity
+ () => {
+ // to avoid garbage collection
+ timer = null;
+ switch (format) {
+ case "txt": {
+ response.setStatusLine(request.httpVersion, status, "DA DA DA");
+ response.setHeader("Content-Type", "text/plain", false);
+ setCacheHeaders();
+
+ function convertToUtf8(str) {
+ return String.fromCharCode(...new TextEncoder().encode(str));
+ }
+
+ // This script must be evaluated as UTF-8 for this to write out the
+ // bytes of the string in UTF-8. If it's evaluated as Latin-1, the
+ // written bytes will be the result of UTF-8-encoding this string
+ // *twice*.
+ const data = "Братан, ты вообще качаешься?";
+ const stringOfUtf8Bytes = convertToUtf8(data);
+ response.write(stringOfUtf8Bytes);
+
+ response.finish();
+ break;
+ }
+ case "xml": {
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader("Content-Type", "text/xml; charset=utf-8", false);
+ setCacheHeaders();
+ response.write("<label value='greeting'>Hello XML!</label>");
+ response.finish();
+ break;
+ }
+ case "html": {
+ const content = (
+ params.filter(s => s.includes("res="))[0] || ""
+ ).split("=")[1];
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ setCacheHeaders();
+ response.write(content || "<p>Hello HTML!</p>");
+ response.finish();
+ break;
+ }
+ case "xhtml": {
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader(
+ "Content-Type",
+ "application/xhtml+xml; charset=utf-8",
+ false
+ );
+ setCacheHeaders();
+ response.write("<p>Hello XHTML!</p>");
+ response.finish();
+ break;
+ }
+ case "html-long": {
+ const str = new Array(102400 /* 100 KB in bytes */).join(".");
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ setCacheHeaders();
+ response.write("<p>" + str + "</p>");
+ response.finish();
+ break;
+ }
+ case "css": {
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader("Content-Type", "text/css; charset=utf-8", false);
+ setCacheHeaders();
+ response.write("body:pre { content: 'Hello CSS!' }");
+ response.finish();
+ break;
+ }
+ case "js": {
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader(
+ "Content-Type",
+ "application/javascript; charset=utf-8",
+ false
+ );
+ setCacheHeaders();
+ response.write("function() { return 'Hello JS!'; }");
+ response.finish();
+ break;
+ }
+ case "json": {
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader(
+ "Content-Type",
+ "application/json; charset=utf-8",
+ false
+ );
+ setCacheHeaders();
+ response.write('{ "greeting": "Hello JSON!" }');
+ response.finish();
+ break;
+ }
+
+ case "font": {
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader("Content-Type", "font/woff", false);
+ setCacheHeaders();
+ response.finish();
+ break;
+ }
+ case "image": {
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader("Content-Type", "image/png", false);
+ setCacheHeaders();
+ response.finish();
+ break;
+ }
+ case "application-ogg": {
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader("Content-Type", "application/ogg", false);
+ setCacheHeaders();
+ response.finish();
+ break;
+ }
+ case "audio": {
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader("Content-Type", "audio/ogg", false);
+ setCacheHeaders();
+ response.finish();
+ break;
+ }
+ case "video": {
+ response.setStatusLine(request.httpVersion, status, "OK");
+ response.setHeader("Content-Type", "video/webm", false);
+ setCacheHeaders();
+ response.finish();
+ break;
+ }
+ case "ws": {
+ response.setStatusLine(
+ request.httpVersion,
+ 101,
+ "Switching Protocols"
+ );
+ response.setHeader("Connection", "upgrade", false);
+ response.setHeader("Upgrade", "websocket", false);
+ setCacheHeaders();
+ response.finish();
+ break;
+ }
+ default: {
+ response.setStatusLine(request.httpVersion, 404, "Not Found");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ setCacheHeaders();
+ response.write("<blink>Not Found</blink>");
+ response.finish();
+ break;
+ }
+ }
+ },
+ 10,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ ); // Make sure this request takes a few ms.
+}
diff --git a/devtools/shared/network-observer/test/xpcshell/head.js b/devtools/shared/network-observer/test/xpcshell/head.js
new file mode 100644
index 0000000000..93b66e4632
--- /dev/null
+++ b/devtools/shared/network-observer/test/xpcshell/head.js
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ NetworkHelper:
+ "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs",
+});
diff --git a/devtools/shared/network-observer/test/xpcshell/test_network_helper.js b/devtools/shared/network-observer/test/xpcshell/test_network_helper.js
new file mode 100644
index 0000000000..ff514ab98e
--- /dev/null
+++ b/devtools/shared/network-observer/test/xpcshell/test_network_helper.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ test_isTextMimeType();
+ test_parseCookieHeader();
+}
+
+function test_isTextMimeType() {
+ Assert.equal(NetworkHelper.isTextMimeType("text/plain"), true);
+ Assert.equal(NetworkHelper.isTextMimeType("application/javascript"), true);
+ Assert.equal(NetworkHelper.isTextMimeType("application/json"), true);
+ Assert.equal(NetworkHelper.isTextMimeType("text/css"), true);
+ Assert.equal(NetworkHelper.isTextMimeType("text/html"), true);
+ Assert.equal(NetworkHelper.isTextMimeType("image/svg+xml"), true);
+ Assert.equal(NetworkHelper.isTextMimeType("application/xml"), true);
+
+ // Test custom JSON subtype
+ Assert.equal(
+ NetworkHelper.isTextMimeType("application/vnd.tent.posts-feed.v0+json"),
+ true
+ );
+ Assert.equal(
+ NetworkHelper.isTextMimeType("application/vnd.tent.posts-feed.v0-json"),
+ true
+ );
+ // Test custom XML subtype
+ Assert.equal(
+ NetworkHelper.isTextMimeType("application/vnd.tent.posts-feed.v0+xml"),
+ true
+ );
+ Assert.equal(
+ NetworkHelper.isTextMimeType("application/vnd.tent.posts-feed.v0-xml"),
+ false
+ );
+ // Test case-insensitive
+ Assert.equal(
+ NetworkHelper.isTextMimeType("application/vnd.BIG-CORP+json"),
+ true
+ );
+ // Test non-text type
+ Assert.equal(NetworkHelper.isTextMimeType("image/png"), false);
+ // Test invalid types
+ Assert.equal(NetworkHelper.isTextMimeType("application/foo-+json"), false);
+ Assert.equal(NetworkHelper.isTextMimeType("application/-foo+json"), false);
+ Assert.equal(
+ NetworkHelper.isTextMimeType("application/foo--bar+json"),
+ false
+ );
+
+ // Test we do not cause internal errors with unoptimized regex. Bug 961097
+ Assert.equal(
+ NetworkHelper.isTextMimeType("application/vnd.google.safebrowsing-chunk"),
+ false
+ );
+}
+
+function test_parseCookieHeader() {
+ let result = NetworkHelper.parseSetCookieHeaders(["Test=1; SameSite=Strict"]);
+ Assert.deepEqual(result, [{ name: "Test", value: "1", samesite: "Strict" }]);
+
+ result = NetworkHelper.parseSetCookieHeaders(["Test=1; SameSite=strict"]);
+ Assert.deepEqual(result, [{ name: "Test", value: "1", samesite: "Strict" }]);
+
+ result = NetworkHelper.parseSetCookieHeaders(["Test=1; SameSite=STRICT"]);
+ Assert.deepEqual(result, [{ name: "Test", value: "1", samesite: "Strict" }]);
+
+ result = NetworkHelper.parseSetCookieHeaders(["Test=1; SameSite=None"]);
+ Assert.deepEqual(result, [{ name: "Test", value: "1", samesite: "None" }]);
+
+ result = NetworkHelper.parseSetCookieHeaders(["Test=1; SameSite=NONE"]);
+ Assert.deepEqual(result, [{ name: "Test", value: "1", samesite: "None" }]);
+
+ result = NetworkHelper.parseSetCookieHeaders(["Test=1; SameSite=lax"]);
+ Assert.deepEqual(result, [{ name: "Test", value: "1", samesite: "Lax" }]);
+
+ result = NetworkHelper.parseSetCookieHeaders(["Test=1; SameSite=Lax"]);
+ Assert.deepEqual(result, [{ name: "Test", value: "1", samesite: "Lax" }]);
+
+ result = NetworkHelper.parseSetCookieHeaders([
+ "Test=1; SameSite=Lax",
+ "Foo=2; SameSite=None",
+ ]);
+ Assert.deepEqual(result, [
+ { name: "Test", value: "1", samesite: "Lax" },
+ { name: "Foo", value: "2", samesite: "None" },
+ ]);
+}
diff --git a/devtools/shared/network-observer/test/xpcshell/test_security-info-certificate.js b/devtools/shared/network-observer/test/xpcshell/test_security-info-certificate.js
new file mode 100644
index 0000000000..00f482b8ca
--- /dev/null
+++ b/devtools/shared/network-observer/test/xpcshell/test_security-info-certificate.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Tests that NetworkHelper.parseCertificateInfo parses certificate information
+// correctly.
+
+const DUMMY_CERT = {
+ getBase64DERString() {
+ // This is the base64-encoded contents of the "DigiCert ECC Secure Server CA"
+ // intermediate certificate as issued by "DigiCert Global Root CA". It was
+ // chosen as a test certificate because it has an issuer common name,
+ // organization, and organizational unit that are somewhat distinct from
+ // its subject common name and organization name.
+ return "MIIDrDCCApSgAwIBAgIQCssoukZe5TkIdnRw883GEjANBgkqhkiG9w0BAQwFADBhMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBDQTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaMEwxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJjAkBgNVBAMTHURpZ2lDZXJ0IEVDQyBTZWN1cmUgU2VydmVyIENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ghC6nfYJN6gLGSkE85AnCNyqQIKDjc/ITa4jVMU9tWRlUvzlgKNcR7E2Munn17voOZ/WpIRllNv68DLP679Wz9HJOeaBy6Wvqgvu1cYr3GkvXg6HuhbPGtkESvMNCuMo4IBITCCAR0wEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0R2xvYmFsUm9vdENBLmNybDA9BgNVHSAENjA0MDIGBFUdIAAwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAdBgNVHQ4EFgQUo53mH/naOU/AbuiRy5Wl2jHiCp8wHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUwDQYJKoZIhvcNAQEMBQADggEBAMeKoENL7HTJxavVHzA1Nm6YVntIrAVjrnuaVyRXzG/63qttnMe2uuzO58pzZNvfBDcKAEmzP58mrZGMIOgfiA4q+2Y3yDDo0sIkp0VILeoBUEoxlBPfjV/aKrtJPGHzecicZpIalir0ezZYoyxBEHQa0+1IttK7igZFcTMQMHp6mCHdJLnsnLWSB62DxsRq+HfmNb4TDydkskO/g+l3VtsIh5RHFPVfKK+jaEyDj2D3loB5hWp2Jp2VDCADjT7ueihlZGak2YPqmXTNbk19HOuNssWvFhtOyPNV6og4ETQdEa8/B6hPatJ0ES8q/HO3X8IVQwVs1n3aAr0im0/T+Xc=";
+ },
+};
+
+add_task(async function run_test() {
+ info("Testing NetworkHelper.parseCertificateInfo.");
+
+ const result = await NetworkHelper.parseCertificateInfo(
+ DUMMY_CERT,
+ new Map()
+ );
+
+ // Subject
+ equal(
+ result.subject.commonName,
+ "DigiCert ECC Secure Server CA",
+ "Common name is correct."
+ );
+ equal(
+ result.subject.organization,
+ "DigiCert Inc",
+ "Organization is correct."
+ );
+ equal(
+ result.subject.organizationUnit,
+ undefined,
+ "Organizational unit is correct."
+ );
+
+ // Issuer
+ equal(
+ result.issuer.commonName,
+ "DigiCert Global Root CA",
+ "Common name of the issuer is correct."
+ );
+ equal(
+ result.issuer.organization,
+ "DigiCert Inc",
+ "Organization of the issuer is correct."
+ );
+ equal(
+ result.issuer.organizationUnit,
+ "www.digicert.com",
+ "Organizational unit of the issuer is correct."
+ );
+
+ // Validity
+ equal(
+ result.validity.start,
+ "Fri, 08 Mar 2013 12:00:00 GMT",
+ "Start of the validity period is correct."
+ );
+ equal(
+ result.validity.end,
+ "Wed, 08 Mar 2023 12:00:00 GMT",
+ "End of the validity period is correct."
+ );
+
+ // Fingerprints
+ equal(
+ result.fingerprint.sha1,
+ "56:EE:7C:27:06:83:16:2D:83:BA:EA:CC:79:0E:22:47:1A:DA:AB:E8",
+ "Certificate SHA1 fingerprint is correct."
+ );
+ equal(
+ result.fingerprint.sha256,
+ "45:84:46:BA:75:D9:32:E9:14:F2:3C:2B:57:B7:D1:92:ED:DB:C2:18:1D:95:8E:11:81:AD:52:51:74:7A:1E:E8",
+ "Certificate SHA256 fingerprint is correct."
+ );
+});
diff --git a/devtools/shared/network-observer/test/xpcshell/test_security-info-parser.js b/devtools/shared/network-observer/test/xpcshell/test_security-info-parser.js
new file mode 100644
index 0000000000..9515851a8b
--- /dev/null
+++ b/devtools/shared/network-observer/test/xpcshell/test_security-info-parser.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that NetworkHelper.parseSecurityInfo returns correctly formatted object.
+
+const wpl = Ci.nsIWebProgressListener;
+const MockCertificate = {
+ getBase64DERString() {
+ // This is the same test certificate as in
+ // test_security-info-certificate.js for consistency.
+ return "MIIDrDCCApSgAwIBAgIQCssoukZe5TkIdnRw883GEjANBgkqhkiG9w0BAQwFADBhMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBDQTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaMEwxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJjAkBgNVBAMTHURpZ2lDZXJ0IEVDQyBTZWN1cmUgU2VydmVyIENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ghC6nfYJN6gLGSkE85AnCNyqQIKDjc/ITa4jVMU9tWRlUvzlgKNcR7E2Munn17voOZ/WpIRllNv68DLP679Wz9HJOeaBy6Wvqgvu1cYr3GkvXg6HuhbPGtkESvMNCuMo4IBITCCAR0wEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0R2xvYmFsUm9vdENBLmNybDA9BgNVHSAENjA0MDIGBFUdIAAwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAdBgNVHQ4EFgQUo53mH/naOU/AbuiRy5Wl2jHiCp8wHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUwDQYJKoZIhvcNAQEMBQADggEBAMeKoENL7HTJxavVHzA1Nm6YVntIrAVjrnuaVyRXzG/63qttnMe2uuzO58pzZNvfBDcKAEmzP58mrZGMIOgfiA4q+2Y3yDDo0sIkp0VILeoBUEoxlBPfjV/aKrtJPGHzecicZpIalir0ezZYoyxBEHQa0+1IttK7igZFcTMQMHp6mCHdJLnsnLWSB62DxsRq+HfmNb4TDydkskO/g+l3VtsIh5RHFPVfKK+jaEyDj2D3loB5hWp2Jp2VDCADjT7ueihlZGak2YPqmXTNbk19HOuNssWvFhtOyPNV6og4ETQdEa8/B6hPatJ0ES8q/HO3X8IVQwVs1n3aAr0im0/T+Xc=";
+ },
+};
+
+// This *cannot* be used as an nsITransportSecurityInfo (since that interface is
+// builtinclass) but the methods being tested aren't defined by XPCOM and aren't
+// calling QueryInterface, so this usage is fine.
+const MockSecurityInfo = {
+ securityState: wpl.STATE_IS_SECURE,
+ errorCode: 0,
+ cipherName: "TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256",
+ // TLS_VERSION_1_2
+ protocolVersion: 3,
+ serverCert: MockCertificate,
+};
+
+add_task(async function run_test() {
+ const result = await NetworkHelper.parseSecurityInfo(
+ MockSecurityInfo,
+ {},
+ {},
+ new Map()
+ );
+
+ equal(result.state, "secure", "State is correct.");
+
+ equal(
+ result.cipherSuite,
+ MockSecurityInfo.cipherName,
+ "Cipher suite is correct."
+ );
+
+ equal(result.protocolVersion, "TLSv1.2", "Protocol version is correct.");
+
+ deepEqual(
+ result.cert,
+ await NetworkHelper.parseCertificateInfo(MockCertificate, new Map()),
+ "Certificate information is correct."
+ );
+
+ equal(result.hpkp, false, "HPKP is false when URI is not available.");
+ equal(result.hsts, false, "HSTS is false when URI is not available.");
+});
diff --git a/devtools/shared/network-observer/test/xpcshell/test_security-info-protocol-version.js b/devtools/shared/network-observer/test/xpcshell/test_security-info-protocol-version.js
new file mode 100644
index 0000000000..a81f7ce73c
--- /dev/null
+++ b/devtools/shared/network-observer/test/xpcshell/test_security-info-protocol-version.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Tests that NetworkHelper.formatSecurityProtocol returns correct
+// protocol version strings.
+
+const TEST_CASES = [
+ {
+ description: "TLS_VERSION_1",
+ input: 1,
+ expected: "TLSv1",
+ },
+ {
+ description: "TLS_VERSION_1.1",
+ input: 2,
+ expected: "TLSv1.1",
+ },
+ {
+ description: "TLS_VERSION_1.2",
+ input: 3,
+ expected: "TLSv1.2",
+ },
+ {
+ description: "TLS_VERSION_1.3",
+ input: 4,
+ expected: "TLSv1.3",
+ },
+ {
+ description: "invalid version",
+ input: -1,
+ expected: "Unknown",
+ },
+];
+
+function run_test() {
+ info("Testing NetworkHelper.formatSecurityProtocol.");
+
+ for (const { description, input, expected } of TEST_CASES) {
+ info("Testing " + description);
+
+ equal(
+ NetworkHelper.formatSecurityProtocol(input),
+ expected,
+ "Got the expected protocol string."
+ );
+ }
+}
diff --git a/devtools/shared/network-observer/test/xpcshell/test_security-info-state.js b/devtools/shared/network-observer/test/xpcshell/test_security-info-state.js
new file mode 100644
index 0000000000..be622b2019
--- /dev/null
+++ b/devtools/shared/network-observer/test/xpcshell/test_security-info-state.js
@@ -0,0 +1,122 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Tests that security info parser gives correct general security state for
+// different cases.
+
+const wpl = Ci.nsIWebProgressListener;
+
+// This *cannot* be used as an nsITransportSecurityInfo (since that interface is
+// builtinclass) but the methods being tested aren't defined by XPCOM and aren't
+// calling QueryInterface, so this usage is fine.
+const MockSecurityInfo = {
+ securityState: wpl.STATE_IS_BROKEN,
+ errorCode: 0,
+ // nsISSLStatus.TLS_VERSION_1_2
+ protocolVersion: 3,
+ cipherName: "TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256",
+};
+
+add_task(async function run_test() {
+ await test_nullSecurityInfo();
+ await test_insecureSecurityInfoWithNSSError();
+ await test_insecureSecurityInfoWithoutNSSError();
+ await test_brokenSecurityInfo();
+ await test_secureSecurityInfo();
+});
+
+/**
+ * Test that undefined security information is returns "insecure".
+ */
+async function test_nullSecurityInfo() {
+ const result = await NetworkHelper.parseSecurityInfo(null, {}, {}, new Map());
+ equal(
+ result.state,
+ "insecure",
+ "state == 'insecure' when securityInfo was undefined"
+ );
+}
+
+/**
+ * Test that STATE_IS_INSECURE with NSSError returns "broken"
+ */
+async function test_insecureSecurityInfoWithNSSError() {
+ MockSecurityInfo.securityState = wpl.STATE_IS_INSECURE;
+
+ // Taken from security/manager/ssl/tests/unit/head_psm.js.
+ MockSecurityInfo.errorCode = -8180;
+
+ const result = await NetworkHelper.parseSecurityInfo(
+ MockSecurityInfo,
+ {},
+ {},
+ new Map()
+ );
+ equal(
+ result.state,
+ "broken",
+ "state == 'broken' if securityState contains STATE_IS_INSECURE flag AND " +
+ "errorCode is NSS error."
+ );
+
+ MockSecurityInfo.errorCode = 0;
+}
+
+/**
+ * Test that STATE_IS_INSECURE without NSSError returns "insecure"
+ */
+async function test_insecureSecurityInfoWithoutNSSError() {
+ MockSecurityInfo.securityState = wpl.STATE_IS_INSECURE;
+
+ const result = await NetworkHelper.parseSecurityInfo(
+ MockSecurityInfo,
+ {},
+ {},
+ new Map()
+ );
+ equal(
+ result.state,
+ "insecure",
+ "state == 'insecure' if securityState contains STATE_IS_INSECURE flag BUT " +
+ "errorCode is not NSS error."
+ );
+}
+
+/**
+ * Test that STATE_IS_SECURE returns "secure"
+ */
+async function test_secureSecurityInfo() {
+ MockSecurityInfo.securityState = wpl.STATE_IS_SECURE;
+
+ const result = await NetworkHelper.parseSecurityInfo(
+ MockSecurityInfo,
+ {},
+ {},
+ new Map()
+ );
+ equal(
+ result.state,
+ "secure",
+ "state == 'secure' if securityState contains STATE_IS_SECURE flag"
+ );
+}
+
+/**
+ * Test that STATE_IS_BROKEN returns "weak"
+ */
+async function test_brokenSecurityInfo() {
+ MockSecurityInfo.securityState = wpl.STATE_IS_BROKEN;
+
+ const result = await NetworkHelper.parseSecurityInfo(
+ MockSecurityInfo,
+ {},
+ {},
+ new Map()
+ );
+ equal(
+ result.state,
+ "weak",
+ "state == 'weak' if securityState contains STATE_IS_BROKEN flag"
+ );
+}
diff --git a/devtools/shared/network-observer/test/xpcshell/test_security-info-static-hpkp.js b/devtools/shared/network-observer/test/xpcshell/test_security-info-static-hpkp.js
new file mode 100644
index 0000000000..f1aa883b93
--- /dev/null
+++ b/devtools/shared/network-observer/test/xpcshell/test_security-info-static-hpkp.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that NetworkHelper.parseSecurityInfo correctly detects static hpkp pins
+
+const wpl = Ci.nsIWebProgressListener;
+
+// This *cannot* be used as an nsITransportSecurityInfo (since that interface is
+// builtinclass) but the methods being tested aren't defined by XPCOM and aren't
+// calling QueryInterface, so this usage is fine.
+const MockSecurityInfo = {
+ securityState: wpl.STATE_IS_SECURE,
+ errorCode: 0,
+ cipherName: "TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256",
+ // TLS_VERSION_1_2
+ protocolVersion: 3,
+ serverCert: {
+ getBase64DERString() {
+ // This is the same test certificate as in
+ // test_security-info-certificate.js for consistency.
+ return "MIIDrDCCApSgAwIBAgIQCssoukZe5TkIdnRw883GEjANBgkqhkiG9w0BAQwFADBhMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBDQTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaMEwxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJjAkBgNVBAMTHURpZ2lDZXJ0IEVDQyBTZWN1cmUgU2VydmVyIENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ghC6nfYJN6gLGSkE85AnCNyqQIKDjc/ITa4jVMU9tWRlUvzlgKNcR7E2Munn17voOZ/WpIRllNv68DLP679Wz9HJOeaBy6Wvqgvu1cYr3GkvXg6HuhbPGtkESvMNCuMo4IBITCCAR0wEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0R2xvYmFsUm9vdENBLmNybDA9BgNVHSAENjA0MDIGBFUdIAAwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAdBgNVHQ4EFgQUo53mH/naOU/AbuiRy5Wl2jHiCp8wHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUwDQYJKoZIhvcNAQEMBQADggEBAMeKoENL7HTJxavVHzA1Nm6YVntIrAVjrnuaVyRXzG/63qttnMe2uuzO58pzZNvfBDcKAEmzP58mrZGMIOgfiA4q+2Y3yDDo0sIkp0VILeoBUEoxlBPfjV/aKrtJPGHzecicZpIalir0ezZYoyxBEHQa0+1IttK7igZFcTMQMHp6mCHdJLnsnLWSB62DxsRq+HfmNb4TDydkskO/g+l3VtsIh5RHFPVfKK+jaEyDj2D3loB5hWp2Jp2VDCADjT7ueihlZGak2YPqmXTNbk19HOuNssWvFhtOyPNV6og4ETQdEa8/B6hPatJ0ES8q/HO3X8IVQwVs1n3aAr0im0/T+Xc=";
+ },
+ },
+};
+
+const MockHttpInfo = {
+ hostname: "include-subdomains.pinning.example.com",
+ private: false,
+};
+
+add_task(async function run_test() {
+ Services.prefs.setIntPref("security.cert_pinning.enforcement_level", 1);
+ const result = await NetworkHelper.parseSecurityInfo(
+ MockSecurityInfo,
+ {},
+ MockHttpInfo,
+ new Map()
+ );
+ equal(result.hpkp, true, "Static HPKP detected.");
+});
diff --git a/devtools/shared/network-observer/test/xpcshell/test_security-info-weakness-reasons.js b/devtools/shared/network-observer/test/xpcshell/test_security-info-weakness-reasons.js
new file mode 100644
index 0000000000..71d7675284
--- /dev/null
+++ b/devtools/shared/network-observer/test/xpcshell/test_security-info-weakness-reasons.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Tests that NetworkHelper.getReasonsForWeakness returns correct reasons for
+// weak requests.
+
+const wpl = Ci.nsIWebProgressListener;
+const TEST_CASES = [
+ {
+ description: "weak cipher",
+ input: wpl.STATE_IS_BROKEN | wpl.STATE_USES_WEAK_CRYPTO,
+ expected: ["cipher"],
+ },
+ {
+ description: "only STATE_IS_BROKEN flag",
+ input: wpl.STATE_IS_BROKEN,
+ expected: [],
+ },
+ {
+ description: "only STATE_IS_SECURE flag",
+ input: wpl.STATE_IS_SECURE,
+ expected: [],
+ },
+];
+
+function run_test() {
+ info("Testing NetworkHelper.getReasonsForWeakness.");
+
+ for (const { description, input, expected } of TEST_CASES) {
+ info("Testing " + description);
+
+ deepEqual(
+ NetworkHelper.getReasonsForWeakness(input),
+ expected,
+ "Got the expected reasons for weakness."
+ );
+ }
+}
diff --git a/devtools/shared/network-observer/test/xpcshell/test_throttle.js b/devtools/shared/network-observer/test/xpcshell/test_throttle.js
new file mode 100644
index 0000000000..5f8ef589fb
--- /dev/null
+++ b/devtools/shared/network-observer/test/xpcshell/test_throttle.js
@@ -0,0 +1,162 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* eslint-disable mozilla/use-chromeutils-generateqi */
+
+const { NetworkThrottleManager } = ChromeUtils.importESModule(
+ "resource://devtools/shared/network-observer/NetworkThrottleManager.sys.mjs"
+);
+const nsIScriptableInputStream = Ci.nsIScriptableInputStream;
+
+function TestStreamListener() {
+ this.state = "initial";
+}
+TestStreamListener.prototype = {
+ onStartRequest() {
+ this.setState("start");
+ },
+
+ onStopRequest() {
+ this.setState("stop");
+ },
+
+ onDataAvailable(request, inputStream, offset, count) {
+ const sin = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ nsIScriptableInputStream
+ );
+ sin.init(inputStream);
+ this.data = sin.read(count);
+ this.setState("data");
+ },
+
+ setState(state) {
+ this.state = state;
+ if (this._deferred) {
+ this._deferred.resolve(state);
+ this._deferred = null;
+ }
+ },
+
+ onStateChanged() {
+ if (!this._deferred) {
+ let resolve, reject;
+ const promise = new Promise(function (res, rej) {
+ resolve = res;
+ reject = rej;
+ });
+ this._deferred = { resolve, reject, promise };
+ }
+ return this._deferred.promise;
+ },
+};
+
+function TestChannel() {
+ this.state = "initial";
+ this.testListener = new TestStreamListener();
+ this._throttleQueue = null;
+}
+TestChannel.prototype = {
+ QueryInterface() {
+ return this;
+ },
+
+ get throttleQueue() {
+ return this._throttleQueue;
+ },
+
+ set throttleQueue(q) {
+ this._throttleQueue = q;
+ this.state = "throttled";
+ },
+
+ setNewListener(listener) {
+ this.listener = listener;
+ this.state = "listener";
+ return this.testListener;
+ },
+};
+
+add_task(async function () {
+ const throttler = new NetworkThrottleManager({
+ latencyMean: 1,
+ latencyMax: 1,
+ downloadBPSMean: 500,
+ downloadBPSMax: 500,
+ uploadBPSMean: 500,
+ uploadBPSMax: 500,
+ });
+
+ const uploadChannel = new TestChannel();
+ throttler.manageUpload(uploadChannel);
+ equal(
+ uploadChannel.state,
+ "throttled",
+ "NetworkThrottleManager set throttleQueue"
+ );
+
+ const downloadChannel = new TestChannel();
+ const testListener = downloadChannel.testListener;
+
+ const listener = throttler.manage(downloadChannel);
+ equal(
+ downloadChannel.state,
+ "listener",
+ "NetworkThrottleManager called setNewListener"
+ );
+
+ equal(testListener.state, "initial", "test listener in initial state");
+
+ // This method must be passed through immediately.
+ listener.onStartRequest(null);
+ equal(testListener.state, "start", "test listener started");
+
+ const TEST_INPUT = "hi bob";
+
+ const testStream = Cc["@mozilla.org/storagestream;1"].createInstance(
+ Ci.nsIStorageStream
+ );
+ testStream.init(512, 512);
+ const out = testStream.getOutputStream(0);
+ out.write(TEST_INPUT, TEST_INPUT.length);
+ out.close();
+ const testInputStream = testStream.newInputStream(0);
+
+ const activityDistributor = Cc[
+ "@mozilla.org/network/http-activity-distributor;1"
+ ].getService(Ci.nsIHttpActivityDistributor);
+ let activitySeen = false;
+ listener.addActivityCallback(
+ () => {
+ activitySeen = true;
+ },
+ null,
+ null,
+ null,
+ activityDistributor.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE,
+ null,
+ TEST_INPUT.length,
+ null
+ );
+
+ // onDataAvailable is required to immediately read the data.
+ listener.onDataAvailable(null, testInputStream, 0, 6);
+ equal(testInputStream.available(), 0, "no more data should be available");
+ equal(
+ testListener.state,
+ "start",
+ "test listener should not have received data"
+ );
+ equal(activitySeen, false, "activity not distributed yet");
+
+ let newState = await testListener.onStateChanged();
+ equal(newState, "data", "test listener received data");
+ equal(testListener.data, TEST_INPUT, "test listener received all the data");
+ equal(activitySeen, true, "activity has been distributed");
+
+ const onChange = testListener.onStateChanged();
+ listener.onStopRequest(null, null);
+ newState = await onChange;
+ equal(newState, "stop", "onStateChanged reported");
+});
diff --git a/devtools/shared/network-observer/test/xpcshell/xpcshell.toml b/devtools/shared/network-observer/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..d10f08ac82
--- /dev/null
+++ b/devtools/shared/network-observer/test/xpcshell/xpcshell.toml
@@ -0,0 +1,22 @@
+[DEFAULT]
+tags = "devtools"
+head = "head.js"
+firefox-appdir = "browser"
+skip-if = ["os == 'android'"]
+support-files = ""
+
+["test_network_helper.js"]
+
+["test_security-info-certificate.js"]
+
+["test_security-info-parser.js"]
+
+["test_security-info-protocol-version.js"]
+
+["test_security-info-state.js"]
+
+["test_security-info-static-hpkp.js"]
+
+["test_security-info-weakness-reasons.js"]
+
+["test_throttle.js"]