summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/addons
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/modules/addons')
-rw-r--r--toolkit/modules/addons/.eslintrc.js15
-rw-r--r--toolkit/modules/addons/MatchPattern.jsm352
-rw-r--r--toolkit/modules/addons/WebNavigation.jsm370
-rw-r--r--toolkit/modules/addons/WebNavigationContent.js272
-rw-r--r--toolkit/modules/addons/WebNavigationFrames.jsm142
-rw-r--r--toolkit/modules/addons/WebRequest.jsm918
-rw-r--r--toolkit/modules/addons/WebRequestCommon.jsm57
-rw-r--r--toolkit/modules/addons/WebRequestContent.js192
-rw-r--r--toolkit/modules/addons/WebRequestUpload.jsm321
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");