summaryrefslogtreecommitdiffstats
path: root/remote/cdp/observers/NetworkObserver.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'remote/cdp/observers/NetworkObserver.sys.mjs')
-rw-r--r--remote/cdp/observers/NetworkObserver.sys.mjs633
1 files changed, 633 insertions, 0 deletions
diff --git a/remote/cdp/observers/NetworkObserver.sys.mjs b/remote/cdp/observers/NetworkObserver.sys.mjs
new file mode 100644
index 0000000000..a85aa2d44f
--- /dev/null
+++ b/remote/cdp/observers/NetworkObserver.sys.mjs
@@ -0,0 +1,633 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CommonUtils: "resource://services-common/utils.sys.mjs",
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+
+ ChannelEventSinkFactory:
+ "chrome://remote/content/cdp/observers/ChannelEventSink.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gActivityDistributor",
+ "@mozilla.org/network/http-activity-distributor;1",
+ "nsIHttpActivityDistributor"
+);
+
+const CC = Components.Constructor;
+
+XPCOMUtils.defineLazyGetter(lazy, "BinaryInputStream", () => {
+ return CC(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+ );
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "BinaryOutputStream", () => {
+ return CC(
+ "@mozilla.org/binaryoutputstream;1",
+ "nsIBinaryOutputStream",
+ "setOutputStream"
+ );
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "StorageStream", () => {
+ return CC("@mozilla.org/storagestream;1", "nsIStorageStream", "init");
+});
+
+// Cap response storage with 100Mb per tracked tab.
+const MAX_RESPONSE_STORAGE_SIZE = 100 * 1024 * 1024;
+
+export class NetworkObserver {
+ constructor() {
+ lazy.EventEmitter.decorate(this);
+ this._browserSessionCount = new Map();
+ lazy.gActivityDistributor.addObserver(this);
+ lazy.ChannelEventSinkFactory.getService().registerCollector(this);
+
+ this._redirectMap = new Map();
+
+ // Request interception state.
+ this._browserSuspendedChannels = new Map();
+ this._extraHTTPHeaders = new Map();
+ this._browserResponseStorages = new Map();
+
+ this._onRequest = this._onRequest.bind(this);
+ this._onExamineResponse = this._onResponse.bind(
+ this,
+ false /* fromCache */
+ );
+ this._onCachedResponse = this._onResponse.bind(this, true /* fromCache */);
+ }
+
+ dispose() {
+ lazy.gActivityDistributor.removeObserver(this);
+ lazy.ChannelEventSinkFactory.getService().unregisterCollector(this);
+
+ Services.obs.removeObserver(this._onRequest, "http-on-modify-request");
+ Services.obs.removeObserver(
+ this._onExamineResponse,
+ "http-on-examine-response"
+ );
+ Services.obs.removeObserver(
+ this._onCachedResponse,
+ "http-on-examine-cached-response"
+ );
+ Services.obs.removeObserver(
+ this._onCachedResponse,
+ "http-on-examine-merged-response"
+ );
+ }
+
+ setExtraHTTPHeaders(browser, headers) {
+ if (!headers) {
+ this._extraHTTPHeaders.delete(browser);
+ } else {
+ this._extraHTTPHeaders.set(browser, headers);
+ }
+ }
+
+ enableRequestInterception(browser) {
+ if (!this._browserSuspendedChannels.has(browser)) {
+ this._browserSuspendedChannels.set(browser, new Map());
+ }
+ }
+
+ disableRequestInterception(browser) {
+ const suspendedChannels = this._browserSuspendedChannels.get(browser);
+ if (!suspendedChannels) {
+ return;
+ }
+ this._browserSuspendedChannels.delete(browser);
+ for (const channel of suspendedChannels.values()) {
+ channel.resume();
+ }
+ }
+
+ resumeSuspendedRequest(browser, requestId, headers) {
+ const suspendedChannels = this._browserSuspendedChannels.get(browser);
+ if (!suspendedChannels) {
+ throw new Error(`Request interception is not enabled`);
+ }
+ const httpChannel = suspendedChannels.get(requestId);
+ if (!httpChannel) {
+ throw new Error(`Cannot find request "${requestId}"`);
+ }
+ if (headers) {
+ // 1. Clear all previous headers.
+ for (const header of requestHeaders(httpChannel)) {
+ httpChannel.setRequestHeader(header.name, "", false /* merge */);
+ }
+ // 2. Set new headers.
+ for (const header of headers) {
+ httpChannel.setRequestHeader(
+ header.name,
+ header.value,
+ false /* merge */
+ );
+ }
+ }
+ suspendedChannels.delete(requestId);
+ httpChannel.resume();
+ }
+
+ getResponseBody(browser, requestId) {
+ const responseStorage = this._browserResponseStorages.get(browser);
+ if (!responseStorage) {
+ throw new Error("Responses are not tracked for the given browser");
+ }
+ return responseStorage.getBase64EncodedResponse(requestId);
+ }
+
+ abortSuspendedRequest(browser, aRequestId) {
+ const suspendedChannels = this._browserSuspendedChannels.get(browser);
+ if (!suspendedChannels) {
+ throw new Error(`Request interception is not enabled`);
+ }
+ const httpChannel = suspendedChannels.get(aRequestId);
+ if (!httpChannel) {
+ throw new Error(`Cannot find request "${aRequestId}"`);
+ }
+ suspendedChannels.delete(aRequestId);
+ httpChannel.cancel(Cr.NS_ERROR_FAILURE);
+ httpChannel.resume();
+ this.emit("requestfailed", httpChannel, {
+ requestId: requestId(httpChannel),
+ errorCode: getNetworkErrorStatusText(httpChannel.status),
+ });
+ }
+
+ _onChannelRedirect(oldChannel, newChannel) {
+ // We can be called with any nsIChannel, but are interested only in HTTP channels
+ try {
+ oldChannel.QueryInterface(Ci.nsIHttpChannel);
+ newChannel.QueryInterface(Ci.nsIHttpChannel);
+ } catch (ex) {
+ return;
+ }
+
+ const httpChannel = oldChannel.QueryInterface(Ci.nsIHttpChannel);
+ const loadContext = getLoadContext(httpChannel);
+ if (
+ !loadContext ||
+ !this._browserSessionCount.has(loadContext.topFrameElement)
+ ) {
+ return;
+ }
+ this._redirectMap.set(newChannel, oldChannel);
+ }
+
+ _onRequest(channel, topic) {
+ const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
+ const loadContext = getLoadContext(httpChannel);
+ const browser = loadContext?.topFrameElement;
+ if (!loadContext || !this._browserSessionCount.has(browser)) {
+ return;
+ }
+
+ const extraHeaders = this._extraHTTPHeaders.get(browser);
+ if (extraHeaders) {
+ for (const header of extraHeaders) {
+ httpChannel.setRequestHeader(
+ header.name,
+ header.value,
+ false /* merge */
+ );
+ }
+ }
+ const causeType = httpChannel.loadInfo
+ ? httpChannel.loadInfo.externalContentPolicyType
+ : Ci.nsIContentPolicy.TYPE_OTHER;
+
+ const suspendedChannels = this._browserSuspendedChannels.get(browser);
+ if (suspendedChannels) {
+ httpChannel.suspend();
+ suspendedChannels.set(requestId(httpChannel), httpChannel);
+ }
+
+ const oldChannel = this._redirectMap.get(httpChannel);
+ this._redirectMap.delete(httpChannel);
+
+ // Install response body hooks.
+ new ResponseBodyListener(this, browser, httpChannel);
+
+ this.emit("request", httpChannel, {
+ url: httpChannel.URI.spec,
+ suspended: suspendedChannels ? true : undefined,
+ requestId: requestId(httpChannel),
+ redirectedFrom: oldChannel ? requestId(oldChannel) : undefined,
+ postData: readRequestPostData(httpChannel),
+ headers: requestHeaders(httpChannel),
+ method: httpChannel.requestMethod,
+ isNavigationRequest: httpChannel.isMainDocumentChannel,
+ cause: causeType,
+ causeString: causeTypeToString(causeType),
+ frameId: this.frameId(httpChannel),
+ // clients expect loaderId == requestId for document navigation
+ loaderId: [
+ Ci.nsIContentPolicy.TYPE_DOCUMENT,
+ Ci.nsIContentPolicy.TYPE_SUBDOCUMENT,
+ ].includes(causeType)
+ ? requestId(httpChannel)
+ : undefined,
+ });
+ }
+
+ _onResponse(fromCache, httpChannel, topic) {
+ const loadContext = getLoadContext(httpChannel);
+ if (
+ !loadContext ||
+ !this._browserSessionCount.has(loadContext.topFrameElement)
+ ) {
+ return;
+ }
+ httpChannel.QueryInterface(Ci.nsIHttpChannelInternal);
+ const causeType = httpChannel.loadInfo
+ ? httpChannel.loadInfo.externalContentPolicyType
+ : Ci.nsIContentPolicy.TYPE_OTHER;
+ let remoteIPAddress;
+ let remotePort;
+ try {
+ remoteIPAddress = httpChannel.remoteAddress;
+ remotePort = httpChannel.remotePort;
+ } catch (e) {
+ // remoteAddress is not defined for cached requests.
+ }
+
+ this.emit("response", httpChannel, {
+ requestId: requestId(httpChannel),
+ securityDetails: getSecurityDetails(httpChannel),
+ fromCache,
+ headers: responseHeaders(httpChannel),
+ requestHeaders: requestHeaders(httpChannel),
+ remoteIPAddress,
+ remotePort,
+ status: httpChannel.responseStatus,
+ statusText: httpChannel.responseStatusText,
+ cause: causeType,
+ causeString: causeTypeToString(causeType),
+ frameId: this.frameId(httpChannel),
+ // clients expect loaderId == requestId for document navigation
+ loaderId: [
+ Ci.nsIContentPolicy.TYPE_DOCUMENT,
+ Ci.nsIContentPolicy.TYPE_SUBDOCUMENT,
+ ].includes(causeType)
+ ? requestId(httpChannel)
+ : undefined,
+ });
+ }
+
+ _onResponseFinished(browser, httpChannel, body) {
+ const responseStorage = this._browserResponseStorages.get(browser);
+ if (!responseStorage) {
+ return;
+ }
+ responseStorage.addResponseBody(httpChannel, body);
+ this.emit("requestfinished", httpChannel, {
+ requestId: requestId(httpChannel),
+ errorCode: getNetworkErrorStatusText(httpChannel.status),
+ });
+ }
+
+ isActive(browser) {
+ return !!this._browserSessionCount.get(browser);
+ }
+
+ startTrackingBrowserNetwork(browser) {
+ const value = this._browserSessionCount.get(browser) || 0;
+ this._browserSessionCount.set(browser, value + 1);
+ if (value === 0) {
+ Services.obs.addObserver(this._onRequest, "http-on-modify-request");
+ Services.obs.addObserver(
+ this._onExamineResponse,
+ "http-on-examine-response"
+ );
+ Services.obs.addObserver(
+ this._onCachedResponse,
+ "http-on-examine-cached-response"
+ );
+ Services.obs.addObserver(
+ this._onCachedResponse,
+ "http-on-examine-merged-response"
+ );
+ this._browserResponseStorages.set(
+ browser,
+ new ResponseStorage(
+ MAX_RESPONSE_STORAGE_SIZE,
+ MAX_RESPONSE_STORAGE_SIZE / 10
+ )
+ );
+ }
+ return () => this.stopTrackingBrowserNetwork(browser);
+ }
+
+ stopTrackingBrowserNetwork(browser) {
+ const value = this._browserSessionCount.get(browser);
+ if (value) {
+ this._browserSessionCount.set(browser, value - 1);
+ } else {
+ this._browserSessionCount.delete(browser);
+ this._browserResponseStorages.delete(browser);
+ this.dispose();
+ }
+ }
+
+ /**
+ * Returns the frameId of the current httpChannel.
+ */
+ frameId(httpChannel) {
+ const loadInfo = httpChannel.loadInfo;
+ return loadInfo.frameBrowsingContext?.id || loadInfo.browsingContext.id;
+ }
+}
+
+const protocolVersionNames = {
+ [Ci.nsITransportSecurityInfo.TLS_VERSION_1]: "TLS 1",
+ [Ci.nsITransportSecurityInfo.TLS_VERSION_1_1]: "TLS 1.1",
+ [Ci.nsITransportSecurityInfo.TLS_VERSION_1_2]: "TLS 1.2",
+ [Ci.nsITransportSecurityInfo.TLS_VERSION_1_3]: "TLS 1.3",
+};
+
+function getSecurityDetails(httpChannel) {
+ const securityInfo = httpChannel.securityInfo;
+ if (!securityInfo) {
+ return null;
+ }
+ if (!securityInfo.serverCert) {
+ return null;
+ }
+ return {
+ protocol: protocolVersionNames[securityInfo.protocolVersion] || "<unknown>",
+ subjectName: securityInfo.serverCert.commonName,
+ issuer: securityInfo.serverCert.issuerCommonName,
+ // Convert to seconds.
+ validFrom: securityInfo.serverCert.validity.notBefore / 1000 / 1000,
+ validTo: securityInfo.serverCert.validity.notAfter / 1000 / 1000,
+ };
+}
+
+function readRequestPostData(httpChannel) {
+ if (!(httpChannel instanceof Ci.nsIUploadChannel)) {
+ return undefined;
+ }
+ const iStream = httpChannel.uploadStream;
+ if (!iStream) {
+ return undefined;
+ }
+ const isSeekableStream = iStream instanceof Ci.nsISeekableStream;
+
+ let prevOffset;
+ if (isSeekableStream) {
+ prevOffset = iStream.tell();
+ iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0);
+ }
+
+ // Read data from the stream.
+ let text;
+ try {
+ text = lazy.NetUtil.readInputStreamToString(iStream, iStream.available());
+ const converter = Cc[
+ "@mozilla.org/intl/scriptableunicodeconverter"
+ ].createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = "UTF-8";
+ text = converter.ConvertToUnicode(text);
+ } catch (err) {
+ text = undefined;
+ }
+
+ // 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;
+}
+
+function getLoadContext(httpChannel) {
+ let loadContext = null;
+ try {
+ if (httpChannel.notificationCallbacks) {
+ loadContext = httpChannel.notificationCallbacks.getInterface(
+ Ci.nsILoadContext
+ );
+ }
+ } catch (e) {}
+ try {
+ if (!loadContext && httpChannel.loadGroup) {
+ loadContext = httpChannel.loadGroup.notificationCallbacks.getInterface(
+ Ci.nsILoadContext
+ );
+ }
+ } catch (e) {}
+ return loadContext;
+}
+
+function requestId(httpChannel) {
+ return String(httpChannel.channelId);
+}
+
+function requestHeaders(httpChannel) {
+ const headers = [];
+ httpChannel.visitRequestHeaders({
+ visitHeader: (name, value) => headers.push({ name, value }),
+ });
+ return headers;
+}
+
+function responseHeaders(httpChannel) {
+ const headers = [];
+ httpChannel.visitResponseHeaders({
+ visitHeader: (name, value) => headers.push({ name, value }),
+ });
+ return headers;
+}
+
+function causeTypeToString(causeType) {
+ for (let key in Ci.nsIContentPolicy) {
+ if (Ci.nsIContentPolicy[key] === causeType) {
+ return key;
+ }
+ }
+ return "TYPE_OTHER";
+}
+
+class ResponseStorage {
+ constructor(maxTotalSize, maxResponseSize) {
+ this._totalSize = 0;
+ this._maxResponseSize = maxResponseSize;
+ this._maxTotalSize = maxTotalSize;
+ this._responses = new Map();
+ }
+
+ addResponseBody(httpChannel, body) {
+ if (body.length > this._maxResponseSize) {
+ this._responses.set(requestId, {
+ evicted: true,
+ body: "",
+ });
+ return;
+ }
+ let encodings = [];
+ if (
+ httpChannel instanceof Ci.nsIEncodedChannel &&
+ httpChannel.contentEncodings &&
+ !httpChannel.applyConversion
+ ) {
+ const encodingHeader = httpChannel.getResponseHeader("Content-Encoding");
+ encodings = encodingHeader.split(/\s*\t*,\s*\t*/);
+ }
+ this._responses.set(requestId(httpChannel), { body, encodings });
+ this._totalSize += body.length;
+ if (this._totalSize > this._maxTotalSize) {
+ for (let [, response] of this._responses) {
+ this._totalSize -= response.body.length;
+ response.body = "";
+ response.evicted = true;
+ if (this._totalSize < this._maxTotalSize) {
+ break;
+ }
+ }
+ }
+ }
+
+ getBase64EncodedResponse(requestId) {
+ const response = this._responses.get(requestId);
+ if (!response) {
+ throw new Error(`Request "${requestId}" is not found`);
+ }
+ if (response.evicted) {
+ return { base64body: "", evicted: true };
+ }
+ let result = response.body;
+ if (response.encodings && response.encodings.length) {
+ for (const encoding of response.encodings) {
+ result = lazy.CommonUtils.convertString(
+ result,
+ encoding,
+ "uncompressed"
+ );
+ }
+ }
+ return { base64body: btoa(result) };
+ }
+}
+
+class ResponseBodyListener {
+ constructor(networkObserver, browser, httpChannel) {
+ this._networkObserver = networkObserver;
+ this._browser = browser;
+ this._httpChannel = httpChannel;
+ this._chunks = [];
+ this.QueryInterface = ChromeUtils.generateQI(["nsIStreamListener"]);
+ httpChannel.QueryInterface(Ci.nsITraceableChannel);
+ this.originalListener = httpChannel.setNewListener(this);
+ }
+
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ const iStream = new lazy.BinaryInputStream(aInputStream);
+ const sStream = new lazy.StorageStream(8192, aCount, null);
+ const oStream = new lazy.BinaryOutputStream(sStream.getOutputStream(0));
+
+ // Copy received data as they come.
+ const data = iStream.readBytes(aCount);
+ this._chunks.push(data);
+
+ oStream.writeBytes(data, aCount);
+ this.originalListener.onDataAvailable(
+ aRequest,
+ sStream.newInputStream(0),
+ aOffset,
+ aCount
+ );
+ }
+
+ onStartRequest(aRequest) {
+ this.originalListener.onStartRequest(aRequest);
+ }
+
+ onStopRequest(aRequest, aStatusCode) {
+ this.originalListener.onStopRequest(aRequest, aStatusCode);
+ const body = this._chunks.join("");
+ delete this._chunks;
+ this._networkObserver._onResponseFinished(
+ this._browser,
+ this._httpChannel,
+ body
+ );
+ }
+}
+
+function getNetworkErrorStatusText(status) {
+ if (!status) {
+ return null;
+ }
+ for (const key of Object.keys(Cr)) {
+ if (Cr[key] === status) {
+ return key;
+ }
+ }
+ // Security module. The following is taken from
+ // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/How_to_check_the_secruity_state_of_an_XMLHTTPRequest_over_SSL
+ if ((status & 0xff0000) === 0x5a0000) {
+ // NSS_SEC errors (happen below the base value because of negative vals)
+ if (
+ (status & 0xffff) <
+ Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE)
+ ) {
+ // The bases are actually negative, so in our positive numeric space, we
+ // need to subtract the base off our value.
+ const nssErr =
+ Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (status & 0xffff);
+ switch (nssErr) {
+ case 11:
+ return "SEC_ERROR_EXPIRED_CERTIFICATE";
+ case 12:
+ return "SEC_ERROR_REVOKED_CERTIFICATE";
+ case 13:
+ return "SEC_ERROR_UNKNOWN_ISSUER";
+ case 20:
+ return "SEC_ERROR_UNTRUSTED_ISSUER";
+ case 21:
+ return "SEC_ERROR_UNTRUSTED_CERT";
+ case 36:
+ return "SEC_ERROR_CA_CERT_INVALID";
+ case 90:
+ return "SEC_ERROR_INADEQUATE_KEY_USAGE";
+ case 176:
+ return "SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED";
+ default:
+ return "SEC_ERROR_UNKNOWN";
+ }
+ }
+ const sslErr =
+ Math.abs(Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (status & 0xffff);
+ switch (sslErr) {
+ case 3:
+ return "SSL_ERROR_NO_CERTIFICATE";
+ case 4:
+ return "SSL_ERROR_BAD_CERTIFICATE";
+ case 8:
+ return "SSL_ERROR_UNSUPPORTED_CERTIFICATE_TYPE";
+ case 9:
+ return "SSL_ERROR_UNSUPPORTED_VERSION";
+ case 12:
+ return "SSL_ERROR_BAD_CERT_DOMAIN";
+ default:
+ return "SSL_ERROR_UNKNOWN";
+ }
+ }
+ return "<unknown error>";
+}