[6.8] Support deep links inside of RelayState for SAML IdP initiated login. (#69663)

This commit is contained in:
Aleh Zasypkin 2020-06-26 10:38:54 +02:00 committed by GitHub
parent 058e535172
commit 6a529d0720
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 538 additions and 23 deletions

View file

@ -5,6 +5,7 @@
*/
import { resolve } from 'path';
import { has } from 'lodash';
import { getUserProvider } from './server/lib/get_user';
import { initAuthenticateApi } from './server/routes/api/v1/authenticate';
import { initUsersApi } from './server/routes/api/v1/users';
@ -45,6 +46,12 @@ export const security = (kibana) => new kibana.Plugin({
hostname: Joi.string().hostname(),
port: Joi.number().integer().min(0).max(65535)
}).default(),
authc: Joi.object({})
.when('authProviders', {
is: Joi.array().items(Joi.string().valid('saml').required(), Joi.string()),
then: Joi.object({ saml: Joi.object({ useRelayStateDeepLink: Joi.boolean().default(false) }) }).default(),
otherwise: Joi.any().forbidden(),
}),
authorization: Joi.object({
legacyFallback: Joi.object({
enabled: Joi.boolean().default(true)
@ -56,6 +63,16 @@ export const security = (kibana) => new kibana.Plugin({
}).default();
},
deprecations() {
return [
(settings, log) => {
if (has(settings, 'authc.saml.useRelayStateDeepLink')) {
log('Config key "authc.saml.useRelayStateDeepLink" is deprecated and will be removed in the next major version.');
}
}
];
},
uiExports: {
chromeNavControls: ['plugins/security/views/nav_control'],
managementSections: ['plugins/security/views/management'],

View file

@ -50,6 +50,22 @@ function getProviderOptions(server) {
};
}
/**
* Prepares options object that is specific only to an authentication provider.
* @param {Hapi.Server} server HapiJS Server instance.
* @param {string} providerType the type of the provider to get the options for.
* @returns {Object | undefined}
*/
function getProviderSpecificOptions(
server,
providerType
) {
// We can't use `config.has` here as it doesn't currently work with Joi's "alternatives" syntax
// which is used for the `authc` schema.
const authc = server.config().get(`xpack.security.authc`);
return authc && authc.hasOwnProperty(providerType) ? authc[providerType] : undefined;
}
/**
* Authenticator is responsible for authentication of the request using chain of
* authentication providers. The chain is essentially a prioritized list of configured
@ -117,7 +133,10 @@ class Authenticator {
this._providers = new Map(
authProviders.map(
(providerType) => [providerType, this._instantiateProvider(providerType, providerOptions)]
(providerType) => {
const providerSpecificOptions = getProviderSpecificOptions(server, providerType);
return [providerType, this._instantiateProvider(providerType, providerOptions, providerSpecificOptions)];
}
)
);
}
@ -226,16 +245,17 @@ class Authenticator {
* Instantiates authentication provider based on the provider key from config.
* @param {string} providerType Provider type key.
* @param {Object} options Options to pass to provider's constructor.
* @params {Object} providerSpecificOptions Optional provider specific options.
* @returns {Object} Authentication provider instance.
* @private
*/
_instantiateProvider(providerType, options) {
_instantiateProvider(providerType, options, providerSpecificOptions) {
const ProviderClassName = providerMap.get(providerType);
if (!ProviderClassName) {
throw new Error(`Unsupported authentication provider name: ${providerType}.`);
}
return new ProviderClassName(options);
return new ProviderClassName(options, providerSpecificOptions);
}
/**

View file

@ -108,6 +108,38 @@ describe('SAMLAuthenticationProvider', () => {
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});
it('gets token and redirects user to the requested URL if SAML Response is valid ignoring Relay State.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
callWithInternalUser
.withArgs('shield.samlAuthenticate')
.returns(Promise.resolve({ access_token: 'some-token', refresh_token: 'some-refresh-token' }));
provider = new SAMLAuthenticationProvider({
client: { callWithRequest, callWithInternalUser },
log() {},
protocol: 'test-protocol',
hostname: 'test-hostname',
port: 1234,
basePath: '/test-base-path'
}, { useRelayStateDeepLink: true });
const authenticationResult = await provider.authenticate(request, {
requestId: 'some-request-id',
nextURL: '/test-base-path/some-path'
});
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.samlAuthenticate',
{ body: { ids: ['some-request-id'], content: 'saml-response-xml' } }
);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/some-path');
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});
it('fails if SAML Response payload is presented but state does not contain SAML Request token.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
@ -164,6 +196,112 @@ describe('SAMLAuthenticationProvider', () => {
});
});
describe('IdP initiated login', () => {
beforeEach(() => {
provider = new SAMLAuthenticationProvider({
client: { callWithRequest, callWithInternalUser },
log() {},
protocol: 'test-protocol',
hostname: 'test-hostname',
port: 1234,
basePath: '/test-base-path'
}, { useRelayStateDeepLink: true });
callWithInternalUser
.withArgs('shield.samlAuthenticate')
.returns(Promise.resolve({ access_token: 'some-token', refresh_token: 'some-refresh-token' }));
});
it('redirects to the home page if `useRelayStateDeepLink` is set to `false`.', async () => {
provider = new SAMLAuthenticationProvider({
client: { callWithRequest, callWithInternalUser },
log() {},
protocol: 'test-protocol',
hostname: 'test-hostname',
port: 1234,
basePath: '/test-base-path'
}, { useRelayStateDeepLink: false });
const authenticationResult = await provider.authenticate(
requestFixture({ payload: { SAMLResponse: 'saml-response-xml', RelayState: '/test-base-path/app/some-app#some-deep-link' } })
);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/');
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});
it('redirects to the home page if `RelayState` is not specified.', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } })
);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/');
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});
it('redirects to the home page if `RelayState` includes external URL', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ payload: { SAMLResponse: 'saml-response-xml', RelayState: 'https://evil.com/test-base-path/app/some-app#some-deep-link' } })
);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/');
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});
it('redirects to the home page if `RelayState` includes URL that starts with double slashes', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ payload: { SAMLResponse: 'saml-response-xml', RelayState: '//test-base-path/app/some-app#some-deep-link' } })
);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/');
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});
it('redirects to the URL from the relay state.', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ payload: { SAMLResponse: 'saml-response-xml', RelayState: '/test-base-path/app/some-app#some-deep-link' } })
);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/app/some-app#some-deep-link');
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});
});
it('fails if SAML Response is rejected.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });

View file

@ -7,6 +7,7 @@
import Boom from 'boom';
import { canRedirectRequest } from '../../can_redirect_request';
import { getErrorStatusCode } from '../../errors';
import { isInternalURL } from '../../is_internal_url';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
@ -60,12 +61,22 @@ export class SAMLAuthenticationProvider {
*/
_options = null;
/**
* Indicates if we should treat non-empty `RelayState` as a deep link in Kibana we should redirect
* user to after successful IdP initiated login. `RelayState` is ignored for SP initiated login.
* @type {boolean}
* @private
*/
_useRelayStateDeepLink;
/**
* Instantiates SAMLAuthenticationProvider.
* @param {ProviderOptions} options Provider options object.
* @param {Object} samlOptions SAML provider specific options.
*/
constructor(options) {
constructor(options, samlOptions) {
this._options = options;
this._useRelayStateDeepLink = !!(samlOptions && samlOptions.useRelayStateDeepLink);
}
/**
@ -186,11 +197,11 @@ export class SAMLAuthenticationProvider {
}
// When we don't have state and hence request id we assume that SAMLResponse came from the IdP initiated login.
if (stateRequestId) {
this._options.log(['debug', 'security', 'saml'], 'Authentication has been previously initiated by Kibana.');
} else {
this._options.log(['debug', 'security', 'saml'], 'Authentication has been initiated by Identity Provider.');
}
const isIdPInitiatedLogin = !stateRequestId;
this._options.log(['debug', 'security', 'saml'], !isIdPInitiatedLogin
? 'Authentication has been previously initiated by Kibana.'
: 'Authentication has been initiated by Identity Provider.'
);
try {
// This operation should be performed on behalf of the user with a privilege that normal
@ -205,8 +216,29 @@ export class SAMLAuthenticationProvider {
this._options.log(['debug', 'security', 'saml'], 'Request has been authenticated via SAML response.');
// IdP can pass `RelayState` with the deep link in Kibana during IdP initiated login and
// depending on the configuration we may need to redirect user to this URL.
let redirectURLFromRelayState;
const relayState = request.payload.RelayState;
if (isIdPInitiatedLogin && relayState) {
if (!this._useRelayStateDeepLink) {
this._options.log(['debug', 'security', 'saml'],
`"RelayState" is provided, but deep links support is not enabled.`
);
} else if (!isInternalURL(relayState, this._options.basePath)) {
this._options.log(['debug', 'security', 'saml'],
`"RelayState" is provided, but it is not a valid Kibana internal URL.`
);
} else {
this._options.log(['debug', 'security', 'saml'],
`User will be redirected to the Kibana internal URL specified in "RelayState".`
);
redirectURLFromRelayState = relayState;
}
}
return AuthenticationResult.redirectTo(
stateRedirectURL || `${this._options.basePath}/`,
redirectURLFromRelayState || stateRedirectURL || `${this._options.basePath}/`,
{ accessToken, refreshToken }
);
} catch (err) {

View file

@ -0,0 +1,88 @@
/*
* 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 { isInternalURL } from './is_internal_url';
describe('isInternalURL', () => {
describe('with basePath defined', () => {
const basePath = '/iqf';
it('should return `true `if URL includes hash fragment', () => {
const href = `${basePath}/app/kibana#/discover/New-Saved-Search`;
expect(isInternalURL(href, basePath)).toBe(true);
});
it('should return `false` if URL includes a protocol/hostname', () => {
const href = `https://example.com${basePath}/app/kibana`;
expect(isInternalURL(href, basePath)).toBe(false);
});
it('should return `false` if URL includes a port', () => {
const href = `http://localhost:5601${basePath}/app/kibana`;
expect(isInternalURL(href, basePath)).toBe(false);
});
it('should return `false` if URL does not specify protocol', () => {
const hrefWithTwoSlashes = `/${basePath}/app/kibana`;
expect(isInternalURL(hrefWithTwoSlashes)).toBe(false);
const hrefWithThreeSlashes = `//${basePath}/app/kibana`;
expect(isInternalURL(hrefWithThreeSlashes)).toBe(false);
});
it('should return `true` if URL starts with a basepath', () => {
for (const href of [basePath, `${basePath}/`, `${basePath}/login`, `${basePath}/login/`]) {
expect(isInternalURL(href, basePath)).toBe(true);
}
});
it('should return `false` if URL does not start with basePath', () => {
for (const href of [
'/notbasepath/app/kibana',
`${basePath}_/login`,
basePath.slice(1),
`${basePath.slice(1)}/app/kibana`,
]) {
expect(isInternalURL(href, basePath)).toBe(false);
}
});
it('should return `true` if relative path does not escape base path', () => {
const href = `${basePath}/app/kibana/../../management`;
expect(isInternalURL(href, basePath)).toBe(true);
});
it('should return `false` if relative path escapes base path', () => {
const href = `${basePath}/app/kibana/../../../management`;
expect(isInternalURL(href, basePath)).toBe(false);
});
});
describe('without basePath defined', () => {
it('should return `true `if URL includes hash fragment', () => {
const href = '/app/kibana#/discover/New-Saved-Search';
expect(isInternalURL(href)).toBe(true);
});
it('should return `false` if URL includes a protocol/hostname', () => {
const href = 'https://example.com/app/kibana';
expect(isInternalURL(href)).toBe(false);
});
it('should return `false` if URL includes a port', () => {
const href = 'http://localhost:5601/app/kibana';
expect(isInternalURL(href)).toBe(false);
});
it('should return `false` if URL does not specify protocol', () => {
const hrefWithTwoSlashes = `//app/kibana`;
expect(isInternalURL(hrefWithTwoSlashes)).toBe(false);
const hrefWithThreeSlashes = `///app/kibana`;
expect(isInternalURL(hrefWithThreeSlashes)).toBe(false);
});
});
});

View file

@ -6,16 +6,9 @@
import { parse } from 'url';
export function parseNext(href, basePath = '') {
const { query, hash } = parse(href, true);
if (!query.next) {
return `${basePath}/`;
}
// validate that `next` is not attempting a redirect to somewhere
// outside of this Kibana install
export function isInternalURL(url: string, basePath = '') {
const { protocol, hostname, port, pathname } = parse(
query.next,
url,
false /* parseQueryString */,
true /* slashesDenoteHost */
);
@ -26,12 +19,21 @@ export function parseNext(href, basePath = '') {
// hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`)
// and the first slash that belongs to path.
if (protocol !== null || hostname !== null || port !== null) {
return `${basePath}/`;
return false;
}
if (!String(pathname).startsWith(basePath)) {
return `${basePath}/`;
if (basePath) {
// Now we need to normalize URL to make sure any relative path segments (`..`) cannot escape expected
// base path. We can rely on `URL` with a localhost to automatically "normalize" the URL.
const normalizedPathname = new URL(String(pathname), 'https://localhost').pathname;
return (
// Normalized pathname can add a leading slash, but we should also make sure it's included in
// the original URL too
pathname &&
pathname.startsWith('/') &&
(normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`))
);
}
return query.next + (hash || '');
return true;
}

View file

@ -0,0 +1,188 @@
/*
* 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 { parseNext } from './parse_next';
describe('parseNext', () => {
it('should return a function', () => {
expect(parseNext).toBeInstanceOf(Function);
});
describe('with basePath defined', () => {
// trailing slash is important since it must match the cookie path exactly
it('should return basePath with a trailing slash when next is not specified', () => {
const basePath = '/iqf';
const href = `${basePath}/login`;
expect(parseNext(href, basePath)).toEqual(`${basePath}/`);
});
it('should properly handle next without hash', () => {
const basePath = '/iqf';
const next = `${basePath}/app/kibana`;
const href = `${basePath}/login?next=${next}`;
expect(parseNext(href, basePath)).toEqual(next);
});
it('should properly handle next with hash', () => {
const basePath = '/iqf';
const next = `${basePath}/app/kibana`;
const hash = '/discover/New-Saved-Search';
const href = `${basePath}/login?next=${next}#${hash}`;
expect(parseNext(href, basePath)).toEqual(`${next}#${hash}`);
});
it('should properly handle multiple next with hash', () => {
const basePath = '/iqf';
const next1 = `${basePath}/app/kibana`;
const next2 = `${basePath}/app/ml`;
const hash = '/discover/New-Saved-Search';
const href = `${basePath}/login?next=${next1}&next=${next2}#${hash}`;
expect(parseNext(href, basePath)).toEqual(`${next1}#${hash}`);
});
it('should properly decode special characters', () => {
const basePath = '/iqf';
const next = `${encodeURIComponent(basePath)}%2Fapp%2Fkibana`;
const hash = '/discover/New-Saved-Search';
const href = `${basePath}/login?next=${next}#${hash}`;
expect(parseNext(href, basePath)).toEqual(decodeURIComponent(`${next}#${hash}`));
});
// to help prevent open redirect to a different url
it('should return basePath if next includes a protocol/hostname', () => {
const basePath = '/iqf';
const next = `https://example.com${basePath}/app/kibana`;
const href = `${basePath}/login?next=${next}`;
expect(parseNext(href, basePath)).toEqual(`${basePath}/`);
});
// to help prevent open redirect to a different url by abusing encodings
it('should return basePath if including a protocol/host even if it is encoded', () => {
const basePath = '/iqf';
const baseUrl = `http://example.com${basePath}`;
const next = `${encodeURIComponent(baseUrl)}%2Fapp%2Fkibana`;
const hash = '/discover/New-Saved-Search';
const href = `${basePath}/login?next=${next}#${hash}`;
expect(parseNext(href, basePath)).toEqual(`${basePath}/`);
});
// to help prevent open redirect to a different port
it('should return basePath if next includes a port', () => {
const basePath = '/iqf';
const next = `http://localhost:5601${basePath}/app/kibana`;
const href = `${basePath}/login?next=${next}`;
expect(parseNext(href, basePath)).toEqual(`${basePath}/`);
});
// to help prevent open redirect to a different port by abusing encodings
it('should return basePath if including a port even if it is encoded', () => {
const basePath = '/iqf';
const baseUrl = `http://example.com:5601${basePath}`;
const next = `${encodeURIComponent(baseUrl)}%2Fapp%2Fkibana`;
const hash = '/discover/New-Saved-Search';
const href = `${basePath}/login?next=${next}#${hash}`;
expect(parseNext(href, basePath)).toEqual(`${basePath}/`);
});
// to help prevent open redirect to a different base path
it('should return basePath if next does not begin with basePath', () => {
const basePath = '/iqf';
const next = '/notbasepath/app/kibana';
const href = `${basePath}/login?next=${next}`;
expect(parseNext(href, basePath)).toEqual(`${basePath}/`);
});
// disallow network-path references
it('should return / if next is url without protocol', () => {
const nextWithTwoSlashes = '//example.com';
const hrefWithTwoSlashes = `/login?next=${nextWithTwoSlashes}`;
expect(parseNext(hrefWithTwoSlashes)).toEqual('/');
const nextWithThreeSlashes = '///example.com';
const hrefWithThreeSlashes = `/login?next=${nextWithThreeSlashes}`;
expect(parseNext(hrefWithThreeSlashes)).toEqual('/');
});
});
describe('without basePath defined', () => {
// trailing slash is important since it must match the cookie path exactly
it('should return / with a trailing slash when next is not specified', () => {
const href = '/login';
expect(parseNext(href)).toEqual('/');
});
it('should properly handle next without hash', () => {
const next = '/app/kibana';
const href = `/login?next=${next}`;
expect(parseNext(href)).toEqual(next);
});
it('should properly handle next with hash', () => {
const next = '/app/kibana';
const hash = '/discover/New-Saved-Search';
const href = `/login?next=${next}#${hash}`;
expect(parseNext(href)).toEqual(`${next}#${hash}`);
});
it('should properly handle multiple next with hash', () => {
const next1 = '/app/kibana';
const next2 = '/app/ml';
const hash = '/discover/New-Saved-Search';
const href = `/login?next=${next1}&next=${next2}#${hash}`;
expect(parseNext(href)).toEqual(`${next1}#${hash}`);
});
it('should properly decode special characters', () => {
const next = '%2Fapp%2Fkibana';
const hash = '/discover/New-Saved-Search';
const href = `/login?next=${next}#${hash}`;
expect(parseNext(href)).toEqual(decodeURIComponent(`${next}#${hash}`));
});
// to help prevent open redirect to a different url
it('should return / if next includes a protocol/hostname', () => {
const next = 'https://example.com/app/kibana';
const href = `/login?next=${next}`;
expect(parseNext(href)).toEqual('/');
});
// to help prevent open redirect to a different url by abusing encodings
it('should return / if including a protocol/host even if it is encoded', () => {
const baseUrl = 'http://example.com';
const next = `${encodeURIComponent(baseUrl)}%2Fapp%2Fkibana`;
const hash = '/discover/New-Saved-Search';
const href = `/login?next=${next}#${hash}`;
expect(parseNext(href)).toEqual('/');
});
// to help prevent open redirect to a different port
it('should return / if next includes a port', () => {
const next = 'http://localhost:5601/app/kibana';
const href = `/login?next=${next}`;
expect(parseNext(href)).toEqual('/');
});
// to help prevent open redirect to a different port by abusing encodings
it('should return / if including a port even if it is encoded', () => {
const baseUrl = 'http://example.com:5601';
const next = `${encodeURIComponent(baseUrl)}%2Fapp%2Fkibana`;
const hash = '/discover/New-Saved-Search';
const href = `/login?next=${next}#${hash}`;
expect(parseNext(href)).toEqual('/');
});
// disallow network-path references
it('should return / if next is url without protocol', () => {
const nextWithTwoSlashes = '//example.com';
const hrefWithTwoSlashes = `/login?next=${nextWithTwoSlashes}`;
expect(parseNext(hrefWithTwoSlashes)).toEqual('/');
const nextWithThreeSlashes = '///example.com';
const hrefWithThreeSlashes = `/login?next=${nextWithThreeSlashes}`;
expect(parseNext(hrefWithThreeSlashes)).toEqual('/');
});
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 { parse } from 'url';
import { isInternalURL } from './is_internal_url';
export function parseNext(href: string, basePath = '') {
const { query, hash } = parse(href, true);
if (!query.next) {
return `${basePath}/`;
}
let next: string;
if (Array.isArray(query.next) && query.next.length > 0) {
next = query.next[0];
} else {
next = query.next as string;
}
// validate that `next` is not attempting a redirect to somewhere
// outside of this Kibana install.
if (!isInternalURL(next, basePath)) {
return `${basePath}/`;
}
return next + (hash || '');
}