diff --git a/src/core_plugins/console/index.js b/src/core_plugins/console/index.js index 3d61656a5b564..6813871dd4909 100644 --- a/src/core_plugins/console/index.js +++ b/src/core_plugins/console/index.js @@ -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), - }, - - pre: [ - function filterUri(req, reply) { - const { uri } = req.query; - - 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); - } - - 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 - }); - } - }; - - server.route({ - path: '/api/console/proxy', - method: '*', - config: { - ...proxyRouteConfig, - - payload: { - output: 'stream', - parse: false + const proxyPathFilters = options.proxyFilter.map(str => new RegExp(str)); + + 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 (config.has('console.proxyConfig')) { + return { + ...proxyConfigCollection.configForUri(uri), + headers, + }; } - } - }); - server.route({ - path: '/api/console/proxy', - method: 'GET', - config: { - ...proxyRouteConfig + return { + ...getElasticsearchProxyConfig(server), + headers, + }; } - }); + })); server.route({ path: '/api/console/api_server', diff --git a/src/core_plugins/console/public/src/es.js b/src/core_plugins/console/public/src/es.js index 7c487dbe6d657..c1049a462571e 100644 --- a/src/core_plugins/console/public/src/es.js +++ b/src/core_plugins/console/public/src/es.js @@ -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 }; diff --git a/src/core_plugins/console/server/__tests__/proxy_route/body.js b/src/core_plugins/console/server/__tests__/proxy_route/body.js new file mode 100644 index 0000000000000..aebf8d98f98d6 --- /dev/null +++ b/src/core_plugins/console/server/__tests__/proxy_route/body.js @@ -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'); + }); + }); + }); + }); +}); diff --git a/src/core_plugins/console/server/__tests__/proxy_route/headers.js b/src/core_plugins/console/server/__tests__/proxy_route/headers.js new file mode 100644 index 0000000000000..30cbcc6cb7c52 --- /dev/null +++ b/src/core_plugins/console/server/__tests__/proxy_route/headers.js @@ -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(''); + }); + }); +}); diff --git a/src/core_plugins/console/server/__tests__/proxy_route/params.js b/src/core_plugins/console/server/__tests__/proxy_route/params.js new file mode 100644 index 0000000000000..b51c7ced0559d --- /dev/null +++ b/src/core_plugins/console/server/__tests__/proxy_route/params.js @@ -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`); + }); + }); + }); +}); diff --git a/src/core_plugins/console/server/__tests__/proxy_route/query_string.js b/src/core_plugins/console/server/__tests__/proxy_route/query_string.js new file mode 100644 index 0000000000000..752acae0213c1 --- /dev/null +++ b/src/core_plugins/console/server/__tests__/proxy_route/query_string.js @@ -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'); + }); + }); + }); + }); +}); diff --git a/src/core_plugins/console/server/__tests__/proxy_route/stubs.js b/src/core_plugins/console/server/__tests__/proxy_route/stubs.js new file mode 100644 index 0000000000000..9e250befe1826 --- /dev/null +++ b/src/core_plugins/console/server/__tests__/proxy_route/stubs.js @@ -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); + }; +} diff --git a/src/core_plugins/console/server/index.js b/src/core_plugins/console/server/index.js new file mode 100644 index 0000000000000..795c5541af16b --- /dev/null +++ b/src/core_plugins/console/server/index.js @@ -0,0 +1,3 @@ +export { ProxyConfigCollection } from './proxy_config_collection'; +export { getElasticsearchProxyConfig } from './elasticsearch_proxy_config'; +export { createProxyRoute } from './proxy_route'; diff --git a/src/core_plugins/console/server/proxy_route.js b/src/core_plugins/console/server/proxy_route.js new file mode 100644 index 0000000000000..8c2201183979a --- /dev/null +++ b/src/core_plugins/console/server/proxy_route.js @@ -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); + }); + } + } +});