diff options
Diffstat (limited to 'toolkit/modules/addons')
-rw-r--r-- | toolkit/modules/addons/.eslintrc.js | 15 | ||||
-rw-r--r-- | toolkit/modules/addons/MatchPattern.jsm | 352 | ||||
-rw-r--r-- | toolkit/modules/addons/WebNavigation.jsm | 370 | ||||
-rw-r--r-- | toolkit/modules/addons/WebNavigationContent.js | 272 | ||||
-rw-r--r-- | toolkit/modules/addons/WebNavigationFrames.jsm | 142 | ||||
-rw-r--r-- | toolkit/modules/addons/WebRequest.jsm | 918 | ||||
-rw-r--r-- | toolkit/modules/addons/WebRequestCommon.jsm | 57 | ||||
-rw-r--r-- | toolkit/modules/addons/WebRequestContent.js | 192 | ||||
-rw-r--r-- | toolkit/modules/addons/WebRequestUpload.jsm | 321 |
9 files changed, 2639 insertions, 0 deletions
diff --git a/toolkit/modules/addons/.eslintrc.js b/toolkit/modules/addons/.eslintrc.js new file mode 100644 index 000000000..019759c87 --- /dev/null +++ b/toolkit/modules/addons/.eslintrc.js @@ -0,0 +1,15 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": "../../components/extensions/.eslintrc.js", + + "globals": { + "addEventListener": false, + "addMessageListener": false, + "removeEventListener": false, + "sendAsyncMessage": false, + "AddonManagerPermissions": false, + + "initialProcessData": true, + }, +}; diff --git a/toolkit/modules/addons/MatchPattern.jsm b/toolkit/modules/addons/MatchPattern.jsm new file mode 100644 index 000000000..4dff81fd2 --- /dev/null +++ b/toolkit/modules/addons/MatchPattern.jsm @@ -0,0 +1,352 @@ +/* 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 Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +this.EXPORTED_SYMBOLS = ["MatchPattern", "MatchGlobs", "MatchURLFilters"]; + +/* globals MatchPattern, MatchGlobs */ + +const PERMITTED_SCHEMES = ["http", "https", "file", "ftp", "data"]; +const PERMITTED_SCHEMES_REGEXP = PERMITTED_SCHEMES.join("|"); + +// This function converts a glob pattern (containing * and possibly ? +// as wildcards) to a regular expression. +function globToRegexp(pat, allowQuestion) { + // Escape everything except ? and *. + pat = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + + if (allowQuestion) { + pat = pat.replace(/\?/g, "."); + } else { + pat = pat.replace(/\?/g, "\\?"); + } + pat = pat.replace(/\*/g, ".*"); + return new RegExp("^" + pat + "$"); +} + +// These patterns follow the syntax in +// https://developer.chrome.com/extensions/match_patterns +function SingleMatchPattern(pat) { + if (pat == "<all_urls>") { + this.schemes = PERMITTED_SCHEMES; + this.hostMatch = () => true; + this.pathMatch = () => true; + } else if (!pat) { + this.schemes = []; + } else { + let re = new RegExp(`^(${PERMITTED_SCHEMES_REGEXP}|\\*)://(\\*|\\*\\.[^*/]+|[^*/]+|)(/.*)$`); + let match = re.exec(pat); + if (!match) { + Cu.reportError(`Invalid match pattern: '${pat}'`); + this.schemes = []; + return; + } + + if (match[1] == "*") { + this.schemes = ["http", "https"]; + } else { + this.schemes = [match[1]]; + } + + // We allow the host to be empty for file URLs. + if (match[2] == "" && this.schemes[0] != "file") { + Cu.reportError(`Invalid match pattern: '${pat}'`); + this.schemes = []; + return; + } + + this.host = match[2]; + this.hostMatch = this.getHostMatcher(match[2]); + + let pathMatch = globToRegexp(match[3], false); + this.pathMatch = pathMatch.test.bind(pathMatch); + } +} + +SingleMatchPattern.prototype = { + getHostMatcher(host) { + // This code ignores the port, as Chrome does. + if (host == "*") { + return () => true; + } + if (host.startsWith("*.")) { + let suffix = host.substr(2); + let dotSuffix = "." + suffix; + + return ({host}) => host === suffix || host.endsWith(dotSuffix); + } + return uri => uri.host === host; + }, + + matches(uri, ignorePath = false) { + return ( + this.schemes.includes(uri.scheme) && + this.hostMatch(uri) && + (ignorePath || ( + this.pathMatch(uri.cloneIgnoringRef().path) + )) + ); + }, +}; + +this.MatchPattern = function(pat) { + this.pat = pat; + if (!pat) { + this.matchers = []; + } else if (pat instanceof String || typeof(pat) == "string") { + this.matchers = [new SingleMatchPattern(pat)]; + } else { + this.matchers = pat.map(p => new SingleMatchPattern(p)); + } +}; + +MatchPattern.prototype = { + // |uri| should be an nsIURI. + matches(uri) { + return this.matchers.some(matcher => matcher.matches(uri)); + }, + + matchesIgnoringPath(uri) { + return this.matchers.some(matcher => matcher.matches(uri, true)); + }, + + // Checks that this match pattern grants access to read the given + // cookie. |cookie| should be an |nsICookie2| instance. + matchesCookie(cookie) { + // First check for simple matches. + let secureURI = NetUtil.newURI(`https://${cookie.rawHost}/`); + if (this.matchesIgnoringPath(secureURI)) { + return true; + } + + let plainURI = NetUtil.newURI(`http://${cookie.rawHost}/`); + if (!cookie.isSecure && this.matchesIgnoringPath(plainURI)) { + return true; + } + + if (!cookie.isDomain) { + return false; + } + + // Things get tricker for domain cookies. The extension needs to be able + // to read any cookies that could be read any host it has permissions + // for. This means that our normal host matching checks won't work, + // since the pattern "*://*.foo.example.com/" doesn't match ".example.com", + // but it does match "bar.foo.example.com", which can read cookies + // with the domain ".example.com". + // + // So, instead, we need to manually check our filters, and accept any + // with hosts that end with our cookie's host. + + let {host, isSecure} = cookie; + + for (let matcher of this.matchers) { + let schemes = matcher.schemes; + if (schemes.includes("https") || (!isSecure && schemes.includes("http"))) { + if (matcher.host.endsWith(host)) { + return true; + } + } + } + + return false; + }, + + serialize() { + return this.pat; + }, +}; + +// Globs can match everything. Be careful, this DOES NOT filter by allowed schemes! +this.MatchGlobs = function(globs) { + this.original = globs; + if (globs) { + this.regexps = Array.from(globs, (glob) => globToRegexp(glob, true)); + } else { + this.regexps = []; + } +}; + +MatchGlobs.prototype = { + matches(str) { + return this.regexps.some(regexp => regexp.test(str)); + }, + serialize() { + return this.original; + }, +}; + +// Match WebNavigation URL Filters. +this.MatchURLFilters = function(filters) { + if (!Array.isArray(filters)) { + throw new TypeError("filters should be an array"); + } + + if (filters.length == 0) { + throw new Error("filters array should not be empty"); + } + + this.filters = filters; +}; + +MatchURLFilters.prototype = { + matches(url) { + let uri = NetUtil.newURI(url); + // Set uriURL to an empty object (needed because some schemes, e.g. about doesn't support nsIURL). + let uriURL = {}; + if (uri instanceof Ci.nsIURL) { + uriURL = uri; + } + + // Set host to a empty string by default (needed so that schemes without an host, + // e.g. about, can pass an empty string for host based event filtering as expected). + let host = ""; + try { + host = uri.host; + } catch (e) { + // 'uri.host' throws an exception with some uri schemes (e.g. about). + } + + let port; + try { + port = uri.port; + } catch (e) { + // 'uri.port' throws an exception with some uri schemes (e.g. about), + // in which case it will be |undefined|. + } + + let data = { + // NOTE: This properties are named after the name of their related + // filters (e.g. `pathContains/pathEquals/...` will be tested against the + // `data.path` property, and the same is done for the `host`, `query` and `url` + // components as well). + path: uriURL.filePath, + query: uriURL.query, + host, + port, + url, + }; + + // If any of the filters matches, matches returns true. + return this.filters.some(filter => this.matchURLFilter({filter, data, uri, uriURL})); + }, + + matchURLFilter({filter, data, uri, uriURL}) { + // Test for scheme based filtering. + if (filter.schemes) { + // Return false if none of the schemes matches. + if (!filter.schemes.some((scheme) => uri.schemeIs(scheme))) { + return false; + } + } + + // Test for exact port matching or included in a range of ports. + if (filter.ports) { + let port = data.port; + if (port === -1) { + // NOTE: currently defaultPort for "resource" and "chrome" schemes defaults to -1, + // for "about", "data" and "javascript" schemes defaults to undefined. + if (["resource", "chrome"].includes(uri.scheme)) { + port = undefined; + } else { + port = Services.io.getProtocolHandler(uri.scheme).defaultPort; + } + } + + // Return false if none of the ports (or port ranges) is verified + return filter.ports.some((filterPort) => { + if (Array.isArray(filterPort)) { + let [lower, upper] = filterPort; + return port >= lower && port <= upper; + } + + return port === filterPort; + }); + } + + // Filters on host, url, path, query: + // hostContains, hostEquals, hostSuffix, hostPrefix, + // urlContains, urlEquals, ... + for (let urlComponent of ["host", "path", "query", "url"]) { + if (!this.testMatchOnURLComponent({urlComponent, data, filter})) { + return false; + } + } + + // urlMatches is a regular expression string and it is tested for matches + // on the "url without the ref". + if (filter.urlMatches) { + let urlWithoutRef = uri.specIgnoringRef; + if (!urlWithoutRef.match(filter.urlMatches)) { + return false; + } + } + + // originAndPathMatches is a regular expression string and it is tested for matches + // on the "url without the query and the ref". + if (filter.originAndPathMatches) { + let urlWithoutQueryAndRef = uri.resolve(uriURL.filePath); + // The above 'uri.resolve(...)' will be null for some URI schemes + // (e.g. about). + // TODO: handle schemes which will not be able to resolve the filePath + // (e.g. for "about:blank", 'urlWithoutQueryAndRef' should be "about:blank" instead + // of null) + if (!urlWithoutQueryAndRef || + !urlWithoutQueryAndRef.match(filter.originAndPathMatches)) { + return false; + } + } + + return true; + }, + + testMatchOnURLComponent({urlComponent: key, data, filter}) { + // Test for equals. + // NOTE: an empty string should not be considered a filter to skip. + if (filter[`${key}Equals`] != null) { + if (data[key] !== filter[`${key}Equals`]) { + return false; + } + } + + // Test for contains. + if (filter[`${key}Contains`]) { + let value = (key == "host" ? "." : "") + data[key]; + if (!data[key] || !value.includes(filter[`${key}Contains`])) { + return false; + } + } + + // Test for prefix. + if (filter[`${key}Prefix`]) { + if (!data[key] || !data[key].startsWith(filter[`${key}Prefix`])) { + return false; + } + } + + // Test for suffix. + if (filter[`${key}Suffix`]) { + if (!data[key] || !data[key].endsWith(filter[`${key}Suffix`])) { + return false; + } + } + + return true; + }, + + serialize() { + return this.filters; + }, +}; diff --git a/toolkit/modules/addons/WebNavigation.jsm b/toolkit/modules/addons/WebNavigation.jsm new file mode 100644 index 000000000..6302a9d79 --- /dev/null +++ b/toolkit/modules/addons/WebNavigation.jsm @@ -0,0 +1,370 @@ +/* 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 = ["WebNavigation"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow", + "resource:///modules/RecentWindow.jsm"); + +// Maximum amount of time that can be passed and still consider +// the data recent (similar to how is done in nsNavHistory, +// e.g. nsNavHistory::CheckIsRecentEvent, but with a lower threshold value). +const RECENT_DATA_THRESHOLD = 5 * 1000000; + +// TODO: +// onCreatedNavigationTarget + +var Manager = { + // Map[string -> Map[listener -> URLFilter]] + listeners: new Map(), + + init() { + // Collect recent tab transition data in a WeakMap: + // browser -> tabTransitionData + this.recentTabTransitionData = new WeakMap(); + Services.obs.addObserver(this, "autocomplete-did-enter-text", true); + + Services.mm.addMessageListener("Content:Click", this); + Services.mm.addMessageListener("Extension:DOMContentLoaded", this); + Services.mm.addMessageListener("Extension:StateChange", this); + Services.mm.addMessageListener("Extension:DocumentChange", this); + Services.mm.addMessageListener("Extension:HistoryChange", this); + + Services.mm.loadFrameScript("resource://gre/modules/WebNavigationContent.js", true); + }, + + uninit() { + // Stop collecting recent tab transition data and reset the WeakMap. + Services.obs.removeObserver(this, "autocomplete-did-enter-text", true); + this.recentTabTransitionData = new WeakMap(); + + Services.mm.removeMessageListener("Content:Click", this); + Services.mm.removeMessageListener("Extension:StateChange", this); + Services.mm.removeMessageListener("Extension:DocumentChange", this); + Services.mm.removeMessageListener("Extension:HistoryChange", this); + Services.mm.removeMessageListener("Extension:DOMContentLoaded", this); + + Services.mm.removeDelayedFrameScript("resource://gre/modules/WebNavigationContent.js"); + Services.mm.broadcastAsyncMessage("Extension:DisableWebNavigation"); + }, + + addListener(type, listener, filters) { + if (this.listeners.size == 0) { + this.init(); + } + + if (!this.listeners.has(type)) { + this.listeners.set(type, new Map()); + } + let listeners = this.listeners.get(type); + listeners.set(listener, filters); + }, + + removeListener(type, listener) { + let listeners = this.listeners.get(type); + if (!listeners) { + return; + } + listeners.delete(listener); + if (listeners.size == 0) { + this.listeners.delete(type); + } + + if (this.listeners.size == 0) { + this.uninit(); + } + }, + + /** + * Support nsIObserver interface to observe the urlbar autocomplete events used + * to keep track of the urlbar user interaction. + */ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]), + + /** + * Observe autocomplete-did-enter-text topic to track the user interaction with + * the awesome bar. + * + * @param {nsIAutoCompleteInput} subject + * @param {string} topic + * @param {string} data + */ + observe: function(subject, topic, data) { + if (topic == "autocomplete-did-enter-text") { + this.onURLBarAutoCompletion(subject); + } + }, + + /** + * Recognize the type of urlbar user interaction (e.g. typing a new url, + * clicking on an url generated from a searchengine or a keyword, or a + * bookmark found by the urlbar autocompletion). + * + * @param {nsIAutoCompleteInput} input + */ + onURLBarAutoCompletion(input) { + if (input && input instanceof Ci.nsIAutoCompleteInput) { + // We are only interested in urlbar autocompletion events + if (input.id !== "urlbar") { + return; + } + + let controller = input.popup.view.QueryInterface(Ci.nsIAutoCompleteController); + let idx = input.popup.selectedIndex; + + let tabTransistionData = { + from_address_bar: true, + }; + + if (idx < 0 || idx >= controller.matchCount) { + // Recognize when no valid autocomplete results has been selected. + tabTransistionData.typed = true; + } else { + let value = controller.getValueAt(idx); + let action = input._parseActionUrl(value); + + if (action) { + // Detect keywork and generated and more typed scenarios. + switch (action.type) { + case "keyword": + tabTransistionData.keyword = true; + break; + case "searchengine": + case "searchsuggestion": + tabTransistionData.generated = true; + break; + case "visiturl": + // Visiturl are autocompletion results related to + // history suggestions. + tabTransistionData.typed = true; + break; + case "remotetab": + // Remote tab are autocomplete results related to + // tab urls from a remote synchronized Firefox. + tabTransistionData.typed = true; + break; + case "switchtab": + // This "switchtab" autocompletion should be ignored, because + // it is not related to a navigation. + return; + default: + // Fallback on "typed" if unable to detect a known moz-action type. + tabTransistionData.typed = true; + } + } else { + // Special handling for bookmark urlbar autocompletion + // (which happens when we got a null action and a valid selectedIndex) + let styles = new Set(controller.getStyleAt(idx).split(/\s+/)); + + if (styles.has("bookmark")) { + tabTransistionData.auto_bookmark = true; + } else { + // Fallback on "typed" if unable to detect a specific actionType + // (and when in the styles there are "autofill" or "history"). + tabTransistionData.typed = true; + } + } + } + + this.setRecentTabTransitionData(tabTransistionData); + } + }, + + /** + * Keep track of a recent user interaction and cache it in a + * map associated to the current selected tab. + * + * @param {object} tabTransitionData + * @param {boolean} [tabTransitionData.auto_bookmark] + * @param {boolean} [tabTransitionData.from_address_bar] + * @param {boolean} [tabTransitionData.generated] + * @param {boolean} [tabTransitionData.keyword] + * @param {boolean} [tabTransitionData.link] + * @param {boolean} [tabTransitionData.typed] + */ + setRecentTabTransitionData(tabTransitionData) { + let window = RecentWindow.getMostRecentBrowserWindow(); + if (window && window.gBrowser && window.gBrowser.selectedTab && + window.gBrowser.selectedTab.linkedBrowser) { + let browser = window.gBrowser.selectedTab.linkedBrowser; + + // Get recent tab transition data to update if any. + let prevData = this.getAndForgetRecentTabTransitionData(browser); + + let newData = Object.assign( + {time: Date.now()}, + prevData, + tabTransitionData + ); + this.recentTabTransitionData.set(browser, newData); + } + }, + + /** + * Retrieve recent data related to a recent user interaction give a + * given tab's linkedBrowser (only if is is more recent than the + * `RECENT_DATA_THRESHOLD`). + * + * NOTE: this method is used to retrieve the tab transition data + * collected when one of the `onCommitted`, `onHistoryStateUpdated` + * or `onReferenceFragmentUpdated` events has been received. + * + * @param {XULBrowserElement} browser + * @returns {object} + */ + getAndForgetRecentTabTransitionData(browser) { + let data = this.recentTabTransitionData.get(browser); + this.recentTabTransitionData.delete(browser); + + // Return an empty object if there isn't any tab transition data + // or if it's less recent than RECENT_DATA_THRESHOLD. + if (!data || (data.time - Date.now()) > RECENT_DATA_THRESHOLD) { + return {}; + } + + return data; + }, + + /** + * Receive messages from the WebNavigationContent.js framescript + * over message manager events. + */ + receiveMessage({name, data, target}) { + switch (name) { + case "Extension:StateChange": + this.onStateChange(target, data); + break; + + case "Extension:DocumentChange": + this.onDocumentChange(target, data); + break; + + case "Extension:HistoryChange": + this.onHistoryChange(target, data); + break; + + case "Extension:DOMContentLoaded": + this.onLoad(target, data); + break; + + case "Content:Click": + this.onContentClick(target, data); + break; + } + }, + + onContentClick(target, data) { + // We are interested only on clicks to links which are not "add to bookmark" commands + if (data.href && !data.bookmark) { + let ownerWin = target.ownerDocument.defaultView; + let where = ownerWin.whereToOpenLink(data); + if (where == "current") { + this.setRecentTabTransitionData({link: true}); + } + } + }, + + onStateChange(browser, data) { + let stateFlags = data.stateFlags; + if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) { + let url = data.requestURL; + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + this.fire("onBeforeNavigate", browser, data, {url}); + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + if (Components.isSuccessCode(data.status)) { + this.fire("onCompleted", browser, data, {url}); + } else { + let error = `Error code ${data.status}`; + this.fire("onErrorOccurred", browser, data, {error, url}); + } + } + } + }, + + onDocumentChange(browser, data) { + let extra = { + url: data.location, + // Transition data which is coming from the content process. + frameTransitionData: data.frameTransitionData, + tabTransitionData: this.getAndForgetRecentTabTransitionData(browser), + }; + + this.fire("onCommitted", browser, data, extra); + }, + + onHistoryChange(browser, data) { + let extra = { + url: data.location, + // Transition data which is coming from the content process. + frameTransitionData: data.frameTransitionData, + tabTransitionData: this.getAndForgetRecentTabTransitionData(browser), + }; + + if (data.isReferenceFragmentUpdated) { + this.fire("onReferenceFragmentUpdated", browser, data, extra); + } else if (data.isHistoryStateUpdated) { + this.fire("onHistoryStateUpdated", browser, data, extra); + } + }, + + onLoad(browser, data) { + this.fire("onDOMContentLoaded", browser, data, {url: data.url}); + }, + + fire(type, browser, data, extra) { + let listeners = this.listeners.get(type); + if (!listeners) { + return; + } + + let details = { + browser, + windowId: data.windowId, + }; + + if (data.parentWindowId) { + details.parentWindowId = data.parentWindowId; + } + + for (let prop in extra) { + details[prop] = extra[prop]; + } + + for (let [listener, filters] of listeners) { + // Call the listener if the listener has no filter or if its filter matches. + if (!filters || filters.matches(extra.url)) { + listener(details); + } + } + }, +}; + +const EVENTS = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + // "onCreatedNavigationTarget", +]; + +var WebNavigation = {}; + +for (let event of EVENTS) { + WebNavigation[event] = { + addListener: Manager.addListener.bind(Manager, event), + removeListener: Manager.removeListener.bind(Manager, event), + }; +} diff --git a/toolkit/modules/addons/WebNavigationContent.js b/toolkit/modules/addons/WebNavigationContent.js new file mode 100644 index 000000000..cea4a97b3 --- /dev/null +++ b/toolkit/modules/addons/WebNavigationContent.js @@ -0,0 +1,272 @@ +"use strict"; + +/* globals docShell */ + +var Ci = Components.interfaces; + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames", + "resource://gre/modules/WebNavigationFrames.jsm"); + +function loadListener(event) { + let document = event.target; + let window = document.defaultView; + let url = document.documentURI; + let windowId = WebNavigationFrames.getWindowId(window); + let parentWindowId = WebNavigationFrames.getParentWindowId(window); + sendAsyncMessage("Extension:DOMContentLoaded", {windowId, parentWindowId, url}); +} + +addEventListener("DOMContentLoaded", loadListener); +addMessageListener("Extension:DisableWebNavigation", () => { + removeEventListener("DOMContentLoaded", loadListener); +}); + +var FormSubmitListener = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, + Ci.nsIFormSubmitObserver, + Ci.nsISupportsWeakReference]), + init() { + this.formSubmitWindows = new WeakSet(); + Services.obs.addObserver(FormSubmitListener, "earlyformsubmit", false); + }, + + uninit() { + Services.obs.removeObserver(FormSubmitListener, "earlyformsubmit", false); + this.formSubmitWindows = new WeakSet(); + }, + + notify: function(form, window, actionURI) { + try { + this.formSubmitWindows.add(window); + } catch (e) { + Cu.reportError("Error in FormSubmitListener.notify"); + } + }, + + hasAndForget: function(window) { + let has = this.formSubmitWindows.has(window); + this.formSubmitWindows.delete(window); + return has; + }, +}; + +var WebProgressListener = { + init: function() { + // This WeakMap (DOMWindow -> nsIURI) keeps track of the pathname and hash + // of the previous location for all the existent docShells. + this.previousURIMap = new WeakMap(); + + // Populate the above previousURIMap by iterating over the docShells tree. + for (let currentDocShell of WebNavigationFrames.iterateDocShellTree(docShell)) { + let win = currentDocShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + let {currentURI} = currentDocShell.QueryInterface(Ci.nsIWebNavigation); + + this.previousURIMap.set(win, currentURI); + } + + // This WeakSet of DOMWindows keeps track of the attempted refresh. + this.refreshAttemptedDOMWindows = new WeakSet(); + + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW | + Ci.nsIWebProgress.NOTIFY_REFRESH | + Ci.nsIWebProgress.NOTIFY_LOCATION); + }, + + uninit() { + if (!docShell) { + return; + } + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.removeProgressListener(this); + }, + + onRefreshAttempted: function onRefreshAttempted(webProgress, URI, delay, sameURI) { + this.refreshAttemptedDOMWindows.add(webProgress.DOMWindow); + + // If this function doesn't return true, the attempted refresh will be blocked. + return true; + }, + + onStateChange: function onStateChange(webProgress, request, stateFlags, status) { + let {originalURI, URI: locationURI} = request.QueryInterface(Ci.nsIChannel); + + // Prevents "about", "chrome", "resource" and "moz-extension" URI schemes to be + // reported with the resolved "file" or "jar" URIs. (see Bug 1246125 for rationale) + if (locationURI.schemeIs("file") || locationURI.schemeIs("jar")) { + let shouldUseOriginalURI = originalURI.schemeIs("about") || + originalURI.schemeIs("chrome") || + originalURI.schemeIs("resource") || + originalURI.schemeIs("moz-extension"); + + locationURI = shouldUseOriginalURI ? originalURI : locationURI; + } + + this.sendStateChange({webProgress, locationURI, stateFlags, status}); + + // Based on the docs of the webNavigation.onCommitted event, it should be raised when: + // "The document might still be downloading, but at least part of + // the document has been received" + // and for some reason we don't fire onLocationChange for the + // initial navigation of a sub-frame. + // For the above two reasons, when the navigation event is related to + // a sub-frame we process the document change here and + // then send an "Extension:DocumentChange" message to the main process, + // where it will be turned into a webNavigation.onCommitted event. + // (see Bug 1264936 and Bug 125662 for rationale) + if ((webProgress.DOMWindow.top != webProgress.DOMWindow) && + (stateFlags & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT)) { + this.sendDocumentChange({webProgress, locationURI, request}); + } + }, + + onLocationChange: function onLocationChange(webProgress, request, locationURI, flags) { + let {DOMWindow} = webProgress; + + // Get the previous URI loaded in the DOMWindow. + let previousURI = this.previousURIMap.get(DOMWindow); + + // Update the URI in the map with the new locationURI. + this.previousURIMap.set(DOMWindow, locationURI); + + let isSameDocument = (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT); + + // When a frame navigation doesn't change the current loaded document + // (which can be due to history.pushState/replaceState or to a changed hash in the url), + // it is reported only to the onLocationChange, for this reason + // we process the history change here and then we are going to send + // an "Extension:HistoryChange" to the main process, where it will be turned + // into a webNavigation.onHistoryStateUpdated/onReferenceFragmentUpdated event. + if (isSameDocument) { + this.sendHistoryChange({webProgress, previousURI, locationURI, request}); + } else if (webProgress.DOMWindow.top == webProgress.DOMWindow) { + // We have to catch the document changes from top level frames here, + // where we can detect the "server redirect" transition. + // (see Bug 1264936 and Bug 125662 for rationale) + this.sendDocumentChange({webProgress, locationURI, request}); + } + }, + + sendStateChange({webProgress, locationURI, stateFlags, status}) { + let data = { + requestURL: locationURI.spec, + windowId: webProgress.DOMWindowID, + parentWindowId: WebNavigationFrames.getParentWindowId(webProgress.DOMWindow), + status, + stateFlags, + }; + + sendAsyncMessage("Extension:StateChange", data); + }, + + sendDocumentChange({webProgress, locationURI, request}) { + let {loadType, DOMWindow} = webProgress; + let frameTransitionData = this.getFrameTransitionData({loadType, request, DOMWindow}); + + let data = { + frameTransitionData, + location: locationURI ? locationURI.spec : "", + windowId: webProgress.DOMWindowID, + parentWindowId: WebNavigationFrames.getParentWindowId(webProgress.DOMWindow), + }; + + sendAsyncMessage("Extension:DocumentChange", data); + }, + + sendHistoryChange({webProgress, previousURI, locationURI, request}) { + let {loadType, DOMWindow} = webProgress; + + let isHistoryStateUpdated = false; + let isReferenceFragmentUpdated = false; + + let pathChanged = !(previousURI && locationURI.equalsExceptRef(previousURI)); + let hashChanged = !(previousURI && previousURI.ref == locationURI.ref); + + // When the location changes but the document is the same: + // - path not changed and hash changed -> |onReferenceFragmentUpdated| + // (even if it changed using |history.pushState|) + // - path not changed and hash not changed -> |onHistoryStateUpdated| + // (only if it changes using |history.pushState|) + // - path changed -> |onHistoryStateUpdated| + + if (!pathChanged && hashChanged) { + isReferenceFragmentUpdated = true; + } else if (loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE) { + isHistoryStateUpdated = true; + } else if (loadType & Ci.nsIDocShell.LOAD_CMD_HISTORY) { + isHistoryStateUpdated = true; + } + + if (isHistoryStateUpdated || isReferenceFragmentUpdated) { + let frameTransitionData = this.getFrameTransitionData({loadType, request, DOMWindow}); + + let data = { + frameTransitionData, + isHistoryStateUpdated, isReferenceFragmentUpdated, + location: locationURI ? locationURI.spec : "", + windowId: webProgress.DOMWindowID, + parentWindowId: WebNavigationFrames.getParentWindowId(webProgress.DOMWindow), + }; + + sendAsyncMessage("Extension:HistoryChange", data); + } + }, + + getFrameTransitionData({loadType, request, DOMWindow}) { + let frameTransitionData = {}; + + if (loadType & Ci.nsIDocShell.LOAD_CMD_HISTORY) { + frameTransitionData.forward_back = true; + } + + if (loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD) { + frameTransitionData.reload = true; + } + + if (request instanceof Ci.nsIChannel) { + if (request.loadInfo.redirectChain.length) { + frameTransitionData.server_redirect = true; + } + } + + if (FormSubmitListener.hasAndForget(DOMWindow)) { + frameTransitionData.form_submit = true; + } + + if (this.refreshAttemptedDOMWindows.has(DOMWindow)) { + this.refreshAttemptedDOMWindows.delete(DOMWindow); + frameTransitionData.client_redirect = true; + } + + return frameTransitionData; + }, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsIWebProgressListener2, + Ci.nsISupportsWeakReference, + ]), +}; + +var disabled = false; +WebProgressListener.init(); +FormSubmitListener.init(); +addEventListener("unload", () => { + if (!disabled) { + disabled = true; + WebProgressListener.uninit(); + FormSubmitListener.uninit(); + } +}); +addMessageListener("Extension:DisableWebNavigation", () => { + if (!disabled) { + disabled = true; + WebProgressListener.uninit(); + FormSubmitListener.uninit(); + } +}); diff --git a/toolkit/modules/addons/WebNavigationFrames.jsm b/toolkit/modules/addons/WebNavigationFrames.jsm new file mode 100644 index 000000000..5efa6d104 --- /dev/null +++ b/toolkit/modules/addons/WebNavigationFrames.jsm @@ -0,0 +1,142 @@ +/* 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 = ["WebNavigationFrames"]; + +var Ci = Components.interfaces; + +/* exported WebNavigationFrames */ + +function getWindowId(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; +} + +function getParentWindowId(window) { + return getWindowId(window.parent); +} + +/** + * Retrieve the DOMWindow associated to the docShell passed as parameter. + * + * @param {nsIDocShell} docShell - the docShell that we want to get the DOMWindow from. + * @returns {nsIDOMWindow} - the DOMWindow associated to the docShell. + */ +function docShellToWindow(docShell) { + return docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); +} + +/** + * The FrameDetail object which represents a frame in WebExtensions APIs. + * + * @typedef {Object} FrameDetail + * @inner + * @property {number} windowId - Represents the numeric id which identify the frame in its tab. + * @property {number} parentWindowId - Represents the numeric id which identify the parent frame. + * @property {string} url - Represents the current location URL loaded in the frame. + * @property {boolean} errorOccurred - Indicates whether an error is occurred during the last load + * happened on this frame (NOT YET SUPPORTED). + */ + +/** + * Convert a docShell object into its internal FrameDetail representation. + * + * @param {nsIDocShell} docShell - the docShell object to be converted into a FrameDetail JSON object. + * @returns {FrameDetail} the FrameDetail JSON object which represents the docShell. + */ +function convertDocShellToFrameDetail(docShell) { + let window = docShellToWindow(docShell); + + return { + windowId: getWindowId(window), + parentWindowId: getParentWindowId(window), + url: window.location.href, + }; +} + +/** + * A generator function which iterates over a docShell tree, given a root docShell. + * + * @param {nsIDocShell} docShell - the root docShell object + * @returns {Iterator<DocShell>} the FrameDetail JSON object which represents the docShell. + */ +function* iterateDocShellTree(docShell) { + let docShellsEnum = docShell.getDocShellEnumerator( + Ci.nsIDocShellTreeItem.typeContent, + Ci.nsIDocShell.ENUMERATE_FORWARDS + ); + + while (docShellsEnum.hasMoreElements()) { + yield docShellsEnum.getNext(); + } + + return null; +} + +/** + * Returns the frame ID of the given window. If the window is the + * top-level content window, its frame ID is 0. Otherwise, its frame ID + * is its outer window ID. + * + * @param {Window} window - The window to retrieve the frame ID for. + * @returns {number} + */ +function getFrameId(window) { + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + + if (!docShell.sameTypeParent) { + return 0; + } + + let utils = window.getInterface(Ci.nsIDOMWindowUtils); + return utils.outerWindowID; +} + +/** + * Search for a frame starting from the passed root docShell and + * convert it to its related frame detail representation. + * + * @param {number} frameId - the frame ID of the frame to retrieve, as + * described in getFrameId. + * @param {nsIDocShell} rootDocShell - the root docShell object + * @returns {nsIDocShell?} the docShell with the given frameId, or null + * if no match. + */ +function findDocShell(frameId, rootDocShell) { + for (let docShell of iterateDocShellTree(rootDocShell)) { + if (frameId == getFrameId(docShellToWindow(docShell))) { + return docShell; + } + } + + return null; +} + +var WebNavigationFrames = { + iterateDocShellTree, + + findDocShell, + + getFrame(docShell, frameId) { + let result = findDocShell(frameId, docShell); + if (result) { + return convertDocShellToFrameDetail(result); + } + return null; + }, + + getFrameId, + + getAllFrames(docShell) { + return Array.from(iterateDocShellTree(docShell), convertDocShellToFrameDetail); + }, + + getWindowId, + getParentWindowId, +}; diff --git a/toolkit/modules/addons/WebRequest.jsm b/toolkit/modules/addons/WebRequest.jsm new file mode 100644 index 000000000..c720dae5d --- /dev/null +++ b/toolkit/modules/addons/WebRequest.jsm @@ -0,0 +1,918 @@ +/* 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 */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +const {nsIHttpActivityObserver, nsISocketTransport} = Ci; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionUtils", + "resource://gre/modules/ExtensionUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebRequestCommon", + "resource://gre/modules/WebRequestCommon.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebRequestUpload", + "resource://gre/modules/WebRequestUpload.jsm"); + +XPCOMUtils.defineLazyGetter(this, "ExtensionError", () => ExtensionUtils.ExtensionError); + +function attachToChannel(channel, key, data) { + if (channel instanceof Ci.nsIWritablePropertyBag2) { + let wrapper = {wrappedJSObject: data}; + channel.setPropertyAsInterface(key, wrapper); + } + return data; +} + +function extractFromChannel(channel, key) { + if (channel instanceof Ci.nsIPropertyBag2 && channel.hasKey(key)) { + let data = channel.get(key); + return data && data.wrappedJSObject; + } + return null; +} + +function getData(channel) { + const key = "mozilla.webRequest.data"; + return extractFromChannel(channel, key) || attachToChannel(channel, key, {}); +} + +var RequestId = { + count: 1, + create(channel = null) { + let id = (this.count++).toString(); + if (channel) { + getData(channel).requestId = id; + } + return id; + }, + + get(channel) { + return channel && getData(channel).requestId || this.create(channel); + }, +}; + +function runLater(job) { + Services.tm.currentThread.dispatch(job, Ci.nsIEventTarget.DISPATCH_NORMAL); +} + +function parseFilter(filter) { + if (!filter) { + filter = {}; + } + + // FIXME: Support windowId filtering. + return {urls: filter.urls || null, types: filter.types || null}; +} + +function parseExtra(extra, allowed = []) { + if (extra) { + for (let ex of extra) { + if (allowed.indexOf(ex) == -1) { + throw new ExtensionError(`Invalid option ${ex}`); + } + } + } + + let result = {}; + for (let al of allowed) { + if (extra && extra.indexOf(al) != -1) { + result[al] = true; + } + } + return result; +} + +function mergeStatus(data, channel, event) { + try { + data.statusCode = channel.responseStatus; + let statusText = channel.responseStatusText; + let maj = {}; + let min = {}; + channel.QueryInterface(Ci.nsIHttpChannelInternal).getResponseVersion(maj, min); + data.statusLine = `HTTP/${maj.value}.${min.value} ${data.statusCode} ${statusText}`; + } catch (e) { + // NS_ERROR_NOT_AVAILABLE might be thrown if it's an internal redirect, happening before + // any actual HTTP traffic. Otherwise, let's report. + if (event !== "onRedirect" || e.result !== Cr.NS_ERROR_NOT_AVAILABLE) { + Cu.reportError(`webRequest Error: ${e} trying to merge status in ${event}@${channel.name}`); + } + } +} + +function isThenable(value) { + return value && typeof value === "object" && typeof value.then === "function"; +} + +class HeaderChanger { + constructor(channel) { + this.channel = channel; + + this.originalHeaders = new Map(); + this.visitHeaders((name, value) => { + this.originalHeaders.set(name.toLowerCase(), value); + }); + } + + toArray() { + return Array.from(this.originalHeaders, + ([name, value]) => ({name, value})); + } + + 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) { + 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. + for (let name of this.originalHeaders.keys()) { + if (!newHeaders.has(name)) { + this.setHeader(name, ""); + } + } + + // Set new or changed headers. + for (let {name, value, binaryValue} of headers) { + if (binaryValue) { + value = String.fromCharCode(...binaryValue); + } + if (value !== this.originalHeaders.get(name.toLowerCase())) { + this.setHeader(name, value); + } + } + } +} + +class RequestHeaderChanger extends HeaderChanger { + setHeader(name, value) { + try { + this.channel.setRequestHeader(name, value, false); + } catch (e) { + Cu.reportError(new Error(`Error setting request header ${name}: ${e}`)); + } + } + + visitHeaders(visitor) { + if (this.channel instanceof Ci.nsIHttpChannel) { + this.channel.visitRequestHeaders(visitor); + } + } +} + +class ResponseHeaderChanger extends HeaderChanger { + setHeader(name, value) { + try { + if (name.toLowerCase() === "content-type" && value) { + // The Content-Type header value can't be modified, so we + // set the channel's content type directly, instead, and + // record that we made the change for the sake of + // subsequent observers. + this.channel.contentType = value; + + getData(this.channel).contentType = value; + } else { + this.channel.setResponseHeader(name, value, false); + } + } catch (e) { + Cu.reportError(new Error(`Error setting response header ${name}: ${e}`)); + } + } + + visitHeaders(visitor) { + if (this.channel instanceof Ci.nsIHttpChannel) { + try { + this.channel.visitResponseHeaders((name, value) => { + if (name.toLowerCase() === "content-type") { + value = getData(this.channel).contentType || value; + } + + visitor(name, value); + }); + } catch (e) { + // Throws if response headers aren't available yet. + } + } + } +} + +var HttpObserverManager; + +var ContentPolicyManager = { + policyData: new Map(), + policies: new Map(), + idMap: new Map(), + nextId: 0, + + init() { + Services.ppmm.initialProcessData.webRequestContentPolicies = this.policyData; + + Services.ppmm.addMessageListener("WebRequest:ShouldLoad", this); + Services.mm.addMessageListener("WebRequest:ShouldLoad", this); + }, + + receiveMessage(msg) { + let browser = msg.target instanceof Ci.nsIDOMXULElement ? msg.target : null; + + let requestId = RequestId.create(); + for (let id of msg.data.ids) { + let callback = this.policies.get(id); + if (!callback) { + // It's possible that this listener has been removed and the + // child hasn't learned yet. + continue; + } + let response = null; + let listenerKind = "onStop"; + let data = Object.assign({requestId, browser}, msg.data); + delete data.ids; + try { + response = callback(data); + if (response) { + if (response.cancel) { + listenerKind = "onError"; + data.error = "NS_ERROR_ABORT"; + return {cancel: true}; + } + // FIXME: Need to handle redirection here (for non-HTTP URIs only) + } + } catch (e) { + Cu.reportError(e); + } finally { + runLater(() => this.runChannelListener(listenerKind, data)); + } + } + + return {}; + }, + + runChannelListener(kind, data) { + let listeners = HttpObserverManager.listeners[kind]; + let uri = BrowserUtils.makeURI(data.url); + let policyType = data.type; + for (let [callback, opts] of listeners.entries()) { + if (!HttpObserverManager.shouldRunListener(policyType, uri, opts.filter)) { + continue; + } + callback(data); + } + }, + + addListener(callback, opts) { + // Clone opts, since we're going to modify them for IPC. + opts = Object.assign({}, opts); + let id = this.nextId++; + opts.id = id; + if (opts.filter.urls) { + opts.filter = Object.assign({}, opts.filter); + opts.filter.urls = opts.filter.urls.serialize(); + } + Services.ppmm.broadcastAsyncMessage("WebRequest:AddContentPolicy", opts); + + this.policyData.set(id, opts); + + this.policies.set(id, callback); + this.idMap.set(callback, id); + }, + + removeListener(callback) { + let id = this.idMap.get(callback); + Services.ppmm.broadcastAsyncMessage("WebRequest:RemoveContentPolicy", {id}); + + this.policyData.delete(id); + this.idMap.delete(callback); + this.policies.delete(id); + }, +}; +ContentPolicyManager.init(); + +function StartStopListener(manager, loadContext) { + this.manager = manager; + this.loadContext = loadContext; + this.orig = null; +} + +StartStopListener.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIRequestObserver, + Ci.nsIStreamListener]), + + onStartRequest: function(request, context) { + this.manager.onStartRequest(request, this.loadContext); + this.orig.onStartRequest(request, context); + }, + + onStopRequest(request, context, statusCode) { + try { + this.orig.onStopRequest(request, context, statusCode); + } catch (e) { + Cu.reportError(e); + } + this.manager.onStopRequest(request, this.loadContext); + }, + + onDataAvailable(...args) { + return this.orig.onDataAvailable(...args); + }, +}; + +var ChannelEventSink = { + _classDescription: "WebRequest channel event sink", + _classID: Components.ID("115062f8-92f1-11e5-8b7f-080027b0f7ec"), + _contractID: "@mozilla.org/webrequest/channel-event-sink;1", + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIChannelEventSink, + Ci.nsIFactory]), + + init() { + Components.manager.QueryInterface(Ci.nsIComponentRegistrar) + .registerFactory(this._classID, this._classDescription, this._contractID, this); + }, + + register() { + let catMan = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager); + catMan.addCategoryEntry("net-channel-event-sinks", this._contractID, this._contractID, false, true); + }, + + unregister() { + let catMan = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager); + 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(outer, iid) { + if (outer) { + throw Cr.NS_ERROR_NO_AGGREGATION; + } + return this.QueryInterface(iid); + }, +}; + +ChannelEventSink.init(); + +HttpObserverManager = { + modifyInitialized: false, + examineInitialized: false, + redirectInitialized: false, + activityInitialized: false, + needTracing: false, + + listeners: { + opening: new Map(), + modify: new Map(), + afterModify: new Map(), + headersReceived: new Map(), + onRedirect: new Map(), + onStart: new Map(), + onError: new Map(), + onStop: new Map(), + }, + + get activityDistributor() { + return Cc["@mozilla.org/network/http-activity-distributor;1"].getService(Ci.nsIHttpActivityDistributor); + }, + + addOrRemove() { + let needModify = this.listeners.opening.size || this.listeners.modify.size || this.listeners.afterModify.size; + if (needModify && !this.modifyInitialized) { + this.modifyInitialized = true; + Services.obs.addObserver(this, "http-on-modify-request", false); + } else if (!needModify && this.modifyInitialized) { + this.modifyInitialized = false; + Services.obs.removeObserver(this, "http-on-modify-request"); + } + this.needTracing = this.listeners.onStart.size || + this.listeners.onError.size || + this.listeners.onStop.size; + + let needExamine = this.needTracing || + this.listeners.headersReceived.size; + + if (needExamine && !this.examineInitialized) { + this.examineInitialized = true; + Services.obs.addObserver(this, "http-on-examine-response", false); + Services.obs.addObserver(this, "http-on-examine-cached-response", false); + Services.obs.addObserver(this, "http-on-examine-merged-response", false); + } 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"); + } + + let needRedirect = this.listeners.onRedirect.size; + if (needRedirect && !this.redirectInitialized) { + this.redirectInitialized = true; + ChannelEventSink.register(); + } else if (!needRedirect && this.redirectInitialized) { + this.redirectInitialized = false; + ChannelEventSink.unregister(); + } + + let needActivity = this.listeners.onError.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(); + }, + + getLoadContext(channel) { + try { + return channel.QueryInterface(Ci.nsIChannel) + .notificationCallbacks + .getInterface(Components.interfaces.nsILoadContext); + } catch (e) { + try { + return channel.loadGroup + .notificationCallbacks + .getInterface(Components.interfaces.nsILoadContext); + } catch (e) { + return null; + } + } + }, + + observe(subject, topic, data) { + let channel = subject.QueryInterface(Ci.nsIHttpChannel); + switch (topic) { + case "http-on-modify-request": + let loadContext = this.getLoadContext(channel); + + this.runChannelListener(channel, loadContext, "opening"); + break; + case "http-on-examine-cached-response": + case "http-on-examine-merged-response": + getData(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(channel, activityType, activitySubtype /* , aTimestamp, aExtraSizeData, aExtraStringData */) { + let channelData = getData(channel); + let lastActivity = channelData.lastActivity || 0; + if (activitySubtype === nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE && + lastActivity && lastActivity !== this.GOOD_LAST_ACTIVITY) { + let loadContext = this.getLoadContext(channel); + if (!this.errorCheck(channel, loadContext, channelData)) { + this.runChannelListener(channel, loadContext, "onError", + {error: this.activityErrorsMap.get(lastActivity) || + `NS_ERROR_NET_UNKNOWN_${lastActivity}`}); + } + } else if (lastActivity !== this.GOOD_LAST_ACTIVITY && + lastActivity !== nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE) { + channelData.lastActivity = activitySubtype; + } + }, + + shouldRunListener(policyType, uri, filter) { + return WebRequestCommon.typeMatches(policyType, filter.types) && + WebRequestCommon.urlMatches(uri, filter.urls); + }, + + get resultsMap() { + delete this.resultsMap; + this.resultsMap = new Map(Object.keys(Cr).map(name => [Cr[name], name])); + return this.resultsMap; + }, + maybeError(channel, extraData = null, channelData = null) { + if (!(extraData && extraData.error)) { + if (!Components.isSuccessCode(channel.status)) { + extraData = {error: this.resultsMap.get(channel.status)}; + } + } + return extraData; + }, + errorCheck(channel, loadContext, channelData = null) { + let errorData = this.maybeError(channel, null, channelData); + if (errorData) { + this.runChannelListener(channel, loadContext, "onError", errorData); + } + return errorData; + }, + + /** + * Resumes the channel if it is currently suspended due to this + * listener. + * + * @param {nsIChannel} channel + * The channel to possibly suspend. + */ + maybeResume(channel) { + let data = getData(channel); + if (data.suspended) { + channel.resume(); + data.suspended = false; + } + }, + + /** + * Suspends the channel if it is not currently suspended due to this + * listener. Returns true if the channel was suspended as a result of + * this call. + * + * @param {nsIChannel} channel + * The channel to possibly suspend. + * @returns {boolean} + * True if this call resulted in the channel being suspended. + */ + maybeSuspend(channel) { + let data = getData(channel); + if (!data.suspended) { + channel.suspend(); + data.suspended = true; + return true; + } + }, + + getRequestData(channel, loadContext, policyType, extraData) { + let {loadInfo} = channel; + + let data = { + requestId: RequestId.get(channel), + url: channel.URI.spec, + method: channel.requestMethod, + browser: loadContext && loadContext.topFrameElement, + type: WebRequestCommon.typeForPolicyType(policyType), + fromCache: getData(channel).fromCache, + windowId: 0, + parentWindowId: 0, + }; + + if (loadInfo) { + let originPrincipal = loadInfo.triggeringPrincipal; + if (originPrincipal.URI) { + data.originUrl = originPrincipal.URI.spec; + } + + // If there is no loadingPrincipal, check that the request is not going to + // inherit a system principal. triggeringPrincipal is the context that + // initiated the load, but is not necessarily the principal that the + // request results in, only rely on that if no other principal is available. + let {isSystemPrincipal} = Services.scriptSecurityManager; + let isTopLevel = !loadInfo.loadingPrincipal && !!data.browser; + data.isSystemPrincipal = !isTopLevel && + isSystemPrincipal(loadInfo.loadingPrincipal || + loadInfo.principalToInherit || + loadInfo.triggeringPrincipal); + + if (loadInfo.frameOuterWindowID) { + Object.assign(data, { + windowId: loadInfo.frameOuterWindowID, + parentWindowId: loadInfo.outerWindowID, + }); + } else { + Object.assign(data, { + windowId: loadInfo.outerWindowID, + parentWindowId: loadInfo.parentOuterWindowID, + }); + } + } + + if (channel instanceof Ci.nsIHttpChannelInternal) { + try { + data.ip = channel.remoteAddress; + } catch (e) { + // The remoteAddress getter throws if the address is unavailable, + // but ip is an optional property so just ignore the exception. + } + } + + return Object.assign(data, extraData); + }, + + runChannelListener(channel, loadContext = null, kind, extraData = null) { + let handlerResults = []; + let requestHeaders; + let responseHeaders; + + try { + if (this.activityInitialized) { + let channelData = getData(channel); + if (kind === "onError") { + if (channelData.errorNotified) { + return; + } + channelData.errorNotified = true; + } else if (this.errorCheck(channel, loadContext, channelData)) { + return; + } + } + + let {loadInfo} = channel; + let policyType = (loadInfo ? loadInfo.externalContentPolicyType + : Ci.nsIContentPolicy.TYPE_OTHER); + + let includeStatus = (["headersReceived", "onRedirect", "onStart", "onStop"].includes(kind) && + channel instanceof Ci.nsIHttpChannel); + + let commonData = null; + let uri = channel.URI; + let requestBody; + for (let [callback, opts] of this.listeners[kind].entries()) { + if (!this.shouldRunListener(policyType, uri, opts.filter)) { + continue; + } + + if (!commonData) { + commonData = this.getRequestData(channel, loadContext, policyType, extraData); + } + let data = Object.assign({}, commonData); + + if (opts.requestHeaders) { + requestHeaders = requestHeaders || new RequestHeaderChanger(channel); + data.requestHeaders = requestHeaders.toArray(); + } + + if (opts.responseHeaders) { + responseHeaders = responseHeaders || new ResponseHeaderChanger(channel); + data.responseHeaders = responseHeaders.toArray(); + } + + if (opts.requestBody) { + requestBody = requestBody || WebRequestUpload.createRequestBody(channel); + data.requestBody = requestBody; + } + + if (includeStatus) { + mergeStatus(data, channel, kind); + } + + try { + let result = callback(data); + + if (result && typeof result === "object" && opts.blocking + && !AddonManagerPermissions.isHostPermitted(uri.host) + && (!loadInfo || !loadInfo.loadingPrincipal + || !loadInfo.loadingPrincipal.URI + || !AddonManagerPermissions.isHostPermitted(loadInfo.loadingPrincipal.URI.host))) { + handlerResults.push({opts, result}); + } + } catch (e) { + Cu.reportError(e); + } + } + } catch (e) { + Cu.reportError(e); + } + + return this.applyChanges(kind, channel, loadContext, handlerResults, + requestHeaders, responseHeaders); + }, + + applyChanges: Task.async(function* (kind, channel, loadContext, handlerResults, requestHeaders, responseHeaders) { + let asyncHandlers = handlerResults.filter(({result}) => isThenable(result)); + let isAsync = asyncHandlers.length > 0; + let shouldResume = false; + + try { + if (isAsync) { + shouldResume = this.maybeSuspend(channel); + + for (let value of asyncHandlers) { + try { + value.result = yield value.result; + } catch (e) { + Cu.reportError(e); + value.result = {}; + } + } + } + + for (let {opts, result} of handlerResults) { + if (!result || typeof result !== "object") { + continue; + } + + if (result.cancel) { + this.maybeResume(channel); + channel.cancel(Cr.NS_ERROR_ABORT); + + this.errorCheck(channel, loadContext); + return; + } + + if (result.redirectUrl) { + try { + this.maybeResume(channel); + + channel.redirectTo(BrowserUtils.makeURI(result.redirectUrl)); + return; + } catch (e) { + Cu.reportError(e); + } + } + + if (opts.requestHeaders && result.requestHeaders && requestHeaders) { + requestHeaders.applyChanges(result.requestHeaders); + } + + if (opts.responseHeaders && result.responseHeaders && responseHeaders) { + responseHeaders.applyChanges(result.responseHeaders); + } + } + + if (kind === "opening") { + yield this.runChannelListener(channel, loadContext, "modify"); + } else if (kind === "modify") { + yield this.runChannelListener(channel, loadContext, "afterModify"); + } + } catch (e) { + Cu.reportError(e); + } + + // Only resume the channel if it was suspended by this call. + if (shouldResume) { + this.maybeResume(channel); + } + }), + + examine(channel, topic, data) { + let loadContext = this.getLoadContext(channel); + + if (this.needTracing) { + // Check whether we've already added a listener to this channel, + // so we don't wind up chaining multiple listeners. + let channelData = getData(channel); + if (!channelData.hasListener && channel instanceof Ci.nsITraceableChannel) { + let responseStatus = channel.responseStatus; + // skip redirections, https://bugzilla.mozilla.org/show_bug.cgi?id=728901#c8 + if (responseStatus < 300 || responseStatus >= 400) { + let listener = new StartStopListener(this, loadContext); + let orig = channel.setNewListener(listener); + listener.orig = orig; + channelData.hasListener = true; + } + } + } + + this.runChannelListener(channel, loadContext, "headersReceived"); + }, + + onChannelReplaced(oldChannel, newChannel) { + this.runChannelListener(oldChannel, this.getLoadContext(oldChannel), + "onRedirect", {redirectUrl: newChannel.URI.spec}); + }, + + onStartRequest(channel, loadContext) { + this.runChannelListener(channel, loadContext, "onStart"); + }, + + onStopRequest(channel, loadContext) { + this.runChannelListener(channel, loadContext, "onStop"); + }, +}; + +var onBeforeRequest = { + get allowedOptions() { + delete this.allowedOptions; + this.allowedOptions = ["blocking"]; + if (!AppConstants.RELEASE_OR_BETA) { + this.allowedOptions.push("requestBody"); + } + return this.allowedOptions; + }, + addListener(callback, filter = null, opt_extraInfoSpec = null) { + let opts = parseExtra(opt_extraInfoSpec, this.allowedOptions); + opts.filter = parseFilter(filter); + ContentPolicyManager.addListener(callback, opts); + HttpObserverManager.addListener("opening", callback, opts); + }, + + removeListener(callback) { + HttpObserverManager.removeListener("opening", callback); + ContentPolicyManager.removeListener(callback); + }, +}; + +function HttpEvent(internalEvent, options) { + this.internalEvent = internalEvent; + this.options = options; +} + +HttpEvent.prototype = { + addListener(callback, filter = null, opt_extraInfoSpec = null) { + let opts = parseExtra(opt_extraInfoSpec, this.options); + opts.filter = parseFilter(filter); + HttpObserverManager.addListener(this.internalEvent, callback, opts); + }, + + removeListener(callback) { + HttpObserverManager.removeListener(this.internalEvent, callback); + }, +}; + +var onBeforeSendHeaders = new HttpEvent("modify", ["requestHeaders", "blocking"]); +var onSendHeaders = new HttpEvent("afterModify", ["requestHeaders"]); +var onHeadersReceived = new HttpEvent("headersReceived", ["blocking", "responseHeaders"]); +var onBeforeRedirect = new HttpEvent("onRedirect", ["responseHeaders"]); +var onResponseStarted = new HttpEvent("onStart", ["responseHeaders"]); +var onCompleted = new HttpEvent("onStop", ["responseHeaders"]); +var onErrorOccurred = new HttpEvent("onError"); + +var WebRequest = { + // http-on-modify observer for HTTP(S), content policy for the other protocols (notably, data:) + onBeforeRequest: onBeforeRequest, + + // http-on-modify observer. + onBeforeSendHeaders: onBeforeSendHeaders, + + // http-on-modify observer. + onSendHeaders: onSendHeaders, + + // http-on-examine-*observer. + onHeadersReceived: onHeadersReceived, + + // nsIChannelEventSink. + onBeforeRedirect: onBeforeRedirect, + + // OnStartRequest channel listener. + onResponseStarted: onResponseStarted, + + // OnStopRequest channel listener. + onCompleted: onCompleted, + + // nsIHttpActivityObserver. + onErrorOccurred: onErrorOccurred, +}; + +Services.ppmm.loadProcessScript("resource://gre/modules/WebRequestContent.js", true); diff --git a/toolkit/modules/addons/WebRequestCommon.jsm b/toolkit/modules/addons/WebRequestCommon.jsm new file mode 100644 index 000000000..9359f4ff7 --- /dev/null +++ b/toolkit/modules/addons/WebRequestCommon.jsm @@ -0,0 +1,57 @@ +/* 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 = ["WebRequestCommon"]; + +/* exported WebRequestCommon */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +var WebRequestCommon = { + typeForPolicyType(type) { + switch (type) { + case Ci.nsIContentPolicy.TYPE_DOCUMENT: return "main_frame"; + case Ci.nsIContentPolicy.TYPE_SUBDOCUMENT: return "sub_frame"; + case Ci.nsIContentPolicy.TYPE_STYLESHEET: return "stylesheet"; + case Ci.nsIContentPolicy.TYPE_SCRIPT: return "script"; + case Ci.nsIContentPolicy.TYPE_IMAGE: return "image"; + case Ci.nsIContentPolicy.TYPE_OBJECT: return "object"; + case Ci.nsIContentPolicy.TYPE_OBJECT_SUBREQUEST: return "object_subrequest"; + case Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST: return "xmlhttprequest"; + case Ci.nsIContentPolicy.TYPE_XBL: return "xbl"; + case Ci.nsIContentPolicy.TYPE_XSLT: return "xslt"; + case Ci.nsIContentPolicy.TYPE_PING: return "ping"; + case Ci.nsIContentPolicy.TYPE_BEACON: return "beacon"; + case Ci.nsIContentPolicy.TYPE_DTD: return "xml_dtd"; + case Ci.nsIContentPolicy.TYPE_FONT: return "font"; + case Ci.nsIContentPolicy.TYPE_MEDIA: return "media"; + case Ci.nsIContentPolicy.TYPE_WEBSOCKET: return "websocket"; + case Ci.nsIContentPolicy.TYPE_CSP_REPORT: return "csp_report"; + case Ci.nsIContentPolicy.TYPE_IMAGESET: return "imageset"; + case Ci.nsIContentPolicy.TYPE_WEB_MANIFEST: return "web_manifest"; + default: return "other"; + } + }, + + typeMatches(policyType, filterTypes) { + if (filterTypes === null) { + return true; + } + + return filterTypes.indexOf(this.typeForPolicyType(policyType)) != -1; + }, + + urlMatches(uri, urlFilter) { + if (urlFilter === null) { + return true; + } + + return urlFilter.matches(uri); + }, +}; diff --git a/toolkit/modules/addons/WebRequestContent.js b/toolkit/modules/addons/WebRequestContent.js new file mode 100644 index 000000000..219675e5b --- /dev/null +++ b/toolkit/modules/addons/WebRequestContent.js @@ -0,0 +1,192 @@ +/* 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"; + +var Ci = Components.interfaces; +var Cc = Components.classes; +var Cu = Components.utils; +var Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern", + "resource://gre/modules/MatchPattern.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebRequestCommon", + "resource://gre/modules/WebRequestCommon.jsm"); + +const IS_HTTP = /^https?:/; + +var ContentPolicy = { + _classDescription: "WebRequest content policy", + _classID: Components.ID("938e5d24-9ccc-4b55-883e-c252a41f7ce9"), + _contractID: "@mozilla.org/webrequest/policy;1", + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIContentPolicy, + Ci.nsIFactory, + Ci.nsISupportsWeakReference]), + + init() { + let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + registrar.registerFactory(this._classID, this._classDescription, this._contractID, this); + + this.contentPolicies = new Map(); + Services.cpmm.addMessageListener("WebRequest:AddContentPolicy", this); + Services.cpmm.addMessageListener("WebRequest:RemoveContentPolicy", this); + + if (initialProcessData && initialProcessData.webRequestContentPolicies) { + for (let data of initialProcessData.webRequestContentPolicies.values()) { + this.addContentPolicy(data); + } + } + }, + + addContentPolicy({id, blocking, filter}) { + if (this.contentPolicies.size == 0) { + this.register(); + } + if (filter.urls) { + filter.urls = new MatchPattern(filter.urls); + } + this.contentPolicies.set(id, {blocking, filter}); + }, + + receiveMessage(msg) { + switch (msg.name) { + case "WebRequest:AddContentPolicy": + this.addContentPolicy(msg.data); + break; + + case "WebRequest:RemoveContentPolicy": + this.contentPolicies.delete(msg.data.id); + if (this.contentPolicies.size == 0) { + this.unregister(); + } + break; + } + }, + + register() { + let catMan = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager); + catMan.addCategoryEntry("content-policy", this._contractID, this._contractID, false, true); + }, + + unregister() { + let catMan = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager); + catMan.deleteCategoryEntry("content-policy", this._contractID, false); + }, + + shouldLoad(policyType, contentLocation, requestOrigin, + node, mimeTypeGuess, extra, requestPrincipal) { + if (requestPrincipal && + Services.scriptSecurityManager.isSystemPrincipal(requestPrincipal)) { + return Ci.nsIContentPolicy.ACCEPT; + } + let url = contentLocation.spec; + if (IS_HTTP.test(url)) { + // We'll handle this in our parent process HTTP observer. + return Ci.nsIContentPolicy.ACCEPT; + } + + let block = false; + let ids = []; + for (let [id, {blocking, filter}] of this.contentPolicies.entries()) { + if (WebRequestCommon.typeMatches(policyType, filter.types) && + WebRequestCommon.urlMatches(contentLocation, filter.urls)) { + if (blocking) { + block = true; + } + ids.push(id); + } + } + + if (!ids.length) { + return Ci.nsIContentPolicy.ACCEPT; + } + + let windowId = 0; + let parentWindowId = -1; + let mm = Services.cpmm; + + function getWindowId(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + } + + if (policyType == Ci.nsIContentPolicy.TYPE_SUBDOCUMENT || + (node instanceof Ci.nsIDOMXULElement && node.localName == "browser")) { + // Chrome sets frameId to the ID of the sub-window. But when + // Firefox loads an iframe, it sets |node| to the <iframe> + // element, whose window is the parent window. We adopt the + // Chrome behavior here. + node = node.contentWindow; + } + + if (node) { + let window; + if (node instanceof Ci.nsIDOMWindow) { + window = node; + } else { + let doc; + if (node.ownerDocument) { + doc = node.ownerDocument; + } else { + doc = node; + } + window = doc.defaultView; + } + + windowId = getWindowId(window); + if (window.parent !== window) { + parentWindowId = getWindowId(window.parent); + } + + let ir = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIInterfaceRequestor); + try { + // If e10s is disabled, this throws NS_NOINTERFACE for closed tabs. + mm = ir.getInterface(Ci.nsIContentFrameMessageManager); + } catch (e) { + if (e.result != Cr.NS_NOINTERFACE) { + throw e; + } + } + } + + let data = {ids, + url, + type: WebRequestCommon.typeForPolicyType(policyType), + windowId, + parentWindowId}; + if (requestOrigin) { + data.originUrl = requestOrigin.spec; + } + if (block) { + let rval = mm.sendSyncMessage("WebRequest:ShouldLoad", data); + if (rval.length == 1 && rval[0].cancel) { + return Ci.nsIContentPolicy.REJECT; + } + } else { + mm.sendAsyncMessage("WebRequest:ShouldLoad", data); + } + + return Ci.nsIContentPolicy.ACCEPT; + }, + + shouldProcess: function(contentType, contentLocation, requestOrigin, insecNode, mimeType, extra) { + return Ci.nsIContentPolicy.ACCEPT; + }, + + createInstance: function(outer, iid) { + if (outer) { + throw Cr.NS_ERROR_NO_AGGREGATION; + } + return this.QueryInterface(iid); + }, +}; + +ContentPolicy.init(); diff --git a/toolkit/modules/addons/WebRequestUpload.jsm b/toolkit/modules/addons/WebRequestUpload.jsm new file mode 100644 index 000000000..789ce683f --- /dev/null +++ b/toolkit/modules/addons/WebRequestUpload.jsm @@ -0,0 +1,321 @@ +/* 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 = ["WebRequestUpload"]; + +/* exported WebRequestUpload */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +var WebRequestUpload; + +function rewind(stream) { + try { + if (stream instanceof Ci.nsISeekableStream) { + stream.seek(0, 0); + } + } catch (e) { + // It might be already closed, e.g. because of a previous error. + } +} + +function parseFormData(stream, channel, lenient = false) { + const BUFFER_SIZE = 8192; // Empirically it seemed a good compromise. + + let mimeStream = null; + + if (stream instanceof Ci.nsIMIMEInputStream && stream.data) { + mimeStream = stream; + stream = stream.data; + } + let multiplexStream = null; + if (stream instanceof Ci.nsIMultiplexInputStream) { + multiplexStream = stream; + } + + let touchedStreams = new Set(); + + function createTextStream(stream) { + let textStream = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(Ci.nsIConverterInputStream); + textStream.init(stream, "UTF-8", 0, lenient ? textStream.DEFAULT_REPLACEMENT_CHARACTER : 0); + if (stream instanceof Ci.nsISeekableStream) { + touchedStreams.add(stream); + } + return textStream; + } + + let streamIdx = 0; + function nextTextStream() { + for (; streamIdx < multiplexStream.count;) { + let currentStream = multiplexStream.getStream(streamIdx++); + if (currentStream instanceof Ci.nsIStringInputStream) { + touchedStreams.add(multiplexStream); + return createTextStream(currentStream); + } + } + return null; + } + + let textStream; + if (multiplexStream) { + textStream = nextTextStream(); + } else { + textStream = createTextStream(mimeStream || stream); + } + + if (!textStream) { + return null; + } + + function readString() { + if (textStream) { + let textBuffer = {}; + textStream.readString(BUFFER_SIZE, textBuffer); + return textBuffer.value; + } + return ""; + } + + function multiplexRead() { + let str = readString(); + if (!str) { + textStream = nextTextStream(); + if (textStream) { + str = multiplexRead(); + } + } + return str; + } + + let readChunk; + if (multiplexStream) { + readChunk = multiplexRead; + } else { + readChunk = readString; + } + + function appendFormData(formData, name, value) { + if (name in formData) { + formData[name].push(value); + } else { + formData[name] = [value]; + } + } + + function parseMultiPart(firstChunk, boundary = "") { + let formData = Object.create(null); + + if (!boundary) { + let match = firstChunk.match(/^--\S+/); + if (!match) { + return null; + } + boundary = match[0]; + } + + let unslash = (s) => s.replace(/\\"/g, '"'); + let tail = ""; + for (let chunk = firstChunk; + chunk || tail; + chunk = readChunk()) { + let parts; + if (chunk) { + chunk = tail + chunk; + parts = chunk.split(boundary); + tail = parts.pop(); + } else { + parts = [tail]; + tail = ""; + } + + for (let part of parts) { + let match = part.match(/^\r\nContent-Disposition: form-data; name="(.*)"\r\n(?:Content-Type: (\S+))?.*\r\n/i); + if (!match) { + continue; + } + let [header, name, contentType] = match; + if (contentType) { + let fileName; + // Since escaping inside Content-Disposition subfields is still poorly defined and buggy (see Bug 136676), + // currently we always consider backslash-prefixed quotes as escaped even if that's not generally true + // (i.e. in a field whose value actually ends with a backslash). + // Therefore in this edge case we may end coalescing name and filename, which is marginally better than + // potentially truncating the name field at the wrong point, at least from a XSS filter POV. + match = name.match(/^(.*[^\\])"; filename="(.*)/); + if (match) { + [, name, fileName] = match; + } + appendFormData(formData, unslash(name), fileName ? unslash(fileName) : ""); + } else { + appendFormData(formData, unslash(name), part.slice(header.length, -2)); + } + } + } + + return formData; + } + + function parseUrlEncoded(firstChunk) { + let formData = Object.create(null); + + let tail = ""; + for (let chunk = firstChunk; + chunk || tail; + chunk = readChunk()) { + let pairs; + if (chunk) { + chunk = tail + chunk.trim(); + pairs = chunk.split("&"); + tail = pairs.pop(); + } else { + chunk = tail; + tail = ""; + pairs = [chunk]; + } + for (let pair of pairs) { + let [name, value] = pair.replace(/\+/g, " ").split("=").map(decodeURIComponent); + appendFormData(formData, name, value); + } + } + + return formData; + } + + try { + let chunk = readChunk(); + + if (multiplexStream) { + touchedStreams.add(multiplexStream); + return parseMultiPart(chunk); + } + let contentType; + if (/^Content-Type:/i.test(chunk)) { + contentType = chunk.replace(/^Content-Type:\s*/i, ""); + chunk = chunk.slice(chunk.indexOf("\r\n\r\n") + 4); + } else { + try { + contentType = channel.getRequestHeader("Content-Type"); + } catch (e) { + Cu.reportError(e); + return null; + } + } + + let match = contentType.match(/^(?:multipart\/form-data;\s*boundary=(\S*)|application\/x-www-form-urlencoded\s)/i); + if (match) { + let boundary = match[1]; + if (boundary) { + return parseMultiPart(chunk, boundary); + } + return parseUrlEncoded(chunk); + } + } finally { + for (let stream of touchedStreams) { + rewind(stream); + } + } + + return null; +} + +function createFormData(stream, channel) { + try { + rewind(stream); + return parseFormData(stream, channel); + } catch (e) { + Cu.reportError(e); + } finally { + rewind(stream); + } + return null; +} + +function convertRawData(outerStream) { + let raw = []; + let totalBytes = 0; + + // Here we read the stream up to WebRequestUpload.MAX_RAW_BYTES, returning false if we had to truncate the result. + function readAll(stream) { + let unbuffered = stream.unbufferedStream || stream; + if (unbuffered instanceof Ci.nsIFileInputStream) { + raw.push({file: "<file>"}); // Full paths not supported yet for naked files (follow up bug) + return true; + } + rewind(stream); + + let binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(Ci.nsIBinaryInputStream); + binaryStream.setInputStream(stream); + const MAX_BYTES = WebRequestUpload.MAX_RAW_BYTES; + try { + for (let available; (available = binaryStream.available());) { + let size = Math.min(MAX_BYTES - totalBytes, available); + let bytes = new ArrayBuffer(size); + binaryStream.readArrayBuffer(size, bytes); + let chunk = {bytes}; + raw.push(chunk); + totalBytes += size; + + if (totalBytes >= MAX_BYTES) { + if (size < available) { + chunk.truncated = true; + chunk.originalSize = available; + return false; + } + break; + } + } + } finally { + rewind(stream); + } + return true; + } + + let unbuffered = outerStream; + if (outerStream instanceof Ci.nsIStreamBufferAccess) { + unbuffered = outerStream.unbufferedStream; + } + + if (unbuffered instanceof Ci.nsIMultiplexInputStream) { + for (let i = 0, count = unbuffered.count; i < count; i++) { + if (!readAll(unbuffered.getStream(i))) { + break; + } + } + } else { + readAll(outerStream); + } + + return raw; +} + +WebRequestUpload = { + createRequestBody(channel) { + let requestBody = null; + if (channel instanceof Ci.nsIUploadChannel && channel.uploadStream) { + try { + let stream = channel.uploadStream.QueryInterface(Ci.nsISeekableStream); + let formData = createFormData(stream, channel); + if (formData) { + requestBody = {formData}; + } else { + requestBody = {raw: convertRawData(stream), lenientFormData: createFormData(stream, channel, true)}; + } + } catch (e) { + Cu.reportError(e); + requestBody = {error: e.message || String(e)}; + } + requestBody = Object.freeze(requestBody); + } + return requestBody; + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter(WebRequestUpload, "MAX_RAW_BYTES", "webextensions.webRequest.requestBodyMaxRawBytes"); |