diff --git a/chrome/content/zotero/xpcom/connector/server_connector.js b/chrome/content/zotero/xpcom/connector/server_connector.js index be66403055..0ff869a8ac 100644 --- a/chrome/content/zotero/xpcom/connector/server_connector.js +++ b/chrome/content/zotero/xpcom/connector/server_connector.js @@ -1843,3 +1843,103 @@ Zotero.Server.Connector.IEHack.prototype = { ''); } } + +/** + * Make an HTTP request from the client. Accepts {@link Zotero.HTTP.request} options and returns a minimal response + * object with the same form as the one returned from {@link Zotero.Utilities.Translate#request}. + * + * Accepts: + * method - The request method ('GET', 'POST', etc.) + * url - The URL to make the request to. Must be an absolute HTTP(S) URL. + * options - See Zotero.HTTP.request() documentation. Differences: + * - responseType is always set to 'text' + * - successCodes is always set to false (non-2xx status codes will not trigger an error) + * Returns: + * Response code is always 200. Body contains: + * status - The response status code, as a number + * headers - An object mapping header names to values + * body - The response body, as a string + */ +Zotero.Server.Connector.Request = function () {}; + +/** + * The list of allowed hosts. Intentionally hardcoded. + */ +Zotero.Server.Connector.Request.allowedHosts = ['www.worldcat.org']; + +/** + * For testing: allow disabling validation so we can make requests to the server. + */ +Zotero.Server.Connector.Request.enableValidation = false; + +Zotero.Server.Endpoints["/connector/request"] = Zotero.Server.Connector.Request; +Zotero.Server.Connector.Request.prototype = { + supportedMethods: ["POST"], + supportedDataTypes: ["application/json"], + + init: async function (req) { + let { method, url, options } = req.data; + + if (typeof method !== 'string' || typeof url !== 'string') { + return [400, 'text/plain', 'method and url are required and must be strings']; + } + + let uri; + try { + uri = Services.io.newURI(url); + } + catch (e) { + return [400, 'text/plain', 'Invalid URL']; + } + + if (uri.scheme != 'http' && uri.scheme != 'https') { + return [400, 'text/plain', 'Unsupported scheme']; + } + + if (Zotero.Server.Connector.Request.enableValidation) { + if (!Zotero.Server.Connector.Request.allowedHosts.includes(uri.host)) { + return [ + 400, + 'text/plain', + 'Unsupported URL' + ]; + } + + if (!req.headers['User-Agent'] || !req.headers['User-Agent'].startsWith('Mozilla/')) { + return [400, 'text/plain', 'Unsupported User-Agent']; + } + } + + options = options || {}; + options.responseType = 'text'; + options.successCodes = false; + + let xhr; + try { + xhr = await Zotero.HTTP.request(req.data.method, req.data.url, options); + } + catch (e) { + if (e instanceof Zotero.HTTP.BrowserOfflineException) { + return [503, 'text/plain', 'Client is offline']; + } + else { + throw e; + } + } + + let status = xhr.status; + let headers = {}; + xhr.getAllResponseHeaders() + .trim() + .split(/[\r\n]+/) + .map(line => line.split(': ')) + .forEach(parts => headers[parts.shift()] = parts.join(': ')); + let body = xhr.response; + + return [200, 'application/json', JSON.stringify({ + status, + headers, + body + })]; + } +}; diff --git a/chrome/content/zotero/xpcom/http.js b/chrome/content/zotero/xpcom/http.js index dee533de0b..dd19dc075e 100644 --- a/chrome/content/zotero/xpcom/http.js +++ b/chrome/content/zotero/xpcom/http.js @@ -117,7 +117,7 @@ Zotero.HTTP = new function() { * @param {nsIURI|String} url - URL to request * @param {Object} [options] Options for HTTP request: * @param {String} [options.body] - The body of a POST request - * @param {Object} [options.headers] - Object of HTTP headers to send with the request + * @param {Object | Headers} [options.headers] - HTTP headers to send with the request * @param {Boolean} [options.followRedirects = true] - Object of HTTP headers to send with the * request * @param {Zotero.CookieSandbox} [options.cookieSandbox] - The sandbox from which cookies should @@ -343,22 +343,19 @@ Zotero.HTTP = new function() { } // Send headers - var headers = {}; - if (options && options.headers) { - Object.assign(headers, options.headers); - } + var headers = new Headers(options?.headers || {}); var compressedBody = false; if (options.body) { - if (!headers["Content-Type"]) { - headers["Content-Type"] = "application/x-www-form-urlencoded"; + if (!headers.get("Content-Type")) { + headers.set("Content-Type", "application/x-www-form-urlencoded"); } - else if (headers["Content-Type"] == 'multipart/form-data') { + else if (headers.get("Content-Type") == 'multipart/form-data') { // Allow XHR to set Content-Type with boundary for multipart/form-data - delete headers["Content-Type"]; + headers.delete("Content-Type"); } if (options.compressBody && this.isWriteMethod(method)) { - headers['Content-Encoding'] = 'gzip'; + headers.set('Content-Encoding', 'gzip'); compressedBody = await Zotero.Utilities.Internal.gzip(options.body); let oldLen = options.body.length; @@ -368,23 +365,17 @@ Zotero.HTTP = new function() { } } if (options.debug) { - if (headers["Zotero-API-Key"]) { - let dispHeaders = {}; - Object.assign(dispHeaders, headers); - if (dispHeaders["Zotero-API-Key"]) { - dispHeaders["Zotero-API-Key"] = "[Not shown]"; - } - Zotero.debug(dispHeaders); + if (headers.has("Zotero-API-Key")) { + let dispHeaders = new Headers(headers); + dispHeaders.set("Zotero-API-Key", "[Not shown]"); + Zotero.debug({ ...dispHeaders.entries() }); } else { - Zotero.debug(headers); + Zotero.debug({ ...headers.entries() }); } } - for (var header in headers) { + for (var [header, value] of headers) { // Convert numbers to string to make Sinon happy - let value = typeof headers[header] == 'number' - ? headers[header].toString() - : headers[header] xmlhttp.setRequestHeader(header, value); } diff --git a/test/tests/httpTest.js b/test/tests/httpTest.js index dd03b59d18..5f9c2b1160 100644 --- a/test/tests/httpTest.js +++ b/test/tests/httpTest.js @@ -45,6 +45,20 @@ describe("Zotero.HTTP", function () { } } ); + httpd.registerPathHandler( + '/requireJSON', + { + handle(request, response) { + if (request.getHeader('Content-Type') == 'application/json') { + response.setStatusLine(null, 200, "OK"); + } + else { + response.setStatusLine(null, 400, "Bad Request"); + } + response.write('JSON required'); + } + } + ); }); beforeEach(function () { @@ -124,6 +138,20 @@ describe("Zotero.HTTP", function () { server.respond(); }); + it("should process headers case insensitively", async function () { + Zotero.HTTP.mock = null; + var req = await Zotero.HTTP.request( + 'GET', + baseURL + 'requireJSON', + { + headers: { + 'content-type': 'application/json' + } + } + ); + assert.equal(req.status, 200); + }); + describe("Retries", function () { var spy; var delayStub; diff --git a/test/tests/server_connectorTest.js b/test/tests/server_connectorTest.js index 0e6214ae27..aeaa8204b3 100644 --- a/test/tests/server_connectorTest.js +++ b/test/tests/server_connectorTest.js @@ -2647,4 +2647,186 @@ describe("Connector Server", function () { assert.equal(item.libraryID, Zotero.Libraries.userLibraryID); }); }); + + describe('/connector/request', function () { + let endpoint; + + before(function () { + endpoint = connectorServerPath + '/connector/request'; + }); + + beforeEach(function () { + Zotero.Server.Connector.Request.enableValidation = true; + }); + + after(function () { + Zotero.Server.Connector.Request.enableValidation = true; + }); + + it('should reject GET requests', async function () { + let req = await Zotero.HTTP.request( + 'GET', + endpoint, + { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + method: 'GET', + url: 'https://www.example.com/' + }), + successCodes: false + } + ); + assert.equal(req.status, 400); + assert.include(req.responseText, 'Endpoint does not support method'); + }); + + it('should not make requests to arbitrary hosts', async function () { + let req = await Zotero.HTTP.request( + 'POST', + endpoint, + { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + method: 'GET', + url: `http://localhost:${Zotero.Prefs.get('httpServer.port')}/` + }), + successCodes: false + } + ); + assert.equal(req.status, 400); + assert.include(req.responseText, 'Unsupported URL'); + + req = await Zotero.HTTP.request( + 'POST', + endpoint, + { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + method: 'GET', + url: `http://www.example.com/` + }), + successCodes: false + } + ); + assert.equal(req.status, 400); + assert.include(req.responseText, 'Unsupported URL'); + }); + + it('should reject requests with non-Mozilla/ user agents', async function () { + let req = await Zotero.HTTP.request( + 'POST', + endpoint, + { + headers: { + 'content-type': 'application/json', + 'user-agent': 'BadBrowser/1.0' + }, + body: JSON.stringify({ + method: 'GET', + url: `https://www.worldcat.org/api/nonexistent` + }), + successCodes: false + } + ); + assert.equal(req.status, 400); + assert.include(req.responseText, 'Unsupported User-Agent'); + }); + + it('should allow a request to an allowed host', async function () { + let stub = sinon.stub(Zotero.HTTP, 'request'); + // First call: call original + stub.callThrough(); + // Second call (call from within /connector/request handler): return the following + stub.onSecondCall().returns({ + status: 200, + getAllResponseHeaders: () => '', + response: 'it went through' + }); + + let req = await Zotero.HTTP.request( + 'POST', + endpoint, + { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + method: 'GET', + url: `https://www.worldcat.org/api/nonexistent` + }) + } + ); + assert.equal(req.status, 200); + assert.equal(JSON.parse(req.responseText).body, 'it went through'); + + stub.restore(); + }); + + it('should return response in translator request() format with lowercase headers', async function () { + let testEndpointPath = '/test/header'; + + httpd.registerPathHandler( + testEndpointPath, + { + handle: function (request, response) { + response.setStatusLine(null, 200, 'OK'); + response.setHeader('X-Some-Header', 'Header value'); + response.write('body'); + } + } + ); + + Zotero.Server.Connector.Request.enableValidation = false; + let req = await Zotero.HTTP.request( + 'POST', + endpoint, + { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + method: 'GET', + url: testServerPath + testEndpointPath + }), + responseType: 'json' + } + ); + + assert.equal(req.response.status, 200); + assert.equal(req.response.headers['x-some-header'], 'Header value'); + assert.equal(req.response.body, 'body'); + }); + + it('should set Referer', async function () { + let testEndpointPath = '/test/referer'; + let referer = 'https://www.example.com/'; + + httpd.registerPathHandler( + testEndpointPath, + { + handle: function (request, response) { + assert.equal(request.getHeader('Referer'), referer); + response.setStatusLine(null, 200, 'OK'); + response.write(''); + } + } + ); + + Zotero.Server.Connector.Request.enableValidation = false; + let req = await Zotero.HTTP.request( + 'POST', + endpoint, + { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + method: 'GET', + url: testServerPath + testEndpointPath, + options: { + headers: { + Referer: referer + } + } + }) + } + ); + + assert.equal(JSON.parse(req.response).status, 200); + }); + }); });