diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/extensions/webrequest/WebRequest.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/extensions/webrequest/WebRequest.sys.mjs')
-rw-r--r-- | toolkit/components/extensions/webrequest/WebRequest.sys.mjs | 1338 |
1 files changed, 1338 insertions, 0 deletions
diff --git a/toolkit/components/extensions/webrequest/WebRequest.sys.mjs b/toolkit/components/extensions/webrequest/WebRequest.sys.mjs new file mode 100644 index 0000000000..e6e81f5484 --- /dev/null +++ b/toolkit/components/extensions/webrequest/WebRequest.sys.mjs @@ -0,0 +1,1338 @@ +/* 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 { nsIHttpActivityObserver, nsISocketTransport } = Ci; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", + SecurityInfo: "resource://gre/modules/SecurityInfo.sys.mjs", + WebRequestUpload: "resource://gre/modules/WebRequestUpload.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + ExtensionDNR: "resource://gre/modules/ExtensionDNR.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"]; + +// Response HTTP Headers matching the following patterns are restricted for changes +// applied by MV3 extensions. +const MV3_RESTRICTED_HEADERS_PATTERNS = [ + /^cross-origin-embedder-policy$/, + /^cross-origin-opener-policy$/, + /^cross-origin-resource-policy$/, + /^x-frame-options$/, + /^access-control-/, +]; + +// 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 || + channel.loadInfo?.internalContentPolicyType === + Ci.nsIContentPolicy.TYPE_INTERNAL_WORKER_STATIC_MODULE) && + !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; + } else if ( + opts.policy.manifestVersion > 2 && + this.isResponseHeaderRestricted(lowerCaseName) + ) { + // TODO (Bug 1787155 and Bug 1273281) open this up to MV3 extensions, + // locked behind manifest.json declarative permission and a separate + // explicit user-controlled permission (and ideally also check for + // changes that would lead to security downgrades). + Cu.reportError( + `Disallowed change restricted response header ${name} on ${this.channel.finalURL} from ${opts.policy.debugName}` + ); + return; + } + + try { + this.channel.setResponseHeader(name, value, merge); + } catch (e) { + Cu.reportError(new Error(`Error setting response header ${name}: ${e}`)); + } + } + + isResponseHeaderRestricted(lowerCaseHeaderName) { + return MV3_RESTRICTED_HEADERS_PATTERNS.some(regex => + regex.test(lowerCaseHeaderName) + ); + } + + 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(), + }, + // Whether there are any registered declarativeNetRequest rules. These DNR + // rules may match new requests and result in request modifications. + dnrActive: false, + + 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 || this.dnrActive; + let needBeforeConnect = + this.listeners.onBeforeSendHeaders.size || + this.listeners.onSendHeaders.size || + this.dnrActive; + 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 || + this.dnrActive; + + 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(); + }, + + setDNRHandlingEnabled(dnrActive) { + this.dnrActive = dnrActive; + 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; + } + if (this.dnrActive) { + // DNR may modify (but not cancel) the request at this stage. + lazy.ExtensionDNR.beforeWebRequestEvent(channel, kind); + } + + 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); + } + + if (this.dnrActive && lazy.ExtensionDNR.handleRequest(channel, kind)) { + return; + } + + 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.dnrActive) { + 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"); + +export var WebRequest = { + setDNRHandlingEnabled: dnrActive => { + HttpObserverManager.setDNRHandlingEnabled(dnrActive); + }, + getTabIdForChannelWrapper: channel => { + // Warning: This method should only be called after the initialization of + // ExtensionParent.apiManager.global. Generally, this means that this method + // should only be used by implementations of extension API methods (which + // themselves are loaded in ExtensionParent.apiManager.global and therefore + // imply the initialization of ExtensionParent.apiManager.global). + return HttpObserverManager.getBrowserData(channel).tabId; + }, + + 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 + ); + } + }, +}; |