From 6a529d072039f8c70099cf8bcca8055f643cd483 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 26 Jun 2020 10:38:54 +0200 Subject: [PATCH] [6.8] Support deep links inside of `RelayState` for SAML IdP initiated login. (#69663) --- x-pack/plugins/security/index.js | 17 ++ .../lib/authentication/authenticator.js | 26 ++- .../providers/__tests__/saml.js | 138 +++++++++++++ .../lib/authentication/providers/saml.js | 46 ++++- .../server/lib/is_internal_url.test.ts | 88 ++++++++ .../lib/{parse_next.js => is_internal_url.ts} | 28 +-- .../security/server/lib/parse_next.test.ts | 188 ++++++++++++++++++ .../plugins/security/server/lib/parse_next.ts | 30 +++ 8 files changed, 538 insertions(+), 23 deletions(-) create mode 100644 x-pack/plugins/security/server/lib/is_internal_url.test.ts rename x-pack/plugins/security/server/lib/{parse_next.js => is_internal_url.ts} (57%) create mode 100644 x-pack/plugins/security/server/lib/parse_next.test.ts create mode 100644 x-pack/plugins/security/server/lib/parse_next.ts diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index cae1bdc2bcd58b..a5c6ae4090353b 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -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'], diff --git a/x-pack/plugins/security/server/lib/authentication/authenticator.js b/x-pack/plugins/security/server/lib/authentication/authenticator.js index b8ea329227738d..9da60b8c541c0b 100644 --- a/x-pack/plugins/security/server/lib/authentication/authenticator.js +++ b/x-pack/plugins/security/server/lib/authentication/authenticator.js @@ -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); } /** diff --git a/x-pack/plugins/security/server/lib/authentication/providers/__tests__/saml.js b/x-pack/plugins/security/server/lib/authentication/providers/__tests__/saml.js index 6f7790a90d9717..4b88013ebd40e0 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/__tests__/saml.js +++ b/x-pack/plugins/security/server/lib/authentication/providers/__tests__/saml.js @@ -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' } }); diff --git a/x-pack/plugins/security/server/lib/authentication/providers/saml.js b/x-pack/plugins/security/server/lib/authentication/providers/saml.js index 8332cc96e242b3..dd77bc2a28b83b 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/saml.js +++ b/x-pack/plugins/security/server/lib/authentication/providers/saml.js @@ -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) { diff --git a/x-pack/plugins/security/server/lib/is_internal_url.test.ts b/x-pack/plugins/security/server/lib/is_internal_url.test.ts new file mode 100644 index 00000000000000..7e9f63f069fd05 --- /dev/null +++ b/x-pack/plugins/security/server/lib/is_internal_url.test.ts @@ -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); + }); + }); +}); diff --git a/x-pack/plugins/security/server/lib/parse_next.js b/x-pack/plugins/security/server/lib/is_internal_url.ts similarity index 57% rename from x-pack/plugins/security/server/lib/parse_next.js rename to x-pack/plugins/security/server/lib/is_internal_url.ts index c247043876c91b..3df03e83247709 100644 --- a/x-pack/plugins/security/server/lib/parse_next.js +++ b/x-pack/plugins/security/server/lib/is_internal_url.ts @@ -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; } diff --git a/x-pack/plugins/security/server/lib/parse_next.test.ts b/x-pack/plugins/security/server/lib/parse_next.test.ts new file mode 100644 index 00000000000000..11a843d397dedc --- /dev/null +++ b/x-pack/plugins/security/server/lib/parse_next.test.ts @@ -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('/'); + }); + }); +}); diff --git a/x-pack/plugins/security/server/lib/parse_next.ts b/x-pack/plugins/security/server/lib/parse_next.ts new file mode 100644 index 00000000000000..7ce0de05ad526d --- /dev/null +++ b/x-pack/plugins/security/server/lib/parse_next.ts @@ -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 || ''); +}