fune/toolkit/components/extensions/webrequest/WebRequest.jsm
Luca Greco 5549205044 Bug 1785821 - Only allow manifest_version 3 extensions to merge CSP in the short run. r=mixedpuppy
This patch includes:

- changes to WebRequest.jsm to always default to only merge the CSP headers returned by MV3 extensions

- changes to the test_ext_webRequest_mergecsp.js xpcshell test to cover the behavior expected
  with MV3 extensions and combinations of both MV2 and MV3 extensions changing CSP headers
  for the same intercepted web request.

For MV3 extensions we would prefer a more explicit and predictable way for the
extensions to be allowed to replace the CSP header, instead of keeping the same
unpredictable and implicit one that we currently support for MV2 extensions.

Differential Revision: https://phabricator.services.mozilla.com/D154983
2022-08-25 19:13:50 +00:00

1284 lines
39 KiB
JavaScript

/* 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/. */
"use strict";
const EXPORTED_SYMBOLS = ["WebRequest"];
/* exported WebRequest */
/* globals ChannelWrapper */
const { nsIHttpActivityObserver, nsISocketTransport } = Ci;
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const lazy = {};
XPCOMUtils.defineLazyModuleGetters(lazy, {
ExtensionParent: "resource://gre/modules/ExtensionParent.jsm",
ExtensionUtils: "resource://gre/modules/ExtensionUtils.jsm",
WebRequestUpload: "resource://gre/modules/WebRequestUpload.jsm",
SecurityInfo: "resource://gre/modules/SecurityInfo.jsm",
});
// WebRequest.jsm's only consumer is ext-webRequest.js, so we can depend on
// the apiManager.global being initialized.
XPCOMUtils.defineLazyGetter(lazy, "tabTracker", () => {
return lazy.ExtensionParent.apiManager.global.tabTracker;
});
XPCOMUtils.defineLazyGetter(lazy, "getCookieStoreIdForOriginAttributes", () => {
return lazy.ExtensionParent.apiManager.global
.getCookieStoreIdForOriginAttributes;
});
// URI schemes that service workers are allowed to load scripts from (any other
// scheme is not allowed by the specs and it is not expected by the service workers
// internals neither, which would likely trigger unexpected behaviors).
const ALLOWED_SERVICEWORKER_SCHEMES = ["https", "http", "moz-extension"];
// Classes of requests that should be sent immediately instead of batched.
// Covers basically anything that can delay first paint or DOMContentLoaded:
// top frame HTML, <head> blocking CSS, fonts preflight, sync JS and XHR.
const URGENT_CLASSES =
Ci.nsIClassOfService.Leader |
Ci.nsIClassOfService.Unblocked |
Ci.nsIClassOfService.UrgentStart |
Ci.nsIClassOfService.TailForbidden;
function runLater(job) {
Services.tm.dispatchToMainThread(job);
}
function parseFilter(filter) {
if (!filter) {
filter = {};
}
return {
urls: filter.urls || null,
types: filter.types || null,
tabId: filter.tabId ?? null,
windowId: filter.windowId ?? null,
incognito: filter.incognito ?? null,
};
}
function parseExtra(extra, allowed = [], optionsObj = {}) {
if (extra) {
for (let ex of extra) {
if (!allowed.includes(ex)) {
throw new lazy.ExtensionUtils.ExtensionError(`Invalid option ${ex}`);
}
}
}
let result = Object.assign({}, optionsObj);
for (let al of allowed) {
if (extra && extra.includes(al)) {
result[al] = true;
}
}
return result;
}
function isThenable(value) {
return value && typeof value === "object" && typeof value.then === "function";
}
// Verify a requested redirect and throw a more explicit error.
function verifyRedirect(channel, redirectUri, finalUrl, addonId) {
const { isServiceWorkerScript } = channel;
if (
isServiceWorkerScript &&
channel.loadInfo?.internalContentPolicyType ===
Ci.nsIContentPolicy.TYPE_INTERNAL_SERVICE_WORKER
) {
throw new Error(
`Invalid redirectUrl ${redirectUri?.spec} on service worker main script ${finalUrl} requested by ${addonId}`
);
}
if (
isServiceWorkerScript &&
channel.loadInfo?.internalContentPolicyType ===
Ci.nsIContentPolicy.TYPE_INTERNAL_WORKER_IMPORT_SCRIPTS &&
!ALLOWED_SERVICEWORKER_SCHEMES.includes(redirectUri?.scheme)
) {
throw new Error(
`Invalid redirectUrl ${redirectUri?.spec} on service worker imported script ${finalUrl} requested by ${addonId}`
);
}
}
class HeaderChanger {
constructor(channel) {
this.channel = channel;
this.array = this.readHeaders();
}
getMap() {
if (!this.map) {
this.map = new Map();
for (let header of this.array) {
this.map.set(header.name.toLowerCase(), header);
}
}
return this.map;
}
toArray() {
return this.array;
}
validateHeaders(headers) {
// We should probably use schema validation for this.
if (!Array.isArray(headers)) {
return false;
}
return headers.every(header => {
if (typeof header !== "object" || header === null) {
return false;
}
if (typeof header.name !== "string") {
return false;
}
return (
typeof header.value === "string" || Array.isArray(header.binaryValue)
);
});
}
applyChanges(headers, opts = {}) {
if (!this.validateHeaders(headers)) {
/* globals uneval */
Cu.reportError(`Invalid header array: ${uneval(headers)}`);
return;
}
let newHeaders = new Set(headers.map(({ name }) => name.toLowerCase()));
// Remove missing headers.
let origHeaders = this.getMap();
for (let name of origHeaders.keys()) {
if (!newHeaders.has(name)) {
this.setHeader(name, "", false, opts, name);
}
}
// Set new or changed headers. If there are multiple headers with the same
// name (e.g. Set-Cookie), merge them, instead of having new values
// overwrite previous ones.
//
// When the new value of a header is equal the existing value of the header
// (e.g. the initial response set "Set-Cookie: examplename=examplevalue",
// and an extension also added the header
// "Set-Cookie: examplename=examplevalue") then the header value is not
// re-set, but subsequent headers of the same type will be merged in.
//
// Multiple addons will be able to provide modifications to any headers
// listed in the default set.
let headersAlreadySet = new Set();
for (let { name, value, binaryValue } of headers) {
if (binaryValue) {
value = String.fromCharCode(...binaryValue);
}
let lowerCaseName = name.toLowerCase();
let original = origHeaders.get(lowerCaseName);
if (!original || value !== original.value) {
let shouldMerge = headersAlreadySet.has(lowerCaseName);
this.setHeader(name, value, shouldMerge, opts, lowerCaseName);
}
headersAlreadySet.add(lowerCaseName);
}
}
}
const checkRestrictedHeaderValue = (value, opts = {}) => {
let uri = Services.io.newURI(`https://${value}/`);
let { policy } = opts;
if (policy && !policy.allowedOrigins.matches(uri)) {
throw new Error(`Unable to set host header, url missing from permissions.`);
}
if (WebExtensionPolicy.isRestrictedURI(uri)) {
throw new Error(`Unable to set host header to restricted url.`);
}
};
class RequestHeaderChanger extends HeaderChanger {
setHeader(name, value, merge, opts, lowerCaseName) {
try {
if (value && lowerCaseName === "host") {
checkRestrictedHeaderValue(value, opts);
}
this.channel.setRequestHeader(name, value, merge);
} catch (e) {
Cu.reportError(new Error(`Error setting request header ${name}: ${e}`));
}
}
readHeaders() {
return this.channel.getRequestHeaders();
}
}
class ResponseHeaderChanger extends HeaderChanger {
didModifyCSP = false;
setHeader(name, value, merge, opts, lowerCaseName) {
if (lowerCaseName === "content-security-policy") {
// When multiple add-ons change the CSP, enforce the combined (strictest)
// policy - see bug 1462989 for motivation.
// When value is unset, don't force the header to be merged, to allow
// add-ons to clear the header if wanted.
if (value) {
merge = merge || this.didModifyCSP;
}
// For manifest_version 3 extension, we are currently only allowing to
// merge additional CSP strings to the existing ones, which will be initially
// stricter than currently allowed to manifest_version 2 extensions, then
// following up with either a new permission and/or some more changes to the
// APIs (and possibly making the behavior more deterministic than it is for
// manifest_version 2 at the moment).
if (opts.policy.manifestVersion > 2) {
if (value) {
// If the given CSP header value is non empty, then it should be
// merged to the existing one.
merge = true;
} else {
// If the given CSP header value is empty (which would be clearing the
// CSP header), it should be considered a no-op and this.didModifyCSP
// shouldn't be changed to true.
return;
}
}
this.didModifyCSP = true;
}
try {
this.channel.setResponseHeader(name, value, merge);
} catch (e) {
Cu.reportError(new Error(`Error setting response header ${name}: ${e}`));
}
}
readHeaders() {
return this.channel.getResponseHeaders();
}
}
const MAYBE_CACHED_EVENTS = new Set([
"onResponseStarted",
"onHeadersReceived",
"onBeforeRedirect",
"onCompleted",
"onErrorOccurred",
]);
const OPTIONAL_PROPERTIES = [
"requestHeaders",
"responseHeaders",
"statusCode",
"statusLine",
"error",
"redirectUrl",
"requestBody",
"scheme",
"realm",
"isProxy",
"challenger",
"proxyInfo",
"ip",
"frameAncestors",
"urlClassification",
"requestSize",
"responseSize",
];
function serializeRequestData(eventName) {
let data = {
requestId: this.requestId,
url: this.url,
originUrl: this.originUrl,
documentUrl: this.documentUrl,
method: this.method,
type: this.type,
timeStamp: Date.now(),
tabId: this.tabId,
frameId: this.frameId,
parentFrameId: this.parentFrameId,
incognito: this.incognito,
thirdParty: this.thirdParty,
cookieStoreId: this.cookieStoreId,
urgentSend: this.urgentSend,
};
if (MAYBE_CACHED_EVENTS.has(eventName)) {
data.fromCache = !!this.fromCache;
}
for (let opt of OPTIONAL_PROPERTIES) {
if (typeof this[opt] !== "undefined") {
data[opt] = this[opt];
}
}
if (this.urlClassification) {
data.urlClassification = {
firstParty: this.urlClassification.firstParty.filter(
c => !c.startsWith("socialtracking_")
),
thirdParty: this.urlClassification.thirdParty.filter(
c => !c.startsWith("socialtracking_")
),
};
}
return data;
}
var HttpObserverManager;
var ChannelEventSink = {
_classDescription: "WebRequest channel event sink",
_classID: Components.ID("115062f8-92f1-11e5-8b7f-080027b0f7ec"),
_contractID: "@mozilla.org/webrequest/channel-event-sink;1",
QueryInterface: ChromeUtils.generateQI(["nsIChannelEventSink", "nsIFactory"]),
init() {
Components.manager
.QueryInterface(Ci.nsIComponentRegistrar)
.registerFactory(
this._classID,
this._classDescription,
this._contractID,
this
);
},
register() {
Services.catMan.addCategoryEntry(
"net-channel-event-sinks",
this._contractID,
this._contractID,
false,
true
);
},
unregister() {
Services.catMan.deleteCategoryEntry(
"net-channel-event-sinks",
this._contractID,
false
);
},
// nsIChannelEventSink implementation
asyncOnChannelRedirect(oldChannel, newChannel, flags, redirectCallback) {
runLater(() => redirectCallback.onRedirectVerifyCallback(Cr.NS_OK));
try {
HttpObserverManager.onChannelReplaced(oldChannel, newChannel);
} catch (e) {
// we don't wanna throw: it would abort the redirection
}
},
// nsIFactory implementation
createInstance(iid) {
return this.QueryInterface(iid);
},
};
ChannelEventSink.init();
// nsIAuthPrompt2 implementation for onAuthRequired
class AuthRequestor {
constructor(channel, httpObserver) {
this.notificationCallbacks = channel.notificationCallbacks;
this.loadGroupCallbacks =
channel.loadGroup && channel.loadGroup.notificationCallbacks;
this.httpObserver = httpObserver;
}
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);
}
_getForwardedInterface(iid) {
try {
return this.notificationCallbacks.getInterface(iid);
} catch (e) {
return this.loadGroupCallbacks.getInterface(iid);
}
}
// nsIAuthPromptProvider getAuthPrompt
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
);
}
// nsIAuthPrompt2 promptAuth
promptAuth(channel, level, authInfo) {
this._getForwardedInterface(Ci.nsIAuthPrompt2).promptAuth(
channel,
level,
authInfo
);
}
_getForwardPrompt(data) {
let reason = data.isProxy
? Ci.nsIAuthPromptProvider.PROMPT_PROXY
: Ci.nsIAuthPromptProvider.PROMPT_NORMAL;
for (let 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);
}
// nsIAuthPrompt2 asyncPromptAuth
asyncPromptAuth(channel, callback, context, level, authInfo) {
let wrapper = ChannelWrapper.get(channel);
let uri = channel.URI;
let proxyInfo;
let isProxy = !!(authInfo.flags & authInfo.AUTH_PROXY);
if (isProxy && channel instanceof Ci.nsIProxiedChannel) {
proxyInfo = channel.proxyInfo;
}
let data = {
scheme: authInfo.authenticationScheme,
realm: authInfo.realm,
isProxy,
challenger: {
host: proxyInfo ? proxyInfo.host : uri.host,
port: proxyInfo ? proxyInfo.port : uri.port,
},
};
// In the case that no listener provides credentials, we fallback to the
// previously set callback class for authentication.
wrapper.authPromptForward = () => {
try {
let prompt = this._getForwardPrompt(data);
prompt.asyncPromptAuth(channel, callback, context, level, authInfo);
} catch (e) {
Cu.reportError(`webRequest asyncPromptAuth failure ${e}`);
callback.onAuthCancelled(context, false);
}
wrapper.authPromptForward = null;
wrapper.authPromptCallback = null;
};
wrapper.authPromptCallback = authCredentials => {
// The API allows for canceling the request, providing credentials or
// doing nothing, so we do not provide a way to call onAuthCanceled.
// Canceling the request will result in canceling the authentication.
if (
authCredentials &&
typeof authCredentials.username === "string" &&
typeof authCredentials.password === "string"
) {
authInfo.username = authCredentials.username;
authInfo.password = authCredentials.password;
try {
callback.onAuthAvailable(context, authInfo);
} catch (e) {
Cu.reportError(`webRequest onAuthAvailable failure ${e}`);
}
// At least one addon has responded, so we won't forward to the regular
// prompt handlers.
wrapper.authPromptForward = null;
wrapper.authPromptCallback = null;
}
};
this.httpObserver.runChannelListener(wrapper, "onAuthRequired", data);
return {
QueryInterface: ChromeUtils.generateQI(["nsICancelable"]),
cancel() {
try {
callback.onAuthCancelled(context, false);
} catch (e) {
Cu.reportError(`webRequest onAuthCancelled failure ${e}`);
}
wrapper.authPromptForward = null;
wrapper.authPromptCallback = null;
},
};
}
}
AuthRequestor.prototype.QueryInterface = ChromeUtils.generateQI([
"nsIInterfaceRequestor",
"nsIAuthPromptProvider",
"nsIAuthPrompt2",
]);
// Most WebRequest events are implemented via the observer services, but
// a few use custom xpcom interfaces. This class (HttpObserverManager)
// serves two main purposes:
// 1. It abstracts away the names and details of the underlying
// implementation (e.g., onBeforeBeforeRequest is dispatched from
// the http-on-modify-request observable).
// 2. It aggregates multiple listeners so that a single observer or
// handler can serve multiple webRequest listeners.
HttpObserverManager = {
listeners: {
// onBeforeRequest uses http-on-modify observer for HTTP(S).
onBeforeRequest: new Map(),
// onBeforeSendHeaders and onSendHeaders correspond to the
// http-on-before-connect observer.
onBeforeSendHeaders: new Map(),
onSendHeaders: new Map(),
// onHeadersReceived corresponds to the http-on-examine-* obserservers.
onHeadersReceived: new Map(),
// onAuthRequired is handled via the nsIAuthPrompt2 xpcom interface
// which is managed here by AuthRequestor.
onAuthRequired: new Map(),
// onBeforeRedirect is handled by the nsIChannelEVentSink xpcom interface
// which is managed here by ChannelEventSink.
onBeforeRedirect: new Map(),
// onResponseStarted, onErrorOccurred, and OnCompleted correspond
// to events dispatched by the ChannelWrapper EventTarget.
onResponseStarted: new Map(),
onErrorOccurred: new Map(),
onCompleted: new Map(),
},
openingInitialized: false,
beforeConnectInitialized: false,
examineInitialized: false,
redirectInitialized: false,
activityInitialized: false,
needTracing: false,
hasRedirects: false,
getWrapper(nativeChannel) {
let wrapper = ChannelWrapper.get(nativeChannel);
if (!wrapper._addedListeners) {
/* eslint-disable mozilla/balanced-listeners */
if (this.listeners.onErrorOccurred.size) {
wrapper.addEventListener("error", this);
}
if (this.listeners.onResponseStarted.size) {
wrapper.addEventListener("start", this);
}
if (this.listeners.onCompleted.size) {
wrapper.addEventListener("stop", this);
}
/* eslint-enable mozilla/balanced-listeners */
wrapper._addedListeners = true;
}
return wrapper;
},
get activityDistributor() {
return Cc["@mozilla.org/network/http-activity-distributor;1"].getService(
Ci.nsIHttpActivityDistributor
);
},
// This method is called whenever webRequest listeners are added or removed.
// It reconciles the set of listeners with underlying observers, event
// handlers, etc. by adding new low-level handlers for any newly added
// webRequest listeners and removing those that are no longer needed if
// there are no more listeners for corresponding webRequest events.
addOrRemove() {
let needOpening = this.listeners.onBeforeRequest.size;
let needBeforeConnect =
this.listeners.onBeforeSendHeaders.size ||
this.listeners.onSendHeaders.size;
if (needOpening && !this.openingInitialized) {
this.openingInitialized = true;
Services.obs.addObserver(this, "http-on-modify-request");
} else if (!needOpening && this.openingInitialized) {
this.openingInitialized = false;
Services.obs.removeObserver(this, "http-on-modify-request");
}
if (needBeforeConnect && !this.beforeConnectInitialized) {
this.beforeConnectInitialized = true;
Services.obs.addObserver(this, "http-on-before-connect");
} else if (!needBeforeConnect && this.beforeConnectInitialized) {
this.beforeConnectInitialized = false;
Services.obs.removeObserver(this, "http-on-before-connect");
}
let haveBlocking = Object.values(this.listeners).some(listeners =>
Array.from(listeners.values()).some(listener => listener.blockingAllowed)
);
this.needTracing =
this.listeners.onResponseStarted.size ||
this.listeners.onErrorOccurred.size ||
this.listeners.onCompleted.size ||
haveBlocking;
let needExamine =
this.needTracing ||
this.listeners.onHeadersReceived.size ||
this.listeners.onAuthRequired.size;
if (needExamine && !this.examineInitialized) {
this.examineInitialized = true;
Services.obs.addObserver(this, "http-on-examine-response");
Services.obs.addObserver(this, "http-on-examine-cached-response");
Services.obs.addObserver(this, "http-on-examine-merged-response");
} else if (!needExamine && this.examineInitialized) {
this.examineInitialized = false;
Services.obs.removeObserver(this, "http-on-examine-response");
Services.obs.removeObserver(this, "http-on-examine-cached-response");
Services.obs.removeObserver(this, "http-on-examine-merged-response");
}
// If we have any listeners, we need the channelsink so the channelwrapper is
// updated properly. Otherwise events for channels that are redirected will not
// happen correctly. If we have no listeners, shut it down.
this.hasRedirects = this.listeners.onBeforeRedirect.size > 0;
let needRedirect =
this.hasRedirects || needExamine || needOpening || needBeforeConnect;
if (needRedirect && !this.redirectInitialized) {
this.redirectInitialized = true;
ChannelEventSink.register();
} else if (!needRedirect && this.redirectInitialized) {
this.redirectInitialized = false;
ChannelEventSink.unregister();
}
let needActivity = this.listeners.onErrorOccurred.size;
if (needActivity && !this.activityInitialized) {
this.activityInitialized = true;
this.activityDistributor.addObserver(this);
} else if (!needActivity && this.activityInitialized) {
this.activityInitialized = false;
this.activityDistributor.removeObserver(this);
}
},
addListener(kind, callback, opts) {
this.listeners[kind].set(callback, opts);
this.addOrRemove();
},
removeListener(kind, callback) {
this.listeners[kind].delete(callback);
this.addOrRemove();
},
observe(subject, topic, data) {
let channel = this.getWrapper(subject);
switch (topic) {
case "http-on-modify-request":
this.runChannelListener(channel, "onBeforeRequest");
break;
case "http-on-before-connect":
this.runChannelListener(channel, "onBeforeSendHeaders");
break;
case "http-on-examine-cached-response":
case "http-on-examine-merged-response":
channel.fromCache = true;
// falls through
case "http-on-examine-response":
this.examine(channel, topic, data);
break;
}
},
// We map activity values with tentative error names, e.g. "STATUS_RESOLVING" => "NS_ERROR_NET_ON_RESOLVING".
get activityErrorsMap() {
let prefix = /^(?:ACTIVITY_SUBTYPE_|STATUS_)/;
let map = new Map();
for (let iface of [nsIHttpActivityObserver, nsISocketTransport]) {
for (let c of Object.keys(iface).filter(name => prefix.test(name))) {
map.set(iface[c], c.replace(prefix, "NS_ERROR_NET_ON_"));
}
}
delete this.activityErrorsMap;
this.activityErrorsMap = map;
return this.activityErrorsMap;
},
GOOD_LAST_ACTIVITY: nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_HEADER,
observeActivity(
nativeChannel,
activityType,
activitySubtype /* , aTimestamp, aExtraSizeData, aExtraStringData */
) {
// Sometimes we get a NullHttpChannel, which implements
// nsIHttpChannel but not nsIChannel.
if (!(nativeChannel instanceof Ci.nsIChannel)) {
return;
}
let channel = this.getWrapper(nativeChannel);
let lastActivity = channel.lastActivity || 0;
if (
activitySubtype ===
nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE &&
lastActivity &&
lastActivity !== this.GOOD_LAST_ACTIVITY
) {
// Since the channel's security info is assigned in onStartRequest and
// errorCheck is called in ChannelWrapper::onStartRequest, we should check
// the errorString after onStartRequest to make sure errors have a chance
// to be processed before we fall back to a generic error string.
channel.addEventListener(
"start",
() => {
if (!channel.errorString) {
this.runChannelListener(channel, "onErrorOccurred", {
error:
this.activityErrorsMap.get(lastActivity) ||
`NS_ERROR_NET_UNKNOWN_${lastActivity}`,
});
}
},
{ once: true }
);
} else if (
lastActivity !== this.GOOD_LAST_ACTIVITY &&
lastActivity !==
nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE
) {
channel.lastActivity = activitySubtype;
}
},
getRequestData(channel, extraData) {
let originAttributes = channel.loadInfo?.originAttributes;
let cos = channel.channel.QueryInterface(Ci.nsIClassOfService);
let data = {
requestId: String(channel.id),
url: channel.finalURL,
method: channel.method,
type: channel.type,
fromCache: channel.fromCache,
incognito: originAttributes?.privateBrowsingId > 0,
thirdParty: channel.thirdParty,
originUrl: channel.originURL || undefined,
documentUrl: channel.documentURL || undefined,
tabId: this.getBrowserData(channel).tabId,
frameId: channel.frameId,
parentFrameId: channel.parentFrameId,
frameAncestors: channel.frameAncestors || undefined,
ip: channel.remoteAddress,
proxyInfo: channel.proxyInfo,
serialize: serializeRequestData,
requestSize: channel.requestSize,
responseSize: channel.responseSize,
urlClassification: channel.urlClassification,
// Figure out if this is an urgent request that shouldn't be batched.
urgentSend: (cos.classFlags & URGENT_CLASSES) > 0,
};
if (originAttributes) {
data.cookieStoreId = lazy.getCookieStoreIdForOriginAttributes(
originAttributes
);
}
return Object.assign(data, extraData);
},
handleEvent(event) {
let channel = event.currentTarget;
switch (event.type) {
case "error":
this.runChannelListener(channel, "onErrorOccurred", {
error: channel.errorString,
});
break;
case "start":
this.runChannelListener(channel, "onResponseStarted");
break;
case "stop":
this.runChannelListener(channel, "onCompleted");
break;
}
},
STATUS_TYPES: new Set([
"onHeadersReceived",
"onAuthRequired",
"onBeforeRedirect",
"onResponseStarted",
"onCompleted",
]),
FILTER_TYPES: new Set([
"onBeforeRequest",
"onBeforeSendHeaders",
"onSendHeaders",
"onHeadersReceived",
"onAuthRequired",
"onBeforeRedirect",
]),
getBrowserData(wrapper) {
let browserData = wrapper._browserData;
if (!browserData) {
if (wrapper.browserElement) {
browserData = lazy.tabTracker.getBrowserData(wrapper.browserElement);
} else {
browserData = { tabId: -1, windowId: -1 };
}
wrapper._browserData = browserData;
}
return browserData;
},
runChannelListener(channel, kind, extraData = null) {
let handlerResults = [];
let requestHeaders;
let responseHeaders;
try {
if (kind !== "onErrorOccurred" && channel.errorString) {
return;
}
let registerFilter = this.FILTER_TYPES.has(kind);
let commonData = null;
let requestBody;
this.listeners[kind].forEach((opts, callback) => {
if (opts.filter.tabId !== null || opts.filter.windowId !== null) {
const { tabId, windowId } = this.getBrowserData(channel);
if (
(opts.filter.tabId !== null && tabId != opts.filter.tabId) ||
(opts.filter.windowId !== null && windowId != opts.filter.windowId)
) {
return;
}
}
if (!channel.matches(opts.filter, opts.policy, extraData)) {
return;
}
let extension = opts.policy?.extension;
// TODO: Move this logic to ChannelWrapper::matches, see bug 1699481
if (
extension?.userContextIsolation &&
!extension.canAccessContainer(
channel.loadInfo?.originAttributes.userContextId
)
) {
return;
}
if (!commonData) {
commonData = this.getRequestData(channel, extraData);
if (this.STATUS_TYPES.has(kind)) {
commonData.statusCode = channel.statusCode;
commonData.statusLine = channel.statusLine;
}
}
let data = Object.create(commonData);
data.urgentSend = data.urgentSend && opts.blocking;
if (registerFilter && opts.blocking && opts.policy) {
data.registerTraceableChannel = (policy, remoteTab) => {
// `channel` is a ChannelWrapper, which contains the actual
// underlying nsIChannel in `channel.channel`. For startup events
// that are held until the extension background page is started,
// it is possible that the underlying channel can be closed and
// cleaned up between the time the event occurred and the time
// we reach this code.
if (channel.channel) {
channel.registerTraceableChannel(policy, remoteTab);
}
};
}
if (opts.requestHeaders) {
requestHeaders = requestHeaders || new RequestHeaderChanger(channel);
data.requestHeaders = requestHeaders.toArray();
}
if (opts.responseHeaders) {
try {
responseHeaders =
responseHeaders || new ResponseHeaderChanger(channel);
data.responseHeaders = responseHeaders.toArray();
} catch (e) {
/* headers may not be available on some redirects */
}
}
if (opts.requestBody && channel.canModify) {
requestBody =
requestBody ||
lazy.WebRequestUpload.createRequestBody(channel.channel);
data.requestBody = requestBody;
}
try {
let result = callback(data);
// isProxy is set during onAuth if the auth request is for a proxy.
// We allow handling proxy auth regardless of canModify.
if (
(channel.canModify || data.isProxy) &&
typeof result === "object" &&
opts.blocking
) {
handlerResults.push({ opts, result });
}
} catch (e) {
Cu.reportError(e);
}
});
} catch (e) {
Cu.reportError(e);
}
return this.applyChanges(
kind,
channel,
handlerResults,
requestHeaders,
responseHeaders
);
},
async applyChanges(
kind,
channel,
handlerResults,
requestHeaders,
responseHeaders
) {
const { finalURL, id: chanId } = channel;
let shouldResume = !channel.suspended;
// NOTE: if a request has been suspended before the GeckoProfiler
// has been activated and then resumed while the GeckoProfiler is active
// and collecting data, the resulting "Extension Suspend" marker will be
// recorded with an empty marker text (and so without url, chan id and
// the supenders addon ids).
let markerText = "";
if (Services.profiler?.IsActive()) {
const suspenders = handlerResults
.filter(({ result }) => isThenable(result))
.map(({ opts }) => opts.addonId)
.join(", ");
markerText = `${kind} ${finalURL} by ${suspenders} (chanId: ${chanId})`;
}
try {
for (let { opts, result } of handlerResults) {
if (isThenable(result)) {
channel.suspend(markerText);
try {
result = await result;
} catch (e) {
let error;
if (e instanceof Error) {
error = e;
} else if (typeof e === "object" && e.message) {
error = new Error(e.message, e.fileName, e.lineNumber);
}
Cu.reportError(error);
continue;
}
if (!result || typeof result !== "object") {
continue;
}
}
if (
kind === "onAuthRequired" &&
result.authCredentials &&
channel.authPromptCallback
) {
channel.authPromptCallback(result.authCredentials);
}
// We allow proxy auth to cancel or handle authCredentials regardless of
// canModify, but ensure we do nothing else.
if (!channel.canModify) {
continue;
}
if (result.cancel) {
channel.resume();
channel.cancel(
Cr.NS_ERROR_ABORT,
Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST
);
ChromeUtils.addProfilerMarker(
"Extension Canceled",
{ category: "Network" },
`${kind} ${finalURL} canceled by ${opts.addonId} (chanId: ${chanId})`
);
if (opts.policy) {
let properties = channel.channel.QueryInterface(
Ci.nsIWritablePropertyBag
);
properties.setProperty("cancelledByExtension", opts.policy.id);
}
return;
}
if (result.redirectUrl) {
try {
const { redirectUrl } = result;
channel.resume();
const redirectUri = Services.io.newURI(redirectUrl);
verifyRedirect(channel, redirectUri, finalURL, opts.addonId);
channel.redirectTo(redirectUri);
ChromeUtils.addProfilerMarker(
"Extension Redirected",
{ category: "Network" },
`${kind} ${finalURL} redirected to ${redirectUrl} by ${opts.addonId} (chanId: ${chanId})`
);
if (opts.policy) {
let properties = channel.channel.QueryInterface(
Ci.nsIWritablePropertyBag
);
properties.setProperty("redirectedByExtension", opts.policy.id);
}
// Web Extensions using the WebRequest API are allowed
// to redirect a channel to a data: URI, hence we mark
// the channel to let the redirect blocker know. Please
// note that this marking needs to happen after the
// channel.redirectTo is called because the channel's
// RedirectTo() implementation explicitly drops the flag
// to avoid additional redirects not caused by the
// Web Extension.
channel.loadInfo.allowInsecureRedirectToDataURI = true;
// To pass CORS checks, we pretend the current request's
// response allows the triggering origin to access.
let origin = channel.getRequestHeader("Origin");
if (origin) {
channel.setResponseHeader("Access-Control-Allow-Origin", origin);
channel.setResponseHeader(
"Access-Control-Allow-Credentials",
"true"
);
// Compute an arbitrary 'Access-Control-Allow-Headers'
// for the internal Redirect
let allowHeaders = channel
.getRequestHeaders()
.map(header => header.name)
.join();
channel.setResponseHeader(
"Access-Control-Allow-Headers",
allowHeaders
);
channel.setResponseHeader(
"Access-Control-Allow-Methods",
channel.method
);
}
return;
} catch (e) {
Cu.reportError(e);
}
}
if (result.upgradeToSecure && kind === "onBeforeRequest") {
try {
channel.upgradeToSecure();
} catch (e) {
Cu.reportError(e);
}
}
if (opts.requestHeaders && result.requestHeaders && requestHeaders) {
requestHeaders.applyChanges(result.requestHeaders, opts);
}
if (opts.responseHeaders && result.responseHeaders && responseHeaders) {
responseHeaders.applyChanges(result.responseHeaders, opts);
}
}
// If a listener did not cancel the request or provide credentials, we
// forward the auth request to the base handler.
if (kind === "onAuthRequired" && channel.authPromptForward) {
channel.authPromptForward();
}
if (kind === "onBeforeSendHeaders" && this.listeners.onSendHeaders.size) {
this.runChannelListener(channel, "onSendHeaders");
} else if (kind !== "onErrorOccurred") {
channel.errorCheck();
}
} catch (e) {
Cu.reportError(e);
}
// Only resume the channel if it was suspended by this call.
if (shouldResume) {
channel.resume();
}
},
shouldHookListener(listener, channel, extraData) {
if (listener.size == 0) {
return false;
}
for (let opts of listener.values()) {
if (channel.matches(opts.filter, opts.policy, extraData)) {
return true;
}
}
return false;
},
examine(channel, topic, data) {
if (this.listeners.onHeadersReceived.size) {
this.runChannelListener(channel, "onHeadersReceived");
}
if (
!channel.hasAuthRequestor &&
this.shouldHookListener(this.listeners.onAuthRequired, channel, {
isProxy: true,
})
) {
channel.channel.notificationCallbacks = new AuthRequestor(
channel.channel,
this
);
channel.hasAuthRequestor = true;
}
},
onChannelReplaced(oldChannel, newChannel) {
let channel = this.getWrapper(oldChannel);
// We want originalURI, this will provide a moz-ext rather than jar or file
// uri on redirects.
if (this.hasRedirects) {
this.runChannelListener(channel, "onBeforeRedirect", {
redirectUrl: newChannel.originalURI.spec,
});
}
channel.channel = newChannel;
},
};
function HttpEvent(internalEvent, options) {
this.internalEvent = internalEvent;
this.options = options;
}
HttpEvent.prototype = {
addListener(callback, filter = null, options = null, optionsObject = null) {
let opts = parseExtra(options, this.options, optionsObject);
opts.filter = parseFilter(filter);
HttpObserverManager.addListener(this.internalEvent, callback, opts);
},
removeListener(callback) {
HttpObserverManager.removeListener(this.internalEvent, callback);
},
};
var onBeforeRequest = new HttpEvent("onBeforeRequest", [
"blocking",
"requestBody",
]);
var onBeforeSendHeaders = new HttpEvent("onBeforeSendHeaders", [
"requestHeaders",
"blocking",
]);
var onSendHeaders = new HttpEvent("onSendHeaders", ["requestHeaders"]);
var onHeadersReceived = new HttpEvent("onHeadersReceived", [
"blocking",
"responseHeaders",
]);
var onAuthRequired = new HttpEvent("onAuthRequired", [
"blocking",
"responseHeaders",
]);
var onBeforeRedirect = new HttpEvent("onBeforeRedirect", ["responseHeaders"]);
var onResponseStarted = new HttpEvent("onResponseStarted", ["responseHeaders"]);
var onCompleted = new HttpEvent("onCompleted", ["responseHeaders"]);
var onErrorOccurred = new HttpEvent("onErrorOccurred");
var WebRequest = {
onBeforeRequest,
onBeforeSendHeaders,
onSendHeaders,
onHeadersReceived,
onAuthRequired,
onBeforeRedirect,
onResponseStarted,
onCompleted,
onErrorOccurred,
getSecurityInfo: details => {
let channel = ChannelWrapper.getRegisteredChannel(
details.id,
details.policy,
details.remoteTab
);
if (channel) {
return lazy.SecurityInfo.getSecurityInfo(
channel.channel,
details.options
);
}
},
};