Source code

Revision control

Copy as Markdown

Other Tools

/* 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, {
ChannelEventSinkFactory:
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"gActivityDistributor",
"@mozilla.org/network/http-activity-distributor;1",
"nsIHttpActivityDistributor"
);
const CC = Components.Constructor;
ChromeUtils.defineLazyGetter(lazy, "BinaryInputStream", () => {
return CC(
"@mozilla.org/binaryinputstream;1",
"nsIBinaryInputStream",
"setInputStream"
);
});
ChromeUtils.defineLazyGetter(lazy, "BinaryOutputStream", () => {
return CC(
"@mozilla.org/binaryoutputstream;1",
"nsIBinaryOutputStream",
"setOutputStream"
);
});
ChromeUtils.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) {
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) {
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
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>";
}