Add /connector/request endpoint

This commit is contained in:
Abe Jellinek 2022-12-22 13:12:51 -05:00 committed by Dan Stillman
parent 51762e1c41
commit 86c56951df
2 changed files with 282 additions and 0 deletions

View file

@ -1845,3 +1845,103 @@ Zotero.Server.Connector.IEHack.prototype = {
'</head><body></body></html>');
}
}
/**
* 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
})];
}
};

View file

@ -2649,4 +2649,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);
});
});
});