mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[6.8] Support deep links inside of RelayState
for SAML IdP initiated login. (#69663)
This commit is contained in:
parent
058e535172
commit
6a529d0720
8 changed files with 538 additions and 23 deletions
|
@ -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'],
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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' } });
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
88
x-pack/plugins/security/server/lib/is_internal_url.test.ts
Normal file
88
x-pack/plugins/security/server/lib/is_internal_url.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
188
x-pack/plugins/security/server/lib/parse_next.test.ts
Normal file
188
x-pack/plugins/security/server/lib/parse_next.test.ts
Normal 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('/');
|
||||
});
|
||||
});
|
||||
});
|
30
x-pack/plugins/security/server/lib/parse_next.ts
Normal file
30
x-pack/plugins/security/server/lib/parse_next.ts
Normal 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 || '');
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue