diff options
Diffstat (limited to 'devtools/shared/network-observer/NetworkUtils.sys.mjs')
-rw-r--r-- | devtools/shared/network-observer/NetworkUtils.sys.mjs | 670 |
1 files changed, 670 insertions, 0 deletions
diff --git a/devtools/shared/network-observer/NetworkUtils.sys.mjs b/devtools/shared/network-observer/NetworkUtils.sys.mjs new file mode 100644 index 0000000000..44de9fa353 --- /dev/null +++ b/devtools/shared/network-observer/NetworkUtils.sys.mjs @@ -0,0 +1,670 @@ +/* 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, { + NetworkHelper: + "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "tpFlagsMask", () => { + const trackingProtectionLevel2Enabled = Services.prefs + .getStringPref("urlclassifier.trackingTable") + .includes("content-track-digest256"); + + return trackingProtectionLevel2Enabled + ? ~Ci.nsIClassifiedChannel.CLASSIFIED_ANY_BASIC_TRACKING & + ~Ci.nsIClassifiedChannel.CLASSIFIED_ANY_STRICT_TRACKING + : ~Ci.nsIClassifiedChannel.CLASSIFIED_ANY_BASIC_TRACKING & + Ci.nsIClassifiedChannel.CLASSIFIED_ANY_STRICT_TRACKING; +}); + +/** + * Convert a nsIContentPolicy constant to a display string + */ +const LOAD_CAUSE_STRINGS = { + [Ci.nsIContentPolicy.TYPE_INVALID]: "invalid", + [Ci.nsIContentPolicy.TYPE_OTHER]: "other", + [Ci.nsIContentPolicy.TYPE_SCRIPT]: "script", + [Ci.nsIContentPolicy.TYPE_IMAGE]: "img", + [Ci.nsIContentPolicy.TYPE_STYLESHEET]: "stylesheet", + [Ci.nsIContentPolicy.TYPE_OBJECT]: "object", + [Ci.nsIContentPolicy.TYPE_DOCUMENT]: "document", + [Ci.nsIContentPolicy.TYPE_SUBDOCUMENT]: "subdocument", + [Ci.nsIContentPolicy.TYPE_PING]: "ping", + [Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST]: "xhr", + [Ci.nsIContentPolicy.TYPE_OBJECT_SUBREQUEST]: "objectSubdoc", + [Ci.nsIContentPolicy.TYPE_DTD]: "dtd", + [Ci.nsIContentPolicy.TYPE_FONT]: "font", + [Ci.nsIContentPolicy.TYPE_MEDIA]: "media", + [Ci.nsIContentPolicy.TYPE_WEBSOCKET]: "websocket", + [Ci.nsIContentPolicy.TYPE_CSP_REPORT]: "csp", + [Ci.nsIContentPolicy.TYPE_XSLT]: "xslt", + [Ci.nsIContentPolicy.TYPE_BEACON]: "beacon", + [Ci.nsIContentPolicy.TYPE_FETCH]: "fetch", + [Ci.nsIContentPolicy.TYPE_IMAGESET]: "imageset", + [Ci.nsIContentPolicy.TYPE_WEB_MANIFEST]: "webManifest", + [Ci.nsIContentPolicy.TYPE_WEB_IDENTITY]: "webidentity", +}; + +function causeTypeToString(causeType, loadFlags, internalContentPolicyType) { + let prefix = ""; + if ( + (causeType == Ci.nsIContentPolicy.TYPE_IMAGESET || + internalContentPolicyType == Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE) && + loadFlags & Ci.nsIRequest.LOAD_BACKGROUND + ) { + prefix = "lazy-"; + } + + return prefix + LOAD_CAUSE_STRINGS[causeType] || "unknown"; +} + +function stringToCauseType(value) { + return Object.keys(LOAD_CAUSE_STRINGS).find( + key => LOAD_CAUSE_STRINGS[key] === value + ); +} + +function isChannelFromSystemPrincipal(channel) { + let principal = null; + let browsingContext = channel.loadInfo.browsingContext; + if (!browsingContext) { + const topFrame = lazy.NetworkHelper.getTopFrameForRequest(channel); + if (topFrame) { + browsingContext = topFrame.browsingContext; + } else { + // Fallback to the triggering principal when browsingContext and topFrame is null + // e.g some chrome requests + principal = channel.loadInfo.triggeringPrincipal; + } + } + + // When in the parent process, we can get the documentPrincipal from the + // WindowGlobal which is available on the BrowsingContext + if (!principal) { + principal = CanonicalBrowsingContext.isInstance(browsingContext) + ? browsingContext.currentWindowGlobal.documentPrincipal + : browsingContext.window.document.nodePrincipal; + } + return principal.isSystemPrincipal; +} + +/** + * Get the browsing context id for the channel. + * + * @param {*} channel + * @returns {number} + */ +function getChannelBrowsingContextID(channel) { + if (channel.loadInfo.browsingContextID) { + return channel.loadInfo.browsingContextID; + } + // At least WebSocket channel aren't having a browsingContextID set on their loadInfo + // We fallback on top frame element, which works, but will be wrong for WebSocket + // in same-process iframes... + const topFrame = lazy.NetworkHelper.getTopFrameForRequest(channel); + // topFrame is typically null for some chrome requests like favicons + if (topFrame && topFrame.browsingContext) { + return topFrame.browsingContext.id; + } + return null; +} + +/** + * Get the innerWindowId for the channel. + * + * @param {*} channel + * @returns {number} + */ +function getChannelInnerWindowId(channel) { + if (channel.loadInfo.innerWindowID) { + return channel.loadInfo.innerWindowID; + } + // At least WebSocket channel aren't having a browsingContextID set on their loadInfo + // We fallback on top frame element, which works, but will be wrong for WebSocket + // in same-process iframes... + const topFrame = lazy.NetworkHelper.getTopFrameForRequest(channel); + // topFrame is typically null for some chrome requests like favicons + if (topFrame?.browsingContext?.currentWindowGlobal) { + return topFrame.browsingContext.currentWindowGlobal.innerWindowId; + } + return null; +} + +/** + * Does this channel represent a Preload request. + * + * @param {*} channel + * @returns {boolean} + */ +function isPreloadRequest(channel) { + const type = channel.loadInfo.internalContentPolicyType; + return ( + type == Ci.nsIContentPolicy.TYPE_INTERNAL_SCRIPT_PRELOAD || + type == Ci.nsIContentPolicy.TYPE_INTERNAL_MODULE_PRELOAD || + type == Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_PRELOAD || + type == Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET_PRELOAD || + type == Ci.nsIContentPolicy.TYPE_INTERNAL_FONT_PRELOAD + ); +} + +/** + * Get the channel cause details. + * + * @param {nsIChannel} channel + * @returns {Object} + * - loadingDocumentUri {string} uri of the document which created the + * channel + * - type {string} cause type as string + */ +function getCauseDetails(channel) { + // Determine the cause and if this is an XHR request. + let causeType = Ci.nsIContentPolicy.TYPE_OTHER; + let causeUri = null; + + if (channel.loadInfo) { + causeType = channel.loadInfo.externalContentPolicyType; + const { loadingPrincipal } = channel.loadInfo; + if (loadingPrincipal) { + causeUri = loadingPrincipal.spec; + } + } + + return { + loadingDocumentUri: causeUri, + type: causeTypeToString( + causeType, + channel.loadFlags, + channel.loadInfo.internalContentPolicyType + ), + }; +} + +/** + * Get the channel priority. Priority is a number which typically ranges from + * -20 (lowest priority) to 20 (highest priority). Can be null if the channel + * does not implement nsISupportsPriority. + * + * @param {nsIChannel} channel + * @returns {number|undefined} + */ +function getChannelPriority(channel) { + if (channel instanceof Ci.nsISupportsPriority) { + return channel.priority; + } + + return null; +} + +/** + * Get the channel HTTP version as an uppercase string starting with "HTTP/" + * (eg "HTTP/2"). + * + * @param {nsIChannel} channel + * @returns {string} + */ +function getHttpVersion(channel) { + // Determine the HTTP version. + const httpVersionMaj = {}; + const httpVersionMin = {}; + + channel.QueryInterface(Ci.nsIHttpChannelInternal); + channel.getResponseVersion(httpVersionMaj, httpVersionMin); + + // The official name HTTP version 2.0 and 3.0 are HTTP/2 and HTTP/3, omit the + // trailing `.0`. + if (httpVersionMin.value == 0) { + return "HTTP/" + httpVersionMaj.value; + } + + return "HTTP/" + httpVersionMaj.value + "." + httpVersionMin.value; +} + +const UNKNOWN_PROTOCOL_STRINGS = ["", "unknown"]; +const HTTP_PROTOCOL_STRINGS = ["http", "https"]; +/** + * Get the protocol for the provided httpActivity. Either the ALPN negotiated + * protocol or as a fallback a protocol computed from the scheme and the + * response status. + * + * TODO: The `protocol` is similar to another response property called + * `httpVersion`. `httpVersion` is uppercase and purely computed from the + * response status, whereas `protocol` uses nsIHttpChannel.protocolVersion by + * default and otherwise falls back on `httpVersion`. Ideally we should merge + * the two properties. + * + * @param {Object} httpActivity + * The httpActivity object for which we need to get the protocol. + * + * @returns {string} + * The protocol as a string. + */ +function getProtocol(channel) { + let protocol = ""; + try { + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + // protocolVersion corresponds to ALPN negotiated protocol. + protocol = httpChannel.protocolVersion; + } catch (e) { + // Ignore errors reading protocolVersion. + } + + if (UNKNOWN_PROTOCOL_STRINGS.includes(protocol)) { + protocol = channel.URI.scheme; + const httpVersion = getHttpVersion(channel); + if ( + typeof httpVersion == "string" && + HTTP_PROTOCOL_STRINGS.includes(protocol) + ) { + protocol = httpVersion.toLowerCase(); + } + } + + return protocol; +} + +/** + * Get the channel referrer policy as a string + * (eg "strict-origin-when-cross-origin"). + * + * @param {nsIChannel} channel + * @returns {string} + */ +function getReferrerPolicy(channel) { + return channel.referrerInfo + ? channel.referrerInfo.getReferrerPolicyString() + : ""; +} + +/** + * Check if the channel is private. + * + * @param {nsIChannel} channel + * @returns {boolean} + */ +function isChannelPrivate(channel) { + channel.QueryInterface(Ci.nsIPrivateBrowsingChannel); + return channel.isChannelPrivate; +} + +/** + * Check if the channel data is loaded from the cache or not. + * + * @param {nsIChannel} channel + * The channel for which we need to check the cache status. + * + * @returns {boolean} + * True if the channel data is loaded from the cache, false otherwise. + */ +function isFromCache(channel) { + if (channel instanceof Ci.nsICacheInfoChannel) { + return channel.isFromCache(); + } + + return false; +} + +const REDIRECT_STATES = [ + 301, // HTTP Moved Permanently + 302, // HTTP Found + 303, // HTTP See Other + 307, // HTTP Temporary Redirect +]; +/** + * Check if the channel's status corresponds to a known redirect status. + * + * @param {nsIChannel} channel + * The channel for which we need to check the redirect status. + * + * @returns {boolean} + * True if the channel data is a redirect, false otherwise. + */ +function isRedirectedChannel(channel) { + try { + return REDIRECT_STATES.includes(channel.responseStatus); + } catch (e) { + // Throws NS_ERROR_NOT_AVAILABLE if the request was not sent yet. + } + return false; +} + +/** + * isNavigationRequest is true for the one request used to load a new top level + * document of a given tab, or top level window. It will typically be false for + * navigation requests of iframes, i.e. the request loading another document in + * an iframe. + * + * @param {nsIChannel} channel + * @return {boolean} + */ +function isNavigationRequest(channel) { + return channel.isMainDocumentChannel && channel.loadInfo.isTopLevelLoad; +} + +/** + * Returns true if the channel has been processed by URL-Classifier features + * and is considered third-party with the top window URI, and if it has loaded + * a resource that is classified as a tracker. + * + * @param {nsIChannel} channel + * @return {boolean} + */ +function isThirdPartyTrackingResource(channel) { + // Only consider channels classified as level-1 to be trackers if our preferences + // would not cause such channels to be blocked in strict content blocking mode. + // Make sure the value produced here is a boolean. + return !!( + channel instanceof Ci.nsIClassifiedChannel && + channel.isThirdPartyTrackingResource() && + (channel.thirdPartyClassificationFlags & lazy.tpFlagsMask) == 0 + ); +} + +/** + * Retrieve the websocket channel for the provided channel, if available. + * Returns null otherwise. + * + * @param {nsIChannel} channel + * @returns {nsIWebSocketChannel|null} + */ +function getWebSocketChannel(channel) { + let wsChannel = null; + if (channel.notificationCallbacks) { + try { + wsChannel = channel.notificationCallbacks.QueryInterface( + Ci.nsIWebSocketChannel + ); + } catch (e) { + // Not all channels implement nsIWebSocketChannel. + } + } + return wsChannel; +} + +/** + * For a given channel, fetch the request's headers and cookies. + * + * @param {nsIChannel} channel + * @return {Object} + * An object with two properties: + * @property {Array<Object>} cookies + * Array of { name, value } objects. + * @property {Array<Object>} headers + * Array of { name, value } objects. + */ +function fetchRequestHeadersAndCookies(channel) { + const headers = []; + let cookies = []; + let cookieHeader = null; + + // Copy the request header data. + channel.visitRequestHeaders({ + visitHeader(name, value) { + if (name == "Cookie") { + cookieHeader = value; + } + headers.push({ name, value }); + }, + }); + + if (cookieHeader) { + cookies = lazy.NetworkHelper.parseCookieHeader(cookieHeader); + } + + return { cookies, headers }; +} + +/** + * For a given channel, fetch the response's headers and cookies. + * + * @param {nsIChannel} channel + * @return {Object} + * An object with two properties: + * @property {Array<Object>} cookies + * Array of { name, value } objects. + * @property {Array<Object>} headers + * Array of { name, value } objects. + */ +function fetchResponseHeadersAndCookies(channel) { + // Read response headers and cookies. + const headers = []; + const setCookieHeaders = []; + + const SET_COOKIE_REGEXP = /set-cookie/i; + channel.visitOriginalResponseHeaders({ + visitHeader(name, value) { + if (SET_COOKIE_REGEXP.test(name)) { + setCookieHeaders.push(value); + } + headers.push({ name, value }); + }, + }); + + return { + cookies: lazy.NetworkHelper.parseSetCookieHeaders(setCookieHeaders), + headers, + }; +} + +/** + * Check if a given network request should be logged by a network monitor + * based on the specified filters. + * + * @param nsIHttpChannel channel + * Request to check. + * @param filters + * NetworkObserver filters to match against. An object with one of the following attributes: + * - sessionContext: When inspecting requests from the parent process, pass the WatcherActor's session context. + * This helps know what is the overall debugged scope. + * See watcher actor constructor for more info. + * - targetActor: When inspecting requests from the content process, pass the WindowGlobalTargetActor. + * This helps know what exact subset of request we should accept. + * This is especially useful to behave correctly regarding EFT, where we should include or not + * iframes requests. + * - browserId, addonId, window: All these attributes are legacy. + * Only browserId attribute is still used by the legacy WebConsoleActor startListener API. + * @return boolean + * True if the network request should be logged, false otherwise. + */ +function matchRequest(channel, filters) { + // NetworkEventWatcher should now pass a session context for the parent process codepath + if (filters.sessionContext) { + const { type } = filters.sessionContext; + if (type == "all") { + return true; + } + + // Ignore requests from chrome or add-on code when we don't monitor the whole browser + if ( + channel.loadInfo?.loadingDocument === null && + (channel.loadInfo.loadingPrincipal === + Services.scriptSecurityManager.getSystemPrincipal() || + channel.loadInfo.isInDevToolsContext) + ) { + return false; + } + + if (type == "browser-element") { + if (!channel.loadInfo.browsingContext) { + const topFrame = lazy.NetworkHelper.getTopFrameForRequest(channel); + // `topFrame` is typically null for some chrome requests like favicons + // And its `browsingContext` attribute might be null if the request happened + // while the tab is being closed. + return ( + topFrame?.browsingContext?.browserId == + filters.sessionContext.browserId + ); + } + return ( + channel.loadInfo.browsingContext.browserId == + filters.sessionContext.browserId + ); + } + if (type == "webextension") { + return ( + channel.loadInfo?.loadingPrincipal?.addonId === + filters.sessionContext.addonId + ); + } + throw new Error("Unsupported session context type: " + type); + } + + // NetworkEventContentWatcher and NetworkEventStackTraces pass a target actor instead, from the content processes + // Because of EFT, we can't use session context as we have to know what exact windows the target actor covers. + if (filters.targetActor) { + // Bug 1769982 the target actor might be destroying and accessing windows will throw. + // Ignore all further request when this happens. + let windows; + try { + windows = filters.targetActor.windows; + } catch (e) { + return false; + } + const win = lazy.NetworkHelper.getWindowForRequest(channel); + return windows.includes(win); + } + + // This is fallback code for the legacy WebConsole.startListeners codepath, + // which may still pass individual browserId/window/addonId attributes. + // This should be removable once we drop the WebConsole codepath for network events + // (bug 1721592 and followups) + return legacyMatchRequest(channel, filters); +} + +function legacyMatchRequest(channel, filters) { + // Log everything if no filter is specified + if (!filters.browserId && !filters.window && !filters.addonId) { + return true; + } + + // Ignore requests from chrome or add-on code when we are monitoring + // content. + if ( + channel.loadInfo?.loadingDocument === null && + (channel.loadInfo.loadingPrincipal === + Services.scriptSecurityManager.getSystemPrincipal() || + channel.loadInfo.isInDevToolsContext) + ) { + return false; + } + + if (filters.window) { + let win = lazy.NetworkHelper.getWindowForRequest(channel); + if (filters.matchExactWindow) { + return win == filters.window; + } + + // Since frames support, this.window may not be the top level content + // frame, so that we can't only compare with win.top. + while (win) { + if (win == filters.window) { + return true; + } + if (win.parent == win) { + break; + } + win = win.parent; + } + return false; + } + + if (filters.browserId) { + const topFrame = lazy.NetworkHelper.getTopFrameForRequest(channel); + // `topFrame` is typically null for some chrome requests like favicons + // And its `browsingContext` attribute might be null if the request happened + // while the tab is being closed. + if (topFrame?.browsingContext?.browserId == filters.browserId) { + return true; + } + + // If we couldn't get the top frame BrowsingContext from the loadContext, + // look for it on channel.loadInfo instead. + if (channel.loadInfo?.browsingContext?.browserId == filters.browserId) { + return true; + } + } + + if ( + filters.addonId && + channel.loadInfo?.loadingPrincipal?.addonId === filters.addonId + ) { + return true; + } + + return false; +} + +function getBlockedReason(channel) { + let blockingExtension, blockedReason; + const { status } = channel; + + try { + const request = channel.QueryInterface(Ci.nsIHttpChannel); + const properties = request.QueryInterface(Ci.nsIPropertyBag); + + blockedReason = request.loadInfo.requestBlockingReason; + blockingExtension = properties.getProperty("cancelledByExtension"); + + // WebExtensionPolicy is not available for workers + if (typeof WebExtensionPolicy !== "undefined") { + blockingExtension = WebExtensionPolicy.getByID(blockingExtension).name; + } + } catch (err) { + // "cancelledByExtension" doesn't have to be available. + } + // These are platform errors which are not exposed to the users, + // usually the requests (with these errors) might be displayed with various + // other status codes. + const ignoreList = [ + // This is emited when the request is already in the cache. + "NS_ERROR_PARSED_DATA_CACHED", + // This is emited when there is some issues around images e.g When the img.src + // links to a non existent url. This is typically shown as a 404 request. + "NS_IMAGELIB_ERROR_FAILURE", + // This is emited when there is a redirect. They are shown as 301 requests. + "NS_BINDING_REDIRECTED", + // E.g Emited by send beacon requests. + "NS_ERROR_ABORT", + ]; + + // If the request has not failed or is not blocked by a web extension, check for + // any errors not on the ignore list. e.g When a host is not found (NS_ERROR_UNKNOWN_HOST). + if ( + blockedReason == 0 && + !Components.isSuccessCode(status) && + !ignoreList.includes(ChromeUtils.getXPCOMErrorName(status)) + ) { + blockedReason = ChromeUtils.getXPCOMErrorName(status); + } + + return { blockingExtension, blockedReason }; +} + +export const NetworkUtils = { + causeTypeToString, + fetchRequestHeadersAndCookies, + fetchResponseHeadersAndCookies, + getCauseDetails, + getChannelBrowsingContextID, + getChannelInnerWindowId, + getChannelPriority, + getHttpVersion, + getProtocol, + getReferrerPolicy, + getWebSocketChannel, + isChannelFromSystemPrincipal, + isChannelPrivate, + isFromCache, + isNavigationRequest, + isPreloadRequest, + isRedirectedChannel, + isThirdPartyTrackingResource, + matchRequest, + stringToCauseType, + getBlockedReason, +}; |