[console] support HEAD requests (#10611)

* [console] support HEAD requests

Hapi handles HEAD requests automatically (by ignoring the body of a GET response) which prevents the console proxy from handling them correctly. To fix this the console proxy now only accepts requests with the POST method and requires the path and method in the query string.

* [console] proxy 'Warning' header from es

* [console] fix lint errors
This commit is contained in:
Spencer 2017-03-29 10:12:09 -07:00 committed by GitHub
parent 556bfab85d
commit e48a1a9740
9 changed files with 597 additions and 83 deletions

View file

@ -1,11 +1,14 @@
import Joi from 'joi';
import Boom from 'boom';
import apiServer from './api_server/server';
import { existsSync } from 'fs';
import { resolve, join, sep } from 'path';
import { has } from 'lodash';
import { ProxyConfigCollection } from './server/proxy_config_collection';
import { getElasticsearchProxyConfig } from './server/elasticsearch_proxy_config';
import {
ProxyConfigCollection,
getElasticsearchProxyConfig,
createProxyRoute
} from './server';
export default function (kibana) {
const modules = resolve(__dirname, 'public/webpackShims/');
@ -66,91 +69,35 @@ export default function (kibana) {
},
init: function (server, options) {
const filters = options.proxyFilter.map(str => new RegExp(str));
if (options.ssl && options.ssl.verify) {
throw new Error('sense.ssl.verify is no longer supported.');
}
const config = server.config();
const { filterHeaders } = server.plugins.elasticsearch;
const proxyConfigCollection = new ProxyConfigCollection(options.proxyConfig);
const proxyRouteConfig = {
validate: {
query: Joi.object().keys({
uri: Joi.string()
}).unknown(true),
},
const proxyPathFilters = options.proxyFilter.map(str => new RegExp(str));
pre: [
function filterUri(req, reply) {
const { uri } = req.query;
server.route(createProxyRoute({
baseUrl: config.get('elasticsearch.url'),
pathFilters: proxyPathFilters,
getConfigForReq(req, uri) {
const whitelist = config.get('elasticsearch.requestHeadersWhitelist');
const headers = filterHeaders(req.headers, whitelist);
if (!filters.some(re => re.test(uri))) {
const err = Boom.forbidden();
err.output.payload = `Error connecting to '${uri}':\n\nUnable to send requests to that url.`;
err.output.headers['content-type'] = 'text/plain';
reply(err);
} else {
reply();
}
}
],
handler(req, reply) {
let baseUri = server.config().get('elasticsearch.url');
let { uri:path } = req.query;
baseUri = baseUri.replace(/\/+$/, '');
path = path.replace(/^\/+/, '');
const uri = baseUri + '/' + path;
const requestHeadersWhitelist = server.config().get('elasticsearch.requestHeadersWhitelist');
const filterHeaders = server.plugins.elasticsearch.filterHeaders;
let additionalConfig;
if (server.config().get('console.proxyConfig')) {
additionalConfig = proxyConfigCollection.configForUri(uri);
} else {
additionalConfig = getElasticsearchProxyConfig(server);
if (config.has('console.proxyConfig')) {
return {
...proxyConfigCollection.configForUri(uri),
headers,
};
}
reply.proxy({
mapUri: function (request, done) {
done(null, uri, filterHeaders(request.headers, requestHeadersWhitelist));
},
xforward: true,
onResponse(err, res, request, reply) {
if (err != null) {
reply(`Error connecting to '${uri}':\n\n${err.message}`).type('text/plain').statusCode = 502;
} else {
reply(null, res);
}
},
...additionalConfig
});
return {
...getElasticsearchProxyConfig(server),
headers,
};
}
};
server.route({
path: '/api/console/proxy',
method: '*',
config: {
...proxyRouteConfig,
payload: {
output: 'stream',
parse: false
}
}
});
server.route({
path: '/api/console/proxy',
method: 'GET',
config: {
...proxyRouteConfig
}
});
}));
server.route({
path: '/api/console/api_server',

View file

@ -1,4 +1,6 @@
let $ = require('jquery');
import { stringify as formatQueryString } from 'querystring'
import $ from 'jquery';
let esVersion = [];
@ -34,13 +36,13 @@ module.exports.send = function (method, path, data) {
}
var options = {
url: '../api/console/proxy?uri=' + encodeURIComponent(path),
data: method == "GET" ? null : data,
url: '../api/console/proxy?' + formatQueryString({ path, method }),
data,
contentType,
cache: false,
crossDomain: true,
type: method,
dataType: "text", // disable automatic guessing
type: 'POST',
dataType: 'text', // disable automatic guessing
};

View file

@ -0,0 +1,81 @@
import sinon from 'sinon';
import Wreck from 'wreck';
import expect from 'expect.js';
import { Server } from 'hapi';
import { createProxyRoute } from '../../';
import { createWreckResponseStub } from './stubs';
describe('Console Proxy Route', () => {
const sandbox = sinon.sandbox.create();
const teardowns = [];
let request;
beforeEach(() => {
teardowns.push(() => sandbox.restore());
request = async (method, path, response) => {
sandbox.stub(Wreck, 'request', createWreckResponseStub(response));
const server = new Server();
server.connection({ port: 0 });
server.route(createProxyRoute({
baseUrl: 'http://localhost:9200'
}));
teardowns.push(() => server.stop());
const params = [];
if (path != null) params.push(`path=${path}`);
if (method != null) params.push(`method=${method}`);
return await server.inject({
method: 'POST',
url: `/api/console/proxy${params.length ? `?${params.join('&')}` : ''}`,
});
};
});
afterEach(async () => {
await Promise.all(teardowns.splice(0).map(fn => fn()));
});
describe('response body', () => {
context('GET request', () => {
it('returns the exact body', async () => {
const { payload } = await request('GET', '/', 'foobar');
expect(payload).to.be('foobar');
});
});
context('POST request', () => {
it('returns the exact body', async () => {
const { payload } = await request('POST', '/', 'foobar');
expect(payload).to.be('foobar');
});
});
context('PUT request', () => {
it('returns the exact body', async () => {
const { payload } = await request('PUT', '/', 'foobar');
expect(payload).to.be('foobar');
});
});
context('DELETE request', () => {
it('returns the exact body', async () => {
const { payload } = await request('DELETE', '/', 'foobar');
expect(payload).to.be('foobar');
});
});
context('HEAD request', () => {
it('returns the status code and text', async () => {
const { payload } = await request('HEAD', '/');
expect(payload).to.be('200 - OK');
});
context('mixed casing', () => {
it('returns the status code and text', async () => {
const { payload } = await request('HeAd', '/');
expect(payload).to.be('200 - OK');
});
});
});
});
});

View file

@ -0,0 +1,67 @@
import { request } from 'http';
import sinon from 'sinon';
import Wreck from 'wreck';
import expect from 'expect.js';
import { Server } from 'hapi';
import { createProxyRoute } from '../../';
import { createWreckResponseStub } from './stubs';
describe('Console Proxy Route', () => {
const sandbox = sinon.sandbox.create();
const teardowns = [];
let setup;
beforeEach(() => {
teardowns.push(() => sandbox.restore());
sandbox.stub(Wreck, 'request', createWreckResponseStub());
setup = () => {
const server = new Server();
server.connection({ port: 0 });
server.route(createProxyRoute({
baseUrl: 'http://localhost:9200'
}));
teardowns.push(() => server.stop());
return { server };
};
});
afterEach(async () => {
await Promise.all(teardowns.splice(0).map(fn => fn()));
});
describe('headers', function () {
this.timeout(Infinity);
it('forwards the remote header info', async () => {
const { server } = setup();
await server.start();
const resp = await new Promise(resolve => {
request({
protocol: server.info.protocol + ':',
host: server.info.address,
port: server.info.port,
method: 'POST',
path: '/api/console/proxy?method=GET&path=/'
}, resolve).end();
});
resp.destroy();
sinon.assert.calledOnce(Wreck.request);
const { headers } = Wreck.request.getCall(0).args[2];
expect(headers).to.have.property('x-forwarded-for').and.not.be('');
expect(headers).to.have.property('x-forwarded-port').and.not.be('');
expect(headers).to.have.property('x-forwarded-proto').and.not.be('');
expect(headers).to.have.property('x-forwarded-host').and.not.be('');
});
});
});

View file

@ -0,0 +1,163 @@
import { Agent } from 'http';
import sinon from 'sinon';
import Wreck from 'wreck';
import expect from 'expect.js';
import { Server } from 'hapi';
import { createProxyRoute } from '../../';
import { createWreckResponseStub } from './stubs';
describe('Console Proxy Route', () => {
const sandbox = sinon.sandbox.create();
const teardowns = [];
let setup;
beforeEach(() => {
teardowns.push(() => sandbox.restore());
sandbox.stub(Wreck, 'request', createWreckResponseStub());
setup = () => {
const server = new Server();
server.connection({ port: 0 });
teardowns.push(() => server.stop());
return { server };
};
});
afterEach(async () => {
await Promise.all(teardowns.splice(0).map(fn => fn()));
});
describe('params', () => {
describe('pathFilters', () => {
context('no matches', () => {
it('rejects with 403', async () => {
const { server } = setup();
server.route(createProxyRoute({
pathFilters: [
/^\/foo\//,
/^\/bar\//,
]
}));
const { statusCode } = await server.inject({
method: 'POST',
url: '/api/console/proxy?method=GET&path=/baz/type/id',
});
expect(statusCode).to.be(403);
});
});
context('one match', () => {
it('allows the request', async () => {
const { server } = setup();
server.route(createProxyRoute({
pathFilters: [
/^\/foo\//,
/^\/bar\//,
]
}));
const { statusCode } = await server.inject({
method: 'POST',
url: '/api/console/proxy?method=GET&path=/foo/type/id',
});
expect(statusCode).to.be(200);
sinon.assert.calledOnce(Wreck.request);
});
});
context('all match', () => {
it('allows the request', async () => {
const { server } = setup();
server.route(createProxyRoute({
pathFilters: [
/^\/foo\//,
/^\/bar\//,
]
}));
const { statusCode } = await server.inject({
method: 'POST',
url: '/api/console/proxy?method=GET&path=/foo/type/id',
});
expect(statusCode).to.be(200);
sinon.assert.calledOnce(Wreck.request);
});
});
});
describe('getConfigForReq()', () => {
it('passes the request and targeted uri', async () => {
const { server } = setup();
const getConfigForReq = sinon.stub().returns({});
server.route(createProxyRoute({ getConfigForReq }));
await server.inject({
method: 'POST',
url: '/api/console/proxy?method=HEAD&path=/index/type/id',
});
sinon.assert.calledOnce(getConfigForReq);
const args = getConfigForReq.getCall(0).args;
expect(args[0]).to.have.property('path', '/api/console/proxy');
expect(args[0]).to.have.property('method', 'post');
expect(args[0]).to.have.property('query').eql({ method: 'HEAD', path: '/index/type/id' });
expect(args[1]).to.be('/index/type/id');
});
it('sends the returned timeout, rejectUnauthorized, agent, and base headers to Wreck', async () => {
const { server } = setup();
const timeout = Math.round(Math.random() * 10000);
const agent = new Agent();
const rejectUnauthorized = !!Math.round(Math.random());
const headers = {
foo: 'bar',
baz: 'bop'
};
server.route(createProxyRoute({
getConfigForReq: () => ({
timeout,
agent,
rejectUnauthorized,
headers
})
}));
await server.inject({
method: 'POST',
url: '/api/console/proxy?method=HEAD&path=/index/type/id',
});
sinon.assert.calledOnce(Wreck.request);
const opts = Wreck.request.getCall(0).args[2];
expect(opts).to.have.property('timeout', timeout);
expect(opts).to.have.property('agent', agent);
expect(opts).to.have.property('rejectUnauthorized', rejectUnauthorized);
expect(opts.headers).to.have.property('foo', 'bar');
expect(opts.headers).to.have.property('baz', 'bop');
});
});
describe('baseUrl', () => {
context('default', () => {
it('ensures that the path starts with a /');
});
context('url ends with a slash', () => {
it('combines clean with paths that start with a slash');
it(`combines clean with paths that don't start with a slash`);
});
context(`url doesn't end with a slash`, () => {
it('combines clean with paths that start with a slash');
it(`combines clean with paths that don't start with a slash`);
});
});
});
});

View file

@ -0,0 +1,115 @@
import sinon from 'sinon';
import Wreck from 'wreck';
import expect from 'expect.js';
import { Server } from 'hapi';
import { createProxyRoute } from '../../';
import { createWreckResponseStub } from './stubs';
describe('Console Proxy Route', () => {
const sandbox = sinon.sandbox.create();
const teardowns = [];
let request;
beforeEach(() => {
teardowns.push(() => sandbox.restore());
sandbox.stub(Wreck, 'request', createWreckResponseStub());
request = async (method, path) => {
const server = new Server();
server.connection({ port: 0 });
server.route(createProxyRoute({
baseUrl: 'http://localhost:9200'
}));
teardowns.push(() => server.stop());
const params = [];
if (path != null) params.push(`path=${path}`);
if (method != null) params.push(`method=${method}`);
return await server.inject({
method: 'POST',
url: `/api/console/proxy${params.length ? `?${params.join('&')}` : ''}`,
});
};
});
afterEach(async () => {
await Promise.all(teardowns.splice(0).map(fn => fn()));
});
describe('query string', () => {
describe('path', () => {
context('contains full url', () => {
it('treats the url as a path', async () => {
await request('GET', 'http://evil.com/test');
sinon.assert.calledOnce(Wreck.request);
const args = Wreck.request.getCall(0).args;
expect(args[1]).to.be('http://localhost:9200/http://evil.com/test');
});
});
context('is missing', () => {
it('returns a 400 error', async () => {
const { statusCode } = await request('GET', undefined);
expect(statusCode).to.be(400);
sinon.assert.notCalled(Wreck.request);
});
});
context('is empty', () => {
it('returns a 400 error', async () => {
const { statusCode } = await request('GET', '');
expect(statusCode).to.be(400);
sinon.assert.notCalled(Wreck.request);
});
});
context('starts with a slash', () => {
it('combines well with the base url', async () => {
await request('GET', '/index/type/id');
sinon.assert.calledOnce(Wreck.request);
expect(Wreck.request.getCall(0).args[1]).to.be('http://localhost:9200/index/type/id');
});
});
context(`doesn't start with a slash`, () => {
it('combines well with the base url', async () => {
await request('GET', 'index/type/id');
sinon.assert.calledOnce(Wreck.request);
expect(Wreck.request.getCall(0).args[1]).to.be('http://localhost:9200/index/type/id');
});
});
});
describe('method', () => {
context('is missing', () => {
it('returns a 400 error', async () => {
const { statusCode } = await request(null, '/');
expect(statusCode).to.be(400);
sinon.assert.notCalled(Wreck.request);
});
});
context('is empty', () => {
it('returns a 400 error', async () => {
const { statusCode } = await request('', '/');
expect(statusCode).to.be(400);
sinon.assert.notCalled(Wreck.request);
});
});
context('is an invalid http method', () => {
it('returns a 400 error', async () => {
const { statusCode } = await request('foo', '/');
expect(statusCode).to.be(400);
sinon.assert.notCalled(Wreck.request);
});
});
context('is mixed case', () => {
it('sends a request with the exact method', async () => {
const { statusCode } = await request('HeAd', '/');
expect(statusCode).to.be(200);
sinon.assert.calledOnce(Wreck.request);
expect(Wreck.request.getCall(0).args[0]).to.be('HeAd');
});
});
});
});
});

View file

@ -0,0 +1,23 @@
import { Readable } from 'stream';
export function createWreckResponseStub(response) {
return (...args) => {
const resp = new Readable({
read() {
if (response) {
this.push(response);
}
this.push(null);
}
});
resp.statusCode = 200;
resp.statusMessage = 'OK';
resp.headers = {
'content-type': 'text/plain',
'content-length': String(response ? response.length : 0)
};
args.pop()(null, resp);
};
}

View file

@ -0,0 +1,3 @@
export { ProxyConfigCollection } from './proxy_config_collection';
export { getElasticsearchProxyConfig } from './elasticsearch_proxy_config';
export { createProxyRoute } from './proxy_route';

View file

@ -0,0 +1,113 @@
import Joi from 'joi';
import Boom from 'boom';
import Wreck from 'wreck';
import { trimLeft, trimRight } from 'lodash';
function resolveUri(base, path) {
return `${trimRight(base, '/')}/${trimLeft(path, '/')}`;
}
function extendCommaList(obj, property, value) {
obj[property] = (obj[property] ? obj[property] + ',' : '') + value;
}
function getProxyHeaders(req) {
const headers = {};
if (req.info.remotePort && req.info.remoteAddress) {
// see https://git.io/vytQ7
extendCommaList(headers, 'x-forwarded-for', req.info.remoteAddress);
extendCommaList(headers, 'x-forwarded-port', req.info.remotePort);
extendCommaList(headers, 'x-forwarded-proto', req.connection.info.protocol);
extendCommaList(headers, 'x-forwarded-host', req.info.host);
}
const contentType = req.headers['content-type'];
if (contentType) {
headers['content-type'] = contentType;
}
return headers;
}
export const createProxyRoute = ({
baseUrl = '/',
pathFilters = [/.*/],
getConfigForReq = () => ({}),
}) => ({
path: '/api/console/proxy',
method: 'POST',
config: {
payload: {
output: 'stream',
parse: false
},
validate: {
query: Joi.object().keys({
method: Joi.string()
.valid('HEAD', 'GET', 'POST', 'PUT', 'DELETE')
.insensitive()
.required(),
path: Joi.string().required()
}).unknown(true),
},
pre: [
function filterPath(req, reply) {
const { path } = req.query;
if (!pathFilters.some(re => re.test(path))) {
const err = Boom.forbidden();
err.output.payload = `Error connecting to '${path}':\n\nUnable to send requests to that path.`;
err.output.headers['content-type'] = 'text/plain';
reply(err);
} else {
reply();
}
},
],
handler(req, reply) {
const { payload, query } = req;
const { path, method } = query;
const uri = resolveUri(baseUrl, path);
const {
timeout,
rejectUnauthorized,
agent,
headers,
} = getConfigForReq(req, uri);
const wreckOptions = {
payload,
timeout,
rejectUnauthorized,
agent,
headers: {
...headers,
...getProxyHeaders(req)
},
};
Wreck.request(method, uri, wreckOptions, (err, esResponse) => {
if (err) {
return reply(err);
}
if (method.toUpperCase() !== 'HEAD') {
reply(esResponse)
.code(esResponse.statusCode)
.header('warning', esResponse.headers.warning);
return;
}
reply(`${esResponse.statusCode} - ${esResponse.statusMessage}`)
.code(esResponse.statusCode)
.type('text/plain')
.header('warning', esResponse.headers.warning);
});
}
}
});