diff --git a/config/kibana.yml b/config/kibana.yml index cd5569bbbd8b..63ea8aaff8e6 100644 --- a/config/kibana.yml +++ b/config/kibana.yml @@ -4,6 +4,10 @@ # The host to bind the server to. # server.host: "0.0.0.0" +# A value to use as a XSRF token. This token is sent back to the server on each request +# and required if you want to execute requests from other clients (like curl). +# server.xsrf.token: "" + # The Elasticsearch instance to use for all your queries. # elasticsearch.url: "http://localhost:9200" diff --git a/docs/plugins.asciidoc b/docs/plugins.asciidoc index 94c064e54878..8352a0382240 100644 --- a/docs/plugins.asciidoc +++ b/docs/plugins.asciidoc @@ -16,7 +16,7 @@ bin/kibana plugin --install // You can also use `-i` instead of `--install`, as in the following example: [source,shell] -bin/kibana plugin -i elasticsearch/marvel-ui/latest +bin/kibana plugin -i elasticsearch/marvel/latest Because the organization given is `elasticsearch`, the plugin management tool automatically downloads the plugin from `download.elastic.co`. @@ -77,7 +77,7 @@ Use the `--remove` or `-r` option to remove a plugin, including any configuratio example: [source,shell] -bin/kibana plugin --remove marvel-ui +bin/kibana plugin --remove marvel You can also remove a plugin manually by deleting the plugin's subdirectory under the `installedPlugins` directory. diff --git a/src/plugins/elasticsearch/lib/__tests__/routes.js b/src/plugins/elasticsearch/lib/__tests__/routes.js index ca123b108b85..204ff1603a4e 100644 --- a/src/plugins/elasticsearch/lib/__tests__/routes.js +++ b/src/plugins/elasticsearch/lib/__tests__/routes.js @@ -13,7 +13,12 @@ describe('plugins/elasticsearch', function () { before(function () { kbnServer = new KbnServer({ - server: { autoListen: false }, + server: { + autoListen: false, + xsrf: { + disableProtection: true + } + }, logging: { quiet: true }, plugins: { scanDirs: [ @@ -104,5 +109,3 @@ describe('plugins/elasticsearch', function () { }); }); - - diff --git a/src/plugins/kibana/public/discover/index.html b/src/plugins/kibana/public/discover/index.html index 9111b3ba53aa..ac17f7bf80d4 100644 --- a/src/plugins/kibana/public/discover/index.html +++ b/src/plugins/kibana/public/discover/index.html @@ -93,7 +93,7 @@

No results found

- Unfortunately I could not find any results matching your search. I tried really hard. I looked all over the place and frankly, I just couldn't find anything good. Help me, help you. Here's some ideas: + Unfortunately I could not find any results matching your search. I tried really hard. I looked all over the place and frankly, I just couldn't find anything good. Help me, help you. Here are some ideas:

diff --git a/src/server/KbnServer.js b/src/server/KbnServer.js index 525c92473bcf..3bbbca675ae0 100644 --- a/src/server/KbnServer.js +++ b/src/server/KbnServer.js @@ -83,4 +83,18 @@ module.exports = class KbnServer { async close() { await fromNode(cb => this.server.stop(cb)); } + + async inject(opts) { + if (!this.server) await this.ready(); + + return await fromNode(cb => { + try { + this.server.inject(opts, (resp) => { + cb(null, resp); + }); + } catch (err) { + cb(err); + } + }); + } }; diff --git a/src/server/config/schema.js b/src/server/config/schema.js index 69b50dc7aaf8..6bdf9bae1e28 100644 --- a/src/server/config/schema.js +++ b/src/server/config/schema.js @@ -5,8 +5,9 @@ let path = require('path'); let utils = require('requirefrom')('src/utils'); let fromRoot = utils('fromRoot'); +const randomBytes = require('crypto').randomBytes; -module.exports = Joi.object({ +module.exports = () => Joi.object({ pkg: Joi.object({ version: Joi.string().default(Joi.ref('$version')), buildNum: Joi.number().default(Joi.ref('$buildNum')), @@ -39,7 +40,11 @@ module.exports = Joi.object({ origin: ['*://localhost:9876'] // karma test server }), otherwise: Joi.boolean().default(false) - }) + }), + xsrf: Joi.object({ + token: Joi.string().default(randomBytes(32).toString('hex')), + disableProtection: Joi.boolean().default(false), + }).default(), }).default(), logging: Joi.object().keys({ @@ -106,4 +111,3 @@ module.exports = Joi.object({ }).default() }).default(); - diff --git a/src/server/config/setup.js b/src/server/config/setup.js index d1d9f8d69b2e..e1728111a2de 100644 --- a/src/server/config/setup.js +++ b/src/server/config/setup.js @@ -1,6 +1,6 @@ module.exports = function (kbnServer) { let Config = require('./Config'); - let schema = require('./schema'); + let schema = require('./schema')(); kbnServer.config = new Config(schema, kbnServer.settings || {}); }; diff --git a/src/server/http/__tests__/xsrf.js b/src/server/http/__tests__/xsrf.js new file mode 100644 index 000000000000..a38ca767edce --- /dev/null +++ b/src/server/http/__tests__/xsrf.js @@ -0,0 +1,145 @@ +import expect from 'expect.js'; +import { fromNode as fn } from 'bluebird'; +import { resolve } from 'path'; + +import KbnServer from '../../KbnServer'; + +const nonDestructiveMethods = ['GET']; +const destructiveMethods = ['POST', 'PUT', 'DELETE']; +const src = resolve.bind(null, __dirname, '../../../../src'); + +describe('xsrf request filter', function () { + function inject(kbnServer, opts) { + return fn(cb => { + kbnServer.server.inject(opts, (resp) => { + cb(null, resp); + }); + }); + } + + const makeServer = async function (token) { + const kbnServer = new KbnServer({ + server: { autoListen: false, xsrf: { token } }, + plugins: { scanDirs: [src('plugins')] }, + logging: { quiet: true }, + optimize: { enabled: false }, + }); + + await kbnServer.ready(); + + kbnServer.server.route({ + path: '/xsrf/test/route', + method: [...nonDestructiveMethods, ...destructiveMethods], + handler: function (req, reply) { + reply(null, 'ok'); + } + }); + + return kbnServer; + }; + + describe('issuing tokens', function () { + const token = 'secur3'; + let kbnServer; + beforeEach(async () => kbnServer = await makeServer(token)); + afterEach(async () => await kbnServer.close()); + + it('sends a token when rendering an app', async function () { + var resp = await inject(kbnServer, { + method: 'GET', + url: '/app/kibana', + }); + + expect(resp.payload).to.contain(`"xsrfToken":"${token}"`); + }); + }); + + context('without configured token', function () { + let kbnServer; + beforeEach(async () => kbnServer = await makeServer()); + afterEach(async () => await kbnServer.close()); + + it('responds with a random token', async function () { + var resp = await inject(kbnServer, { + method: 'GET', + url: '/app/kibana', + }); + + expect(resp.payload).to.match(/"xsrfToken":".{64}"/); + }); + }); + + context('with configured token', function () { + const token = 'mytoken'; + let kbnServer; + beforeEach(async () => kbnServer = await makeServer(token)); + afterEach(async () => await kbnServer.close()); + + for (const method of nonDestructiveMethods) { + context(`nonDestructiveMethod: ${method}`, function () { // eslint-disable-line no-loop-func + it('accepts requests without a token', async function () { + const resp = await inject(kbnServer, { + url: '/xsrf/test/route', + method: method + }); + + expect(resp.statusCode).to.be(200); + expect(resp.payload).to.be('ok'); + }); + + it('ignores invalid tokens', async function () { + const resp = await inject(kbnServer, { + url: '/xsrf/test/route', + method: method, + headers: { + 'kbn-xsrf-token': `invalid:${token}`, + }, + }); + + expect(resp.statusCode).to.be(200); + expect(resp.headers).to.not.have.property('kbn-xsrf-token'); + }); + }); + } + + for (const method of destructiveMethods) { + context(`destructiveMethod: ${method}`, function () { // eslint-disable-line no-loop-func + it('accepts requests with the correct token', async function () { + const resp = await inject(kbnServer, { + url: '/xsrf/test/route', + method: method, + headers: { + 'kbn-xsrf-token': token, + }, + }); + + expect(resp.statusCode).to.be(200); + expect(resp.payload).to.be('ok'); + }); + + it('rejects requests without a token', async function () { + const resp = await inject(kbnServer, { + url: '/xsrf/test/route', + method: method + }); + + expect(resp.statusCode).to.be(403); + expect(resp.payload).to.match(/"Missing XSRF token"/); + }); + + it('rejects requests with an invalid token', async function () { + const resp = await inject(kbnServer, { + url: '/xsrf/test/route', + method: method, + headers: { + 'kbn-xsrf-token': `invalid:${token}`, + }, + }); + + expect(resp.statusCode).to.be(403); + expect(resp.payload).to.match(/"Invalid XSRF token"/); + }); + }); + } + }); +}); diff --git a/src/server/http/index.js b/src/server/http/index.js index 8ee54653fa25..968d84032341 100644 --- a/src/server/http/index.js +++ b/src/server/http/index.js @@ -121,4 +121,6 @@ module.exports = function (kbnServer, server, config) { .permanent(true); } }); + + return kbnServer.mixin(require('./xsrf')); }; diff --git a/src/server/http/xsrf.js b/src/server/http/xsrf.js new file mode 100644 index 000000000000..7b2050148192 --- /dev/null +++ b/src/server/http/xsrf.js @@ -0,0 +1,20 @@ +import { forbidden } from 'boom'; + +export default function (kbnServer, server, config) { + const token = config.get('server.xsrf.token'); + const disabled = config.get('server.xsrf.disableProtection'); + + server.decorate('reply', 'issueXsrfToken', function () { + return token; + }); + + server.ext('onPostAuth', function (req, reply) { + if (disabled || req.method === 'get') return reply.continue(); + + const attempt = req.headers['kbn-xsrf-token']; + if (!attempt) return reply(forbidden('Missing XSRF token')); + if (attempt !== token) return reply(forbidden('Invalid XSRF token')); + + return reply.continue(); + }); +} diff --git a/src/server/logging/LogReporter.js b/src/server/logging/LogReporter.js index ac098732a711..e108fde23b82 100644 --- a/src/server/logging/LogReporter.js +++ b/src/server/logging/LogReporter.js @@ -21,12 +21,13 @@ module.exports = class KbnLogger { } init(readstream, emitter, callback) { - readstream - .pipe(this.squeeze) - .pipe(this.format) - .pipe(this.dest); - emitter.on('stop', _.noop); + this.output = readstream.pipe(this.squeeze).pipe(this.format); + this.output.pipe(this.dest); + + emitter.on('stop', () => { + this.output.unpipe(this.dest); + }); callback(); } diff --git a/src/server/plugins/initialize.js b/src/server/plugins/initialize.js index 4b3e6481cdf9..0810718f9125 100644 --- a/src/server/plugins/initialize.js +++ b/src/server/plugins/initialize.js @@ -19,7 +19,7 @@ module.exports = async function (kbnServer, server, config) { let path = []; - async function initialize(id) { + const initialize = async function (id) { let plugin = plugins.byId[id]; if (includes(path, id)) { diff --git a/src/ui/index.js b/src/ui/index.js index f2ded3b539c2..8b75e7f519d4 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -66,13 +66,14 @@ module.exports = async (kbnServer, server, config) => { } server.decorate('reply', 'renderApp', function (app) { - let payload = { + const payload = { app: app, nav: uiExports.apps, version: kbnServer.version, buildNum: config.get('pkg.buildNum'), buildSha: config.get('pkg.buildSha'), vars: defaults(app.getInjectedVars(), defaultInjectedVars), + xsrfToken: this.issueXsrfToken(), }; return this.view(app.templateName, { diff --git a/src/ui/public/chrome/api/__tests__/xsrf.js b/src/ui/public/chrome/api/__tests__/xsrf.js new file mode 100644 index 000000000000..9603a0fe35f7 --- /dev/null +++ b/src/ui/public/chrome/api/__tests__/xsrf.js @@ -0,0 +1,132 @@ +import $ from 'jquery'; +import expect from 'expect.js'; +import { stub } from 'auto-release-sinon'; +import ngMock from 'ngMock'; + +import xsrfChromeApi from '../xsrf'; + +const xsrfHeader = 'kbn-xsrf-token'; +const xsrfToken = 'xsrfToken'; + +describe('chrome xsrf apis', function () { + describe('#getXsrfToken()', function () { + it('exposes the token', function () { + const chrome = {}; + xsrfChromeApi(chrome, { xsrfToken }); + expect(chrome.getXsrfToken()).to.be(xsrfToken); + }); + }); + + context('jQuery support', function () { + it('adds a global jQuery prefilter', function () { + stub($, 'ajaxPrefilter'); + xsrfChromeApi({}, {}); + expect($.ajaxPrefilter.callCount).to.be(1); + }); + + context('jQuery prefilter', function () { + let prefilter; + const xsrfToken = 'xsrfToken'; + + beforeEach(function () { + stub($, 'ajaxPrefilter'); + xsrfChromeApi({}, { xsrfToken }); + prefilter = $.ajaxPrefilter.args[0][0]; + }); + + it('sets the kbn-xsrf-token header', function () { + const setHeader = stub(); + prefilter({}, {}, { setRequestHeader: setHeader }); + + expect(setHeader.callCount).to.be(1); + expect(setHeader.args[0]).to.eql([ + xsrfHeader, + xsrfToken + ]); + }); + + it('can be canceled by setting the kbnXsrfToken option', function () { + const setHeader = stub(); + prefilter({ kbnXsrfToken: false }, {}, { setRequestHeader: setHeader }); + expect(setHeader.callCount).to.be(0); + }); + }); + + context('Angular support', function () { + + let $http; + let $httpBackend; + + beforeEach(function () { + stub($, 'ajaxPrefilter'); + const chrome = {}; + xsrfChromeApi(chrome, { xsrfToken }); + ngMock.module(chrome.$setupXsrfRequestInterceptor); + }); + + beforeEach(ngMock.inject(function ($injector) { + $http = $injector.get('$http'); + $httpBackend = $injector.get('$httpBackend'); + + $httpBackend + .when('POST', '/api/test') + .respond('ok'); + })); + + afterEach(function () { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('injects a kbn-xsrf-token header on every request', function () { + $httpBackend.expectPOST('/api/test', undefined, function (headers) { + return headers[xsrfHeader] === xsrfToken; + }).respond(200, ''); + + $http.post('/api/test'); + $httpBackend.flush(); + }); + + it('skips requests with the kbnXsrfToken set falsey', function () { + $httpBackend.expectPOST('/api/test', undefined, function (headers) { + return !(xsrfHeader in headers); + }).respond(200, ''); + + $http({ + method: 'POST', + url: '/api/test', + kbnXsrfToken: 0 + }); + + $http({ + method: 'POST', + url: '/api/test', + kbnXsrfToken: '' + }); + + $http({ + method: 'POST', + url: '/api/test', + kbnXsrfToken: false + }); + + $httpBackend.flush(); + }); + + it('accepts alternate tokens to use', function () { + const customToken = `custom:${xsrfToken}`; + $httpBackend.expectPOST('/api/test', undefined, function (headers) { + return headers[xsrfHeader] === customToken; + }).respond(200, ''); + + $http({ + method: 'POST', + url: '/api/test', + kbnXsrfToken: customToken + }); + + $httpBackend.flush(); + }); + }); + }); +}); diff --git a/src/ui/public/chrome/api/angular.js b/src/ui/public/chrome/api/angular.js index db003c08a171..14e4b5a33dbc 100644 --- a/src/ui/public/chrome/api/angular.js +++ b/src/ui/public/chrome/api/angular.js @@ -24,6 +24,7 @@ module.exports = function (chrome, internals) { a.href = '/elasticsearch'; return a.href; }())) + .config(chrome.$setupXsrfRequestInterceptor) .directive('kbnChrome', function ($rootScope) { return { template: function ($el) { diff --git a/src/ui/public/chrome/api/xsrf.js b/src/ui/public/chrome/api/xsrf.js new file mode 100644 index 000000000000..244f709a9eaa --- /dev/null +++ b/src/ui/public/chrome/api/xsrf.js @@ -0,0 +1,29 @@ +import $ from 'jquery'; +import { set } from 'lodash'; + +export default function (chrome, internals) { + + chrome.getXsrfToken = function () { + return internals.xsrfToken; + }; + + $.ajaxPrefilter(function ({ kbnXsrfToken = internals.xsrfToken }, originalOptions, jqXHR) { + if (kbnXsrfToken) { + jqXHR.setRequestHeader('kbn-xsrf-token', kbnXsrfToken); + } + }); + + chrome.$setupXsrfRequestInterceptor = function ($httpProvider) { + $httpProvider.interceptors.push(function () { + return { + request: function (opts) { + const { kbnXsrfToken = internals.xsrfToken } = opts; + if (kbnXsrfToken) { + set(opts, ['headers', 'kbn-xsrf-token'], kbnXsrfToken); + } + return opts; + } + }; + }); + }; +} diff --git a/src/ui/public/chrome/chrome.js b/src/ui/public/chrome/chrome.js index 5a162d79980a..c9190ccc4e78 100644 --- a/src/ui/public/chrome/chrome.js +++ b/src/ui/public/chrome/chrome.js @@ -18,6 +18,7 @@ var internals = _.defaults( rootController: null, rootTemplate: null, showAppsLink: null, + xsrfToken: null, brand: null, nav: [], applicationClasses: [] @@ -30,6 +31,7 @@ $('').attr({ }).appendTo('head'); require('./api/apps')(chrome, internals); +require('./api/xsrf')(chrome, internals); require('./api/nav')(chrome, internals); require('./api/angular')(chrome, internals); require('./api/controls')(chrome, internals);