Fix: Canvas socket auth (#24094)

## Summary

Closes https://github.com/elastic/kibana/issues/23303 ~(@cqliu1 can you confirm this too?)~ confirmed

Fixes the way we capture the request info when configuring the socket and providing it to plugins via `callWithRequest`. Instead of exposing a route that returns the info, simply use the request object that comes back from `server.inject`.

Also adds a check in the `elasticsearchClient` handler exposed to plugins to ensure the session is still valid because using `callWithRequest`.

![screenshot 2018-10-16 10 37 56](https://user-images.githubusercontent.com/404731/47036828-32768c00-d132-11e8-81a0-122b5e83c7ef.png)
*Note:* the actual error message is a bit different, but this is how the failure is exposed to the user
This commit is contained in:
Joe Fleming 2018-10-23 17:19:32 -07:00 committed by GitHub
parent ee335d86bb
commit 5009efde13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 148 additions and 78 deletions

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from 'expect.js';
import { createHandlers } from '../create_handlers';
let securityMode = 'pass';
const authError = new Error('auth error');
const mockRequest = {
headers: 'i can haz headers',
};
const mockServer = {
plugins: {
security: {
authenticate: () => ({
succeeded: () => (securityMode === 'pass' ? true : false),
error: securityMode === 'pass' ? null : authError,
}),
},
elasticsearch: {
getCluster: () => ({
callWithRequest: (...args) => Promise.resolve(args),
}),
},
},
config: () => ({
has: () => false,
get: val => val,
}),
info: {
uri: 'serveruri',
},
};
describe('server createHandlers', () => {
let handlers;
beforeEach(() => {
securityMode = 'pass';
handlers = createHandlers(mockRequest, mockServer);
});
it('provides helper methods and properties', () => {
expect(handlers).to.have.property('environment', 'server');
expect(handlers).to.have.property('serverUri');
expect(handlers).to.have.property('httpHeaders', mockRequest.headers);
expect(handlers).to.have.property('elasticsearchClient');
});
describe('elasticsearchClient', () => {
it('executes callWithRequest', async () => {
const [request, endpoint, payload] = await handlers.elasticsearchClient(
'endpoint',
'payload'
);
expect(request).to.equal(mockRequest);
expect(endpoint).to.equal('endpoint');
expect(payload).to.equal('payload');
});
it('rejects when authentication check fails', () => {
securityMode = 'fail';
return handlers
.elasticsearchClient('endpoint', 'payload')
.then(() => {
throw new Error('elasticsearchClient should fail when authentication fails');
})
.catch(err => {
// note: boom pre-pends error messages with "Error: "
expect(err.message).to.be.equal(`Error: ${authError.message}`);
});
});
it('works without security', async () => {
// create server without security plugin
const mockServerClone = {
...mockServer,
plugins: { ...mockServer.plugins },
};
delete mockServerClone.plugins.security;
expect(mockServer.plugins).to.have.property('security'); // confirm original server object
expect(mockServerClone.plugins).to.not.have.property('security');
// this shouldn't do anything
securityMode = 'fail';
// make sure the method still works
handlers = createHandlers(mockRequest, mockServerClone);
const [request, endpoint, payload] = await handlers.elasticsearchClient(
'endpoint',
'payload'
);
expect(request).to.equal(mockRequest);
expect(endpoint).to.equal('endpoint');
expect(payload).to.equal('payload');
});
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { partial } from 'lodash';
import boom from 'boom';
export const createHandlers = (request, server) => {
const { callWithRequest } = server.plugins.elasticsearch.getCluster('data');
@ -17,6 +17,14 @@ export const createHandlers = (request, server) => {
? `${server.info.uri}${config.get('server.basePath')}`
: server.info.uri,
httpHeaders: request.headers,
elasticsearchClient: partial(callWithRequest, request),
elasticsearchClient: async (...args) => {
// check if the session is valid because continuing to use it
if (server.plugins.security) {
const authenticationResult = await server.plugins.security.authenticate(request);
if (!authenticationResult.succeeded()) throw boom.unauthorized(authenticationResult.error);
}
return callWithRequest(request, ...args);
},
};
};

View file

@ -4,6 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import uuid from 'uuid/v4';
export function getRequest(server, { headers }) {
const basePath = server.config().get('server.basePath') || '/';
export const insecureAuthRoute = `/api/canvas/ar-${uuid()}`;
return server
.inject({
method: 'GET',
url: basePath,
headers,
})
.then(res => res.request);
}

View file

@ -1,21 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { insecureAuthRoute } from './insecure_auth_route';
// TODO: OMG. No. Need a better way of setting to this than our wacky route thing.
export function getAuthHeader(request, server) {
const basePath = server.config().get('server.basePath') || '';
const fullPath = `${basePath}${insecureAuthRoute}`;
return server
.inject({
method: 'GET',
url: fullPath,
headers: request.headers,
})
.then(res => res.result);
}

View file

@ -1,22 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { insecureAuthRoute } from './insecure_auth_route';
// TODO: Fix this first. This route returns decrypts the cookie and returns the basic auth header. It is used because
// the pre-route hapi hook doesn't work on the socket and there are no exposed methods for doing the conversion from cookie
// to auth header. We will need to add that to x-pack security
// In theory this is pretty difficult to exploit, but not impossible.
//
export function getAuth(server) {
server.route({
method: 'GET',
path: insecureAuthRoute,
handler: function(request, reply) {
reply(request.headers.authorization);
},
});
}

View file

@ -9,7 +9,6 @@ import { socketApi } from './socket';
import { translate } from './translate';
import { esFields } from './es_fields';
import { esIndices } from './es_indices';
import { getAuth } from './get_auth';
import { plugins } from './plugins';
export function routes(server) {
@ -18,6 +17,5 @@ export function routes(server) {
translate(server);
esFields(server);
esIndices(server);
getAuth(server);
plugins(server);
}

View file

@ -11,17 +11,12 @@ import { serializeProvider } from '../../common/lib/serialize';
import { functionsRegistry } from '../../common/lib/functions_registry';
import { typesRegistry } from '../../common/lib/types_registry';
import { loadServerPlugins } from '../lib/load_server_plugins';
import { getAuthHeader } from './get_auth/get_auth_header';
import { getRequest } from '../lib/get_request';
export function socketApi(server) {
const io = socket(server.listener, { path: '/socket.io' });
io.on('connection', socket => {
// This is the HAPI request object
const request = socket.handshake;
const authHeader = getAuthHeader(request, server);
// Create the function list
socket.emit('getFunctionList');
const getClientFunctions = new Promise(resolve => socket.once('functionList', resolve));
@ -31,30 +26,31 @@ export function socketApi(server) {
});
const handler = ({ ast, context, id }) => {
Promise.all([getClientFunctions, authHeader]).then(([clientFunctions, authHeader]) => {
if (server.plugins.security) request.headers.authorization = authHeader;
const types = typesRegistry.toJS();
const interpret = socketInterpreterProvider({
types,
functions: functionsRegistry.toJS(),
handlers: createHandlers(request, server),
referableFunctions: clientFunctions,
socket: socket,
});
const { serialize, deserialize } = serializeProvider(types);
return interpret(ast, deserialize(context))
.then(value => {
socket.emit(`resp:${id}`, { value: serialize(value) });
})
.catch(e => {
socket.emit(`resp:${id}`, {
error: e.message,
stack: e.stack,
});
Promise.all([getClientFunctions, getRequest(server, socket.handshake)]).then(
([clientFunctions, request]) => {
// request is the modified hapi request object
const types = typesRegistry.toJS();
const interpret = socketInterpreterProvider({
types,
functions: functionsRegistry.toJS(),
handlers: createHandlers(request, server),
referableFunctions: clientFunctions,
socket: socket,
});
});
const { serialize, deserialize } = serializeProvider(types);
return interpret(ast, deserialize(context))
.then(value => {
socket.emit(`resp:${id}`, { value: serialize(value) });
})
.catch(e => {
socket.emit(`resp:${id}`, {
error: e.message,
stack: e.stack,
});
});
}
);
};
socket.on('run', handler);