/* * This file is part of TbSync, contributed by John Bieling. * * 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/. * * Limitations: * ============ * - no real event support (cannot add eventlisteners) * - send only supports string body * - onprogress not supported * - readyState 2 & 3 not supported * * Note about HttpRequest.open(method, url, async, username, password): * ============================================================================ * If an Authorization header is specified, HttpRequest will use the * given header. * * If no Authorization header is specified, but a username, HttpRequest * will delegate the authentication process to nsIHttpChannel. If a password is * specified as well, it will be used for authentication. If no password is * specified, it will call the passwordCallback(username, realm, host) callback to * request a password for the given username, host and realm send back from * the server (in the WWW-Authenticate header). * */ "use strict"; var EXPORTED_SYMBOLS = ["HttpRequest"]; var bug669675 = []; var containers = []; var sandboxes = {}; var Services = globalThis.Services || ChromeUtils.import( "resource://gre/modules/Services.jsm" ).Services; var HttpRequest = class { constructor() { // a private object to store xhr related properties this._xhr = {}; // HttpRequest supports two methods to receive data, using the // streamLoader seems to be the more modern approach. // BUT in order to overide MimeType, we need to call onStartRequest this._xhr.useStreamLoader = false; this._xhr.responseAsBase64 = false; this._xhr.loadFlags = Components.interfaces.nsIRequest.LOAD_BYPASS_CACHE; this._xhr.headers = {}; this._xhr.readyState = 0; this._xhr.responseStatus = null; this._xhr.responseStatusText = null; this._xhr.responseText = null; this._xhr.httpchannel = null; this._xhr.method = null; this._xhr.uri = null; this._xhr.permanentlyRedirectedUrl = null; this._xhr.username = ""; this._xhr.password = ""; this._xhr.overrideMimeType = null; this._xhr.mozAnon = false; this._xhr.mozBackgroundRequest = false; this._xhr.timeout = 0; this._xhr.redirectFlags = null; this._xhr.containerReset = false; this._xhr.containerRealm = "default"; this.onreadystatechange = function () {}; this.onerror = function () {}; this.onload = function () {}; this.ontimeout = function () {}; // Redirects are handled internally, this callback is just called to // inform the caller about the redirect. // Flags: (https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIChannelEventSink) // - REDIRECT_TEMPORARY = 1 << 0; // - REDIRECT_PERMANENT = 1 << 1; // - REDIRECT_INTERNAL = 1 << 2; // TB enforces a redirect from http -> https without having received a redirect, // but an STS header // - REDIRECT_STS_UPGRADE = 1 << 3; // This is a custom bit set by HttpRequest to indicate, that this redirect was missed by the nsIHttpChannel implementation // probably due to a CORS violation (like https -> http) // - REDIRECT_MISSED = 1 << 7; this.onredirect = function(flags, newUri) {}; // Whenever a WWW-Authenticate header has been parsed, this callback is // called to inform the caller about the found realm. this.realmCallback = function (username, realm, host) {}; // Whenever a channel needs authentication, but the caller has only provided a username // this callback is called to request the password. this.passwordCallback = function (username, realm, host) {return null}; var self = this; this.notificationCallbacks = { // nsIInterfaceRequestor getInterface : function(aIID) { if (aIID.equals(Components.interfaces.nsIAuthPrompt2)) { // implement a custom nsIAuthPrompt2 - needed for auto authorization if (!self._xhr.authPrompt) { self._xhr.authPrompt = new HttpRequestPrompt(self._xhr.username, self._xhr.password, self.passwordCallback, self.realmCallback); } return self._xhr.authPrompt; } else if (aIID.equals(Components.interfaces.nsIAuthPrompt)) { // implement a custom nsIAuthPrompt } else if (aIID.equals(Components.interfaces.nsIAuthPromptProvider)) { // implement a custom nsIAuthPromptProvider } else if (aIID.equals(Components.interfaces.nsIPrompt)) { // implement a custom nsIPrompt } else if (aIID.equals(Components.interfaces.nsIProgressEventSink)) { // implement a custom nsIProgressEventSink } else if (aIID.equals(Components.interfaces.nsIChannelEventSink)) { // implement a custom nsIChannelEventSink return self.redirect; } throw Components.results.NS_ERROR_NO_INTERFACE; }, }; this.redirect = { // nsIChannelEventSink implementation asyncOnChannelRedirect: function(aOldChannel, aNewChannel, aFlags, aCallback) { // Disallow redirects from https to http. if (aOldChannel.URI.scheme == "https" && aNewChannel.URI.scheme == "http") { // Using an unused error code according to https://developer.mozilla.org/en-US/docs/Mozilla/Errors. // REJECTED_REDIRECT_FROM_HTTPS_TO_HTTP' aCallback.onRedirectVerifyCallback(0x804B002F); return; } let uploadData; let uploadContent; if (aOldChannel instanceof Ci.nsIUploadChannel && aOldChannel instanceof Ci.nsIHttpChannel && aOldChannel.uploadStream) { uploadData = aOldChannel.uploadStream; uploadContent = aOldChannel.getRequestHeader("Content-Type"); } aNewChannel.QueryInterface(Ci.nsIHttpChannel); aOldChannel.QueryInterface(Ci.nsIHttpChannel); function copyHeader(aHdr) { try { let hdrValue = aOldChannel.getRequestHeader(aHdr); if (hdrValue) { aNewChannel.setRequestHeader(aHdr, hdrValue, false); } } catch (e) { if (e.code != Components.results.NS_ERROR_NOT_AVAILIBLE) { // The header could possibly not be available, ignore that // case but throw otherwise throw e; } } } // Copy manually added headers for (let header in self._xhr.headers) { if (self._xhr.headers.hasOwnProperty(header)) { copyHeader(header); } } prepHttpChannelUploadData( aNewChannel, aOldChannel.requestMethod, uploadData, uploadContent); self._xhr.redirectFlags = aFlags; if (aFlags & Ci.nsIChannelEventSink.REDIRECT_PERMANENT) { self._xhr.permanentlyRedirectedUrl = aNewChannel.URI.spec; } self.onredirect(aFlags, aNewChannel.URI); aCallback.onRedirectVerifyCallback(Components.results.NS_OK); } }; this.listener = { _buffer: [], //nsIStreamListener (aUseStreamLoader = false) onStartRequest: function(aRequest) { //Services.console.logStringMessage("[onStartRequest] " + aRequest.URI.spec); this._buffer = []; if (self._xhr.overrideMimeType) { aRequest.contentType = self._xhr.overrideMimeType; } }, onDataAvailable: function (aRequest, aInputStream, aOffset, aCount) { //Services.console.logStringMessage("[onDataAvailable] " + aRequest.URI.spec + " : " + aCount); let buffer = new ArrayBuffer(aCount); let stream = Components.classes["@mozilla.org/binaryinputstream;1"].createInstance(Components.interfaces.nsIBinaryInputStream); stream.setInputStream(aInputStream); stream.readArrayBuffer(aCount, buffer); // store the chunk this._buffer.push(Array.from(new Uint8Array(buffer))); }, onStopRequest: function(aRequest, aStatusCode) { //Services.console.logStringMessage("[onStopRequest] " + aRequest.URI.spec + " : " + aStatusCode); // combine all binary chunks to create a flat byte array; let combined = [].concat.apply([], this._buffer); let data = convertByteArray(combined, self.responseAsBase64); this.processResponse(aRequest.QueryInterface(Components.interfaces.nsIHttpChannel), aStatusCode, data); }, //nsIStreamLoaderObserver (aUseStreamLoader = true) onStreamComplete: function(aLoader, aContext, aStatus, aResultLength, aResult) { let result = convertByteArray(aResult, self.responseAsBase64); this.processResponse(aLoader.request.QueryInterface(Components.interfaces.nsIHttpChannel), aStatus, result); }, processResponse: function(aChannel, aStatus, aResult) { //Services.console.logStringMessage("[processResponse] " + aChannel.URI.spec + " : " + aStatus); // do not set any channal response data, before we know we failed // and before we know we do not have to rerun (due to bug 669675) let responseStatus = null; try { responseStatus = aChannel.responseStatus; } catch (ex) { switch (aStatus) { case Components.results.NS_ERROR_NET_TIMEOUT: self._xhr.httpchannel = aChannel; self._xhr.responseText = aResult; self._xhr.responseStatus = 0; self._xhr.responseStatusText = ""; self._xhr.readyState = 4; self.onreadystatechange(); self.ontimeout(); return; case Components.results.NS_BINDING_ABORTED: case 0x804B002F: //Custom error (REJECTED_REDIRECT_FROM_HTTPS_TO_HTTP') self._xhr.httpchannel = aChannel; self._xhr.responseText = aResult; self._xhr.responseStatus = 0; self._xhr.responseStatusText = ""; self._xhr.readyState = 0; self.onreadystatechange(); self.onerror(); return; case 0x805303F4: //NS_ERROR_DOM_BAD_URI // Error on Strict-Transport-Security induced Redirect http -> https (these do not even show up in the console) // The redirect has been done already and the channel is already the new channel. // Due to CORS violation, it cannot be fulfilled. if (self._xhr.redirectFlags && aChannel.URI.spec != self._xhr.uri.spec) { self._xhr.uri = aChannel.URI; self.send(self._xhr.data); return; } default: self._xhr.httpchannel = aChannel; self._xhr.responseText = aResult; self._xhr.responseStatus = 0; self._xhr.responseStatusText = ""; self._xhr.readyState = 4; self.onreadystatechange(); self.onerror(); return; } } // Usually redirects are handled internally, but any CORS violating request is // returning a 30x, if CORS is not allowed. This is not true for STS induced redirects (see above). if ([301,302,307,308].includes(responseStatus)) { // aChannel is still the old channel let redirected = self.getResponseHeader("location"); if (redirected && redirected != aChannel.URI.spec) { let flag = Ci.nsIChannelEventSink.REDIRECT_TEMPORARY; let uri = Services.io.newURI(redirected); if ([301,308].includes(responseStatus)) { flag = Ci.nsIChannelEventSink.REDIRECT_PERMANENT; self._xhr.permanentlyRedirectedUrl = uri.spec; } // inform caller about the redirect and set the REDIRECT_MISSED bit self.onredirect(flag | 0x80, uri); self._xhr.uri = uri; self.send(self._xhr.data); return; } } // mitigation for bug https://bugzilla.mozilla.org/show_bug.cgi?id=669675 // we need to check, if nsIHttpChannel was in charge of auth: // if there was no Authentication header provided by the user, but a username // nsIHttpChannel should have added one. Is there one? if ( (responseStatus == 401) && !self._xhr.mozAnon && !self.hasRequestHeader("Authorization") && // no user defined header, so nsIHttpChannel should have called the authPrompt self._xhr.username && // we can only add basic auth header if user self._xhr.password // and pass are present ) { // check the actual Authorization headers send let unauthenticated; try { let header = aChannel.getRequestHeader("Authorization"); unauthenticated = false; } catch (e) { unauthenticated = true; } if (unauthenticated) { if (!bug669675.includes(self._xhr.uri.spec)) { bug669675.push(self._xhr.uri.spec) console.log("Mitigation for bug 669675 for URL <"+self._xhr.uri.spec+"> (Once per URL per session)"); // rerun self.send(self._xhr.data); return; } else { console.log("Mitigation failed for URL <"+self._xhr.uri.spec+">"); } } } self._xhr.httpchannel = aChannel; self._xhr.responseText = aResult; self._xhr.responseStatus = responseStatus; self._xhr.responseStatusText = aChannel.responseStatusText; self._xhr.readyState = 4; self.onreadystatechange(); self.onload(); } }; } /** public **/ open(method, url, async = true, username = "", password = "") { this._xhr.method = method; try { this._xhr.uri = Services.io.newURI(url); } catch (e) { Components.utils.reportError(e); throw new Error("HttpRequest: Invalid URL <"+url+">"); } if (!async) throw new Error ("HttpRequest: Synchronous requests not implemented."); this._xhr.username = username; this._xhr.password = password; this._xhr.readyState = 1; this.onreadystatechange(); } // must be called after open, before send setContainerRealm(v) { this._xhr.containerRealm = v; } // must be called after open, before send clearContainerCache() { this._xhr.containerReset = true; } send(data) { //store the data, so we can rerun this._xhr.data = data; this._xhr.redirectFlags = null; // The sandbox will have a loadingNode let sandbox = getSandboxForOrigin(this._xhr.username, this._xhr.uri, this._xhr.containerRealm, this._xhr.containerReset); // The XHR in the sandbox will have the correct loadInfo, which will allow us // to use cookies and a CodebasePrincipal for us to use userContextIds and to // contact nextcloud servers (error 503). // We will not use the XHR or the sandbox itself. let XHR = new sandbox.XMLHttpRequest(); XHR.open(this._xhr.method, this._xhr.uri.spec); // Create the channel with the loadInfo from the sandboxed XHR let channel = Services.io.newChannelFromURIWithLoadInfo(this._xhr.uri, XHR.channel.loadInfo); /* // as of TB67 newChannelFromURI needs to specify a loading node to have access to the cookie jars // using the main window // another option would be workers let options = {}; let mainWindow = Services.wm.getMostRecentWindow("mail:3pane"); let channel = Services.io.newChannelFromURI( this._xhr.uri, mainWindow.document, Services.scriptSecurityManager.createContentPrincipal(this._xhr.uri, options), null, Components.interfaces.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL, Components.interfaces.nsIContentPolicy.TYPE_OTHER); */ /* // enforce anonymous access if requested (will not work with proxy, see MDN) if (this._xhr.mozAnon) { channel.loadFlag |= Components.interfaces.nsIRequest.LOAD_ANONYMOUS; } // set background request if (this._xhr.mozBackgroundRequest) { channel.loadFlag |= Components.interfaces.nsIRequest.LOAD_BACKGROUND; } */ this._xhr.httpchannel = channel.QueryInterface(Components.interfaces.nsIHttpChannel); this._xhr.httpchannel.loadFlags |= this._xhr.loadFlags; this._xhr.httpchannel.notificationCallbacks = this.notificationCallbacks; // Set default content type. if (!this.hasRequestHeader("Content-Type")) { this.setRequestHeader("Content-Type", "application/xml; charset=utf-8") } // Set default accept value. if (!this.hasRequestHeader("Accept")) { this.setRequestHeader("Accept", "*/*"); } // Set non-standard header to request authorization (https://github.com/jobisoft/DAV-4-TbSync/issues/106) if (this._xhr.username) { this.setRequestHeader("X-EnforceAuthentication", "True"); } // calculate length of request and add header if (data) { let textEncoder = new TextEncoder(); let encoded = textEncoder.encode(data); this.setRequestHeader("Content-Length", encoded.length); } // mitigation for bug 669675 if ( bug669675.includes(this._xhr.uri.spec) && !this._xhr.mozAnon && !this.hasRequestHeader("Authorization") && this._xhr.username && this._xhr.password ) { this.setRequestHeader("Authorization", "Basic " + b64EncodeUnicode(this._xhr.username + ':' + this._xhr.password)); } // add all headers to the channel for (let header in this._xhr.headers) { if (this._xhr.headers.hasOwnProperty(header)) { this._xhr.httpchannel.setRequestHeader(header, this._xhr.headers[header], false); } } // Will overwrite the Content-Type, so it must be called after the headers have been set. prepHttpChannelUploadData(this._xhr.httpchannel, this._xhr.method, data, this.getRequestHeader("Content-Type")); if (this._xhr.useStreamLoader) { let loader = Components.classes["@mozilla.org/network/stream-loader;1"].createInstance(Components.interfaces.nsIStreamLoader); loader.init(this.listener); this.listener = loader; } this._startTimeout(); this._xhr.httpchannel.asyncOpen(this.listener, this._xhr.httpchannel); } get readyState() { return this._xhr.readyState; } get responseURI() { return this._xhr.httpchannel.URI; } get responseURL() { return this._xhr.httpchannel.URI.spec; } get permanentlyRedirectedUrl() { return this._xhr.permanentlyRedirectedUrl; } get responseText() { return this._xhr.responseText; } get status() { return this._xhr.responseStatus; } get statusText() { return this._xhr.responseStatusText; } get channel() { return this._xhr.httpchannel; } get loadFlags() { return this._xhr.loadFlags; } get timeout() { return this._xhr.timeout; } get mozBackgroundRequest() { return this._xhr.mozBackgroundRequest; } get mozAnon() { return this._xhr.mozAnon; } set loadFlags(v) { this._xhr.loadFlags = v; } set timeout(v) { this._xhr.timeout = v; } set mozBackgroundRequest(v) { this._xhr.mozBackgroundRequest = (v === true); } set mozAnon(v) { this._xhr.mozAnon = (v === true); } // case insensitive method to check for headers hasRequestHeader(header) { let lowHeaders = Object.keys(this._xhr.headers).map(x => x.toLowerCase()); return lowHeaders.includes(header.toLowerCase()); } // if a header exists (case insensitive), it will be replaced (keeping the original capitalization) setRequestHeader(header, value) { let useHeader = header; let lowHeader = header.toLowerCase(); for (let h in this._xhr.headers) { if (this._xhr.headers.hasOwnProperty(h) && h.toLowerCase() == lowHeader) { useHeader = h; break; } } this._xhr.headers[useHeader] = value; } // checks if a header (case insensitive) has been set by setRequestHeader - that does not mean it has been added to the channel! getRequestHeader(header) { let lowHeader = header.toLowerCase(); for (let h in this._xhr.headers) { if (this._xhr.headers.hasOwnProperty(h) && h.toLowerCase() == lowHeader) { return this._xhr.headers[h]; } } return null; } getResponseHeader(header) { try { return this._xhr.httpchannel.getResponseHeader(header); } catch (e) { if (e.code != Components.results.NS_ERROR_NOT_AVAILIBLE) { // The header could possibly not be available, ignore that // case but throw otherwise throw e; } } return null; } overrideMimeType(mime) { this._xhr.overrideMimeType = mime; } abort() { this._cancel(Components.results.NS_BINDING_ABORTED); } get responseAsBase64() { return this._xhr.responseAsBase64; } set responseAsBase64(v) { this._xhr.responseAsBase64 = (v == true);} /* not used */ get responseXML() { throw new Error("HttpRequest: responseXML not implemented"); } get response() { throw new Error("HttpRequest: response not implemented"); } set response(v) { throw new Error("HttpRequest: response not implemented"); } get responseType() { throw new Error("HttpRequest: response not implemented"); } set responseType(v) { throw new Error("HttpRequest: response not implemented"); } get upload() { throw new Error("HttpRequest: upload not implemented"); } set upload(v) { throw new Error("HttpRequest: upload not implemented"); } get withCredentials() { throw new Error("HttpRequest: withCredentials not implemented"); } set withCredentials(v) { throw new Error("HttpRequest: withCredentials not implemented"); } /** private helper methods **/ _startTimeout() { let that = this; this._xhr.timer = Components.classes["@mozilla.org/timer;1"].createInstance(Components.interfaces.nsITimer); let event = { notify: function(timer) { that._cancel(Components.results.NS_ERROR_NET_TIMEOUT) } } this._xhr.timer.initWithCallback( event, this._xhr.timeout, Components.interfaces.nsITimer.TYPE_ONE_SHOT); } _cancel(error) { if (this._xhr.httpchannel && error) { this._xhr.httpchannel.cancel(error); } } } var HttpRequestPrompt = class { constructor(username, password, promptCallback, realmCallback) { this.mCounts = 0; this.mUsername = username; this.mPassword = password; this.mPromptCallback = promptCallback; this.mRealmCallback = realmCallback; } // boolean promptAuth(in nsIChannel aChannel, // in uint32_t level, // in nsIAuthInformation authInfo) promptAuth (aChannel, aLevel, aAuthInfo) { this.mRealmCallback(this.mUsername, aAuthInfo.realm, aChannel.URI.host); if (this.mUsername && this.mPassword) { console.log("Passing provided credentials for user <"+this.mUsername+"> to nsIHttpChannel."); aAuthInfo.username = this.mUsername; aAuthInfo.password = this.mPassword; } else if (this.mUsername) { console.log("Using passwordCallback callback to get password for user <"+this.mUsername+"> and realm <"+aAuthInfo.realm+"> @ host <"+aChannel.URI.host+">"); let password = this.mPromptCallback(this.mUsername, aAuthInfo.realm, aChannel.URI.host); if (password) { aAuthInfo.username = this.mUsername; aAuthInfo.password = password; } else { return false; } } else { return false; } // The provided password could be wrong, in whichcase // we would be here more than once. this.mCounts++ return (this.mCounts < 2); } } function getSandboxForOrigin(username, uri, containerRealm = "default", containerReset = false) { let options = {}; let origin = uri.scheme + "://" + uri.hostPort; // Note: // Disabled check for username to always use containers, even if no username is given. // There was an issue with NC (in its container) and google being in the default container, // causing NC to fail with 503. Putting google inside a container as well fixed it. options.userContextId = getContainerIdForUser(containerRealm + "::" + username); origin = options.userContextId + "@" + origin; if (containerReset) { resetContainerWithId(options.userContextId); } if (!sandboxes.hasOwnProperty(origin)) { console.log("Creating sandbox for <"+origin+">"); let principal = Services.scriptSecurityManager.createContentPrincipal(uri, options); sandboxes[origin] = Components.utils.Sandbox(principal, { wantXrays: true, wantGlobalProperties: ["XMLHttpRequest"], }); } return sandboxes[origin]; } function resetContainerWithId(id) { Services.clearData.deleteDataFromOriginAttributesPattern({ userContextId: id }); } function getContainerIdForUser(username) { // Define the allowed range of container ids to be used // TbSync is using 10000 - 19999 // Lightning is using 20000 - 29999 // Cardbook is using 30000 - 39999 let min = 10000; let max = 19999; //reset if adding an entry will exceed allowed range if (containers.length > (max-min) && containers.indexOf(username) == -1) { for (let i=0; i < containers.length; i++) { resetContainerWithId(i + min); } containers = []; } let idx = containers.indexOf(username); return (idx == -1) ? containers.push(username) - 1 + min : (idx + min); } // copied from cardbook function b64EncodeUnicode (aString) { return btoa(encodeURIComponent(aString).replace(/%([0-9A-F]{2})/g, function(match, p1) { return String.fromCharCode('0x' + p1); })); } // copied from lightning function prepHttpChannelUploadData(aHttpChannel, aMethod, aUploadData, aContentType) { if (aUploadData) { aHttpChannel.QueryInterface(Components.interfaces.nsIUploadChannel); let stream; if (aUploadData instanceof Components.interfaces.nsIInputStream) { // Make sure the stream is reset stream = aUploadData.QueryInterface(Components.interfaces.nsISeekableStream); stream.seek(Components.interfaces.nsISeekableStream.NS_SEEK_SET, 0); } else { // Otherwise its something that should be a string, convert it. let converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"] .createInstance(Components.interfaces.nsIScriptableUnicodeConverter); converter.charset = "UTF-8"; stream = converter.convertToInputStream(aUploadData.toString()); } // If aContentType is empty, the protocol will assume that no content headers are to be // added to the uploaded stream and that any required headers are already encoded in // the stream. In the case of HTTP, if this parameter is non-empty, then its value will // replace any existing Content-Type header on the HTTP request. In the case of FTP and // FILE, this parameter is ignored. aHttpChannel.setUploadStream(stream, aContentType, -1); } //must be set after setUploadStream //https://developer.mozilla.org/en-US/docs/Mozilla/Creating_sandboxed_HTTP_connections aHttpChannel.QueryInterface(Ci.nsIHttpChannel); aHttpChannel.requestMethod = aMethod; } /** * Convert a byte array to a string - copied from lightning * * @param {octet[]} aResult The bytes to convert * @param {Boolean} responseAsBase64 Return a base64 encoded string * @param {String} aCharset The character set of the bytes, defaults to utf-8 * @param {Boolean} aThrow If true, the function will raise an exception on error * @returns {?String} The string result, or null on error */ function convertByteArray(aResult, responseAsBase64 = false, aCharset="utf-8", aThrow) { if (responseAsBase64) { var bin = ''; var bytes = Uint8Array.from(aResult); var len = bytes.byteLength; for (var i = 0; i < len; i++) { bin += String.fromCharCode( bytes[ i ] ); } return btoa( bin ); // if we ever need raw, return bin } else { try { return new TextDecoder(aCharset).decode(Uint8Array.from(aResult)); } catch (e) { if (aThrow) { throw e; } } } return null; }