mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Space arbitrary destination support (#180872)
## Summary 1. Moved `parseNextURL ` and `isInternalURL` to `@kbn/std` package. 2. Added optional `next` query parameter for enter space endpoint to navigate to specified destination. 3. If `next` query parameter is malformed, we fall back to space `defaultRoute`. ### Checklist - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) __Fixes: https://github.com/elastic/kibana/issues/180711__ --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
5b40e10120
commit
f91f86b28c
17 changed files with 395 additions and 57 deletions
|
@ -16,6 +16,8 @@ export { pick } from './src/pick';
|
|||
export { withTimeout, isPromise } from './src/promise';
|
||||
export type { URLMeaningfulParts } from './src/url';
|
||||
export { isRelativeUrl, modifyUrl, getUrlOrigin } from './src/url';
|
||||
export { isInternalURL } from './src/is_internal_url';
|
||||
export { parseNextURL } from './src/parse_next_url';
|
||||
export { unset } from './src/unset';
|
||||
export { getFlattenedObject } from './src/get_flattened_object';
|
||||
export { ensureNoUnsafeProperties } from './src/ensure_no_unsafe_properties';
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { isInternalURL } from './is_internal_url';
|
|
@ -1,14 +1,18 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { parse } from 'url';
|
||||
import { parse as parseUrl } from 'url';
|
||||
|
||||
/**
|
||||
* Determine if url is outside of this Kibana install.
|
||||
*/
|
||||
export function isInternalURL(url: string, basePath = '') {
|
||||
const { protocol, hostname, port, pathname } = parse(
|
||||
const { protocol, hostname, port, pathname } = parseUrl(
|
||||
url,
|
||||
false /* parseQueryString */,
|
||||
true /* slashesDenoteHost */
|
||||
|
@ -27,6 +31,7 @@ export function isInternalURL(url: string, 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
|
|
@ -1,15 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { parseNext } from './parse_next';
|
||||
import { parseNextURL } from './parse_next_url';
|
||||
|
||||
describe('parseNext', () => {
|
||||
describe('parseNextURL', () => {
|
||||
it('should return a function', () => {
|
||||
expect(parseNext).toBeInstanceOf(Function);
|
||||
expect(parseNextURL).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
describe('with basePath defined', () => {
|
||||
|
@ -17,14 +18,14 @@ describe('parseNext', () => {
|
|||
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}/`);
|
||||
expect(parseNextURL(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);
|
||||
expect(parseNextURL(href, basePath)).toEqual(next);
|
||||
});
|
||||
|
||||
it('should properly handle next with hash', () => {
|
||||
|
@ -32,7 +33,7 @@ describe('parseNext', () => {
|
|||
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}`);
|
||||
expect(parseNextURL(href, basePath)).toEqual(`${next}#${hash}`);
|
||||
});
|
||||
|
||||
it('should properly handle multiple next with hash', () => {
|
||||
|
@ -41,7 +42,7 @@ describe('parseNext', () => {
|
|||
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}`);
|
||||
expect(parseNextURL(href, basePath)).toEqual(`${next1}#${hash}`);
|
||||
});
|
||||
|
||||
it('should properly decode special characters', () => {
|
||||
|
@ -49,7 +50,7 @@ describe('parseNext', () => {
|
|||
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}`));
|
||||
expect(parseNextURL(href, basePath)).toEqual(decodeURIComponent(`${next}#${hash}`));
|
||||
});
|
||||
|
||||
// to help prevent open redirect to a different url
|
||||
|
@ -57,7 +58,7 @@ describe('parseNext', () => {
|
|||
const basePath = '/iqf';
|
||||
const next = `https://example.com${basePath}/app/kibana`;
|
||||
const href = `${basePath}/login?next=${next}`;
|
||||
expect(parseNext(href, basePath)).toEqual(`${basePath}/`);
|
||||
expect(parseNextURL(href, basePath)).toEqual(`${basePath}/`);
|
||||
});
|
||||
|
||||
// to help prevent open redirect to a different url by abusing encodings
|
||||
|
@ -67,7 +68,7 @@ describe('parseNext', () => {
|
|||
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}/`);
|
||||
expect(parseNextURL(href, basePath)).toEqual(`${basePath}/`);
|
||||
});
|
||||
|
||||
// to help prevent open redirect to a different port
|
||||
|
@ -75,7 +76,7 @@ describe('parseNext', () => {
|
|||
const basePath = '/iqf';
|
||||
const next = `http://localhost:5601${basePath}/app/kibana`;
|
||||
const href = `${basePath}/login?next=${next}`;
|
||||
expect(parseNext(href, basePath)).toEqual(`${basePath}/`);
|
||||
expect(parseNextURL(href, basePath)).toEqual(`${basePath}/`);
|
||||
});
|
||||
|
||||
// to help prevent open redirect to a different port by abusing encodings
|
||||
|
@ -85,7 +86,7 @@ describe('parseNext', () => {
|
|||
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}/`);
|
||||
expect(parseNextURL(href, basePath)).toEqual(`${basePath}/`);
|
||||
});
|
||||
|
||||
// to help prevent open redirect to a different base path
|
||||
|
@ -93,18 +94,18 @@ describe('parseNext', () => {
|
|||
const basePath = '/iqf';
|
||||
const next = '/notbasepath/app/kibana';
|
||||
const href = `${basePath}/login?next=${next}`;
|
||||
expect(parseNext(href, basePath)).toEqual(`${basePath}/`);
|
||||
expect(parseNextURL(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('/');
|
||||
expect(parseNextURL(hrefWithTwoSlashes)).toEqual('/');
|
||||
|
||||
const nextWithThreeSlashes = '///example.com';
|
||||
const hrefWithThreeSlashes = `/login?next=${nextWithThreeSlashes}`;
|
||||
expect(parseNext(hrefWithThreeSlashes)).toEqual('/');
|
||||
expect(parseNextURL(hrefWithThreeSlashes)).toEqual('/');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -112,20 +113,20 @@ describe('parseNext', () => {
|
|||
// 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('/');
|
||||
expect(parseNextURL(href)).toEqual('/');
|
||||
});
|
||||
|
||||
it('should properly handle next without hash', () => {
|
||||
const next = '/app/kibana';
|
||||
const href = `/login?next=${next}`;
|
||||
expect(parseNext(href)).toEqual(next);
|
||||
expect(parseNextURL(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}`);
|
||||
expect(parseNextURL(href)).toEqual(`${next}#${hash}`);
|
||||
});
|
||||
|
||||
it('should properly handle multiple next with hash', () => {
|
||||
|
@ -133,21 +134,21 @@ describe('parseNext', () => {
|
|||
const next2 = '/app/ml';
|
||||
const hash = '/discover/New-Saved-Search';
|
||||
const href = `/login?next=${next1}&next=${next2}#${hash}`;
|
||||
expect(parseNext(href)).toEqual(`${next1}#${hash}`);
|
||||
expect(parseNextURL(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}`));
|
||||
expect(parseNextURL(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('/');
|
||||
expect(parseNextURL(href)).toEqual('/');
|
||||
});
|
||||
|
||||
// to help prevent open redirect to a different url by abusing encodings
|
||||
|
@ -156,14 +157,14 @@ describe('parseNext', () => {
|
|||
const next = `${encodeURIComponent(baseUrl)}%2Fapp%2Fkibana`;
|
||||
const hash = '/discover/New-Saved-Search';
|
||||
const href = `/login?next=${next}#${hash}`;
|
||||
expect(parseNext(href)).toEqual('/');
|
||||
expect(parseNextURL(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('/');
|
||||
expect(parseNextURL(href)).toEqual('/');
|
||||
});
|
||||
|
||||
// to help prevent open redirect to a different port by abusing encodings
|
||||
|
@ -172,18 +173,18 @@ describe('parseNext', () => {
|
|||
const next = `${encodeURIComponent(baseUrl)}%2Fapp%2Fkibana`;
|
||||
const hash = '/discover/New-Saved-Search';
|
||||
const href = `/login?next=${next}#${hash}`;
|
||||
expect(parseNext(href)).toEqual('/');
|
||||
expect(parseNextURL(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('/');
|
||||
expect(parseNextURL(hrefWithTwoSlashes)).toEqual('/');
|
||||
|
||||
const nextWithThreeSlashes = '///example.com';
|
||||
const hrefWithThreeSlashes = `/login?next=${nextWithThreeSlashes}`;
|
||||
expect(parseNext(hrefWithThreeSlashes)).toEqual('/');
|
||||
expect(parseNextURL(hrefWithThreeSlashes)).toEqual('/');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,19 +1,29 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { parse } from 'url';
|
||||
|
||||
import { NEXT_URL_QUERY_STRING_PARAMETER } from './constants';
|
||||
import { isInternalURL } from './is_internal_url';
|
||||
|
||||
export function parseNext(href: string, basePath = '') {
|
||||
const DEFAULT_NEXT_URL_QUERY_STRING_PARAMETER = 'next';
|
||||
|
||||
/**
|
||||
* Parse the url value from query param. By default
|
||||
*
|
||||
* By default query param is set to next.
|
||||
*/
|
||||
export function parseNextURL(
|
||||
href: string,
|
||||
basePath = '',
|
||||
nextUrlQueryParam = DEFAULT_NEXT_URL_QUERY_STRING_PARAMETER
|
||||
) {
|
||||
const { query, hash } = parse(href, true);
|
||||
|
||||
let next = query[NEXT_URL_QUERY_STRING_PARAMETER];
|
||||
let next = query[nextUrlQueryParam];
|
||||
if (!next) {
|
||||
return `${basePath}/`;
|
||||
}
|
|
@ -30,9 +30,9 @@ import type {
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||
import { parseNextURL } from '@kbn/std';
|
||||
|
||||
import type { StartServices } from '../..';
|
||||
import { parseNext } from '../../../common/parse_next';
|
||||
import { AuthenticationStatePage } from '../components';
|
||||
|
||||
interface Props {
|
||||
|
@ -59,7 +59,7 @@ export function AccessAgreementPage({ http, fatalErrors, notifications }: Props)
|
|||
try {
|
||||
setIsLoading(true);
|
||||
await http.post('/internal/security/access_agreement/acknowledge');
|
||||
window.location.href = parseNext(window.location.href, http.basePath.serverBasePath);
|
||||
window.location.href = parseNextURL(window.location.href, http.basePath.serverBasePath);
|
||||
} catch (err) {
|
||||
notifications.toasts.addError(err, {
|
||||
title: i18n.translate('xpack.security.accessAgreement.acknowledgeErrorMessage', {
|
||||
|
|
|
@ -47,9 +47,9 @@ export const captureURLApp = Object.freeze({
|
|||
try {
|
||||
// This is an async import because it requires `url`, which is a sizable dependency.
|
||||
// Otherwise this becomes part of the "page load bundle".
|
||||
const { parseNext } = await import('../../../common/parse_next');
|
||||
const { parseNextURL } = await import('@kbn/std');
|
||||
const url = new URL(
|
||||
parseNext(window.location.href, http.basePath.serverBasePath),
|
||||
parseNextURL(window.location.href, http.basePath.serverBasePath),
|
||||
window.location.origin
|
||||
);
|
||||
url.searchParams.append(AUTH_URL_HASH_QUERY_STRING_PARAMETER, window.location.hash);
|
||||
|
|
|
@ -13,9 +13,9 @@ import useObservable from 'react-use/lib/useObservable';
|
|||
import type { AppMountParameters, CustomBrandingStart, IBasePath } from '@kbn/core/public';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||
import { parseNextURL } from '@kbn/std';
|
||||
|
||||
import type { StartServices } from '../..';
|
||||
import { parseNext } from '../../../common/parse_next';
|
||||
import { AuthenticationStatePage } from '../components';
|
||||
|
||||
interface Props {
|
||||
|
@ -35,7 +35,7 @@ export function LoggedOutPage({ basePath, customBranding }: Props) {
|
|||
}
|
||||
logo={customBrandingValue?.logo}
|
||||
>
|
||||
<EuiButton href={parseNext(window.location.href, basePath.serverBasePath)}>
|
||||
<EuiButton href={parseNextURL(window.location.href, basePath.serverBasePath)}>
|
||||
<FormattedMessage id="xpack.security.loggedOut.login" defaultMessage="Log in" />
|
||||
</EuiButton>
|
||||
</AuthenticationStatePage>
|
||||
|
|
|
@ -13,9 +13,9 @@ import type { AppMountParameters, IBasePath } from '@kbn/core/public';
|
|||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||
import type { AuthenticationServiceSetup } from '@kbn/security-plugin-types-public';
|
||||
import { parseNextURL } from '@kbn/std';
|
||||
|
||||
import type { StartServices } from '../..';
|
||||
import { parseNext } from '../../../common/parse_next';
|
||||
import { AuthenticationStatePage } from '../components';
|
||||
|
||||
interface Props {
|
||||
|
@ -42,7 +42,7 @@ export function OverwrittenSessionPage({ authc, basePath }: Props) {
|
|||
/>
|
||||
}
|
||||
>
|
||||
<EuiButton href={parseNext(window.location.href, basePath.serverBasePath)}>
|
||||
<EuiButton href={parseNextURL(window.location.href, basePath.serverBasePath)}>
|
||||
<FormattedMessage
|
||||
id="xpack.security.overwrittenSession.continueAsUserText"
|
||||
defaultMessage="Continue as {username}"
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import Boom from '@hapi/boom';
|
||||
|
||||
import type { KibanaRequest } from '@kbn/core/server';
|
||||
import { isInternalURL } from '@kbn/std';
|
||||
|
||||
import type { AuthenticationProviderOptions } from './base';
|
||||
import { BaseAuthenticationProvider } from './base';
|
||||
|
@ -16,7 +17,6 @@ import {
|
|||
AUTH_URL_HASH_QUERY_STRING_PARAMETER,
|
||||
NEXT_URL_QUERY_STRING_PARAMETER,
|
||||
} from '../../../common/constants';
|
||||
import { isInternalURL } from '../../../common/is_internal_url';
|
||||
import type { AuthenticationInfo } from '../../elasticsearch';
|
||||
import { getDetailedErrorMessage } from '../../errors';
|
||||
import { AuthenticationResult } from '../authentication_result';
|
||||
|
|
|
@ -7,9 +7,9 @@
|
|||
|
||||
import type { TypeOf } from '@kbn/config-schema';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { parseNextURL } from '@kbn/std';
|
||||
|
||||
import type { RouteDefinitionParams } from '..';
|
||||
import { parseNext } from '../../../common/parse_next';
|
||||
import {
|
||||
BasicAuthenticationProvider,
|
||||
canRedirectRequest,
|
||||
|
@ -157,7 +157,7 @@ export function defineCommonRoutes({
|
|||
},
|
||||
createLicensedRouteHandler(async (context, request, response) => {
|
||||
const { providerType, providerName, currentURL, params } = request.body;
|
||||
const redirectURL = parseNext(currentURL, basePath.serverBasePath);
|
||||
const redirectURL = parseNextURL(currentURL, basePath.serverBasePath);
|
||||
const authenticationResult = await getAuthenticationService().login(request, {
|
||||
provider: { name: providerName },
|
||||
redirectURL,
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { parseNextURL } from '@kbn/std';
|
||||
|
||||
import type { RouteDefinitionParams } from '..';
|
||||
import {
|
||||
|
@ -14,7 +15,6 @@ import {
|
|||
} from '../../../common/constants';
|
||||
import type { LoginState } from '../../../common/login_state';
|
||||
import { shouldProviderUseLoginForm } from '../../../common/model';
|
||||
import { parseNext } from '../../../common/parse_next';
|
||||
|
||||
/**
|
||||
* Defines routes required for the Login view.
|
||||
|
@ -48,7 +48,7 @@ export function defineLoginRoutes({
|
|||
if (isUserAlreadyLoggedIn || !shouldShowLogin) {
|
||||
logger.debug('User is already authenticated, redirecting...');
|
||||
return response.redirected({
|
||||
headers: { location: parseNext(request.url?.href ?? '', basePath.serverBasePath) },
|
||||
headers: { location: parseNextURL(request.url?.href ?? '', basePath.serverBasePath) },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
240
x-pack/plugins/spaces/server/routes/views/index.test.ts
Normal file
240
x-pack/plugins/spaces/server/routes/views/index.test.ts
Normal file
|
@ -0,0 +1,240 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Type } from '@kbn/config-schema';
|
||||
import type {
|
||||
HttpResources,
|
||||
HttpResourcesRequestHandler,
|
||||
RequestHandlerContext,
|
||||
RouteConfig,
|
||||
} from '@kbn/core/server';
|
||||
import {
|
||||
coreMock,
|
||||
httpResourcesMock,
|
||||
httpServerMock,
|
||||
httpServiceMock,
|
||||
loggingSystemMock,
|
||||
uiSettingsServiceMock,
|
||||
} from '@kbn/core/server/mocks';
|
||||
import type { DeeplyMockedKeys } from '@kbn/utility-types-jest';
|
||||
|
||||
import type { ViewRouteDeps } from '.';
|
||||
import { initSpacesViewsRoutes } from '.';
|
||||
import { ENTER_SPACE_PATH } from '../../../common';
|
||||
|
||||
const routeDefinitionParamsMock = {
|
||||
create: () => {
|
||||
uiSettingsServiceMock.createStartContract();
|
||||
|
||||
return {
|
||||
basePath: httpServiceMock.createBasePath(),
|
||||
logger: loggingSystemMock.create().get(),
|
||||
httpResources: httpResourcesMock.createRegistrar(),
|
||||
} as unknown as DeeplyMockedKeys<ViewRouteDeps>;
|
||||
},
|
||||
};
|
||||
|
||||
describe('Space Selector view routes', () => {
|
||||
let httpResources: jest.Mocked<HttpResources>;
|
||||
beforeEach(() => {
|
||||
const routeParamsMock = routeDefinitionParamsMock.create();
|
||||
httpResources = routeParamsMock.httpResources;
|
||||
|
||||
initSpacesViewsRoutes(routeParamsMock);
|
||||
});
|
||||
|
||||
let routeHandler: HttpResourcesRequestHandler<any, any, any>;
|
||||
let routeConfig: RouteConfig<any, any, any, 'get'>;
|
||||
beforeEach(() => {
|
||||
const [viewRouteConfig, viewRouteHandler] = httpResources.register.mock.calls.find(
|
||||
([{ path }]) => path === '/spaces/space_selector'
|
||||
)!;
|
||||
|
||||
routeConfig = viewRouteConfig;
|
||||
routeHandler = viewRouteHandler;
|
||||
});
|
||||
|
||||
it('correctly defines route.', () => {
|
||||
expect(routeConfig.options).toBeUndefined();
|
||||
expect(routeConfig.validate).toBe(false);
|
||||
});
|
||||
|
||||
it('renders view.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
const responseFactory = httpResourcesMock.createResponseFactory();
|
||||
|
||||
await routeHandler({} as unknown as RequestHandlerContext, request, responseFactory);
|
||||
|
||||
expect(responseFactory.renderCoreApp).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enter Space view routes', () => {
|
||||
let httpResources: jest.Mocked<HttpResources>;
|
||||
beforeEach(() => {
|
||||
const routeParamsMock = routeDefinitionParamsMock.create();
|
||||
httpResources = routeParamsMock.httpResources;
|
||||
|
||||
initSpacesViewsRoutes(routeParamsMock);
|
||||
});
|
||||
|
||||
let routeHandler: HttpResourcesRequestHandler<any, any, any>;
|
||||
let routeConfig: RouteConfig<any, any, any, 'get'>;
|
||||
beforeEach(() => {
|
||||
const [viewRouteConfig, viewRouteHandler] = httpResources.register.mock.calls.find(
|
||||
([{ path }]) => path === ENTER_SPACE_PATH
|
||||
)!;
|
||||
|
||||
routeConfig = viewRouteConfig;
|
||||
routeHandler = viewRouteHandler;
|
||||
});
|
||||
|
||||
it('correctly defines route.', () => {
|
||||
expect(routeConfig.validate).toEqual({
|
||||
body: undefined,
|
||||
query: expect.any(Type),
|
||||
params: undefined,
|
||||
});
|
||||
|
||||
const queryValidator = (routeConfig.validate as any).query as Type<any>;
|
||||
expect(queryValidator.validate({})).toEqual({});
|
||||
expect(queryValidator.validate({ next: '/some-url', something: 'something' })).toEqual({
|
||||
next: '/some-url',
|
||||
});
|
||||
});
|
||||
|
||||
it('correctly enters space default route.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
const responseFactory = httpResourcesMock.createResponseFactory();
|
||||
const contextMock = coreMock.createRequestHandlerContext();
|
||||
|
||||
contextMock.uiSettings.client.get.mockResolvedValue('/home');
|
||||
|
||||
await routeHandler(
|
||||
{ core: contextMock } as unknown as RequestHandlerContext,
|
||||
request,
|
||||
responseFactory
|
||||
);
|
||||
|
||||
expect(responseFactory.redirected).toHaveBeenCalledWith({
|
||||
headers: { location: '/mock-server-basepath/home' },
|
||||
});
|
||||
});
|
||||
|
||||
it('correctly enters space with specified route.', async () => {
|
||||
const nextRoute = '/app/management/kibana/objects';
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
query: {
|
||||
next: nextRoute,
|
||||
},
|
||||
});
|
||||
|
||||
const responseFactory = httpResourcesMock.createResponseFactory();
|
||||
const contextMock = coreMock.createRequestHandlerContext();
|
||||
|
||||
await routeHandler(
|
||||
{ core: contextMock } as unknown as RequestHandlerContext,
|
||||
request,
|
||||
responseFactory
|
||||
);
|
||||
|
||||
expect(responseFactory.redirected).toHaveBeenCalledWith({
|
||||
headers: { location: `/mock-server-basepath${nextRoute}` },
|
||||
});
|
||||
});
|
||||
|
||||
it('correctly enters space with specified route without leading slash.', async () => {
|
||||
const nextRoute = 'app/management/kibana/objects';
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
query: {
|
||||
next: nextRoute,
|
||||
},
|
||||
});
|
||||
|
||||
const responseFactory = httpResourcesMock.createResponseFactory();
|
||||
const contextMock = coreMock.createRequestHandlerContext();
|
||||
|
||||
await routeHandler(
|
||||
{ core: contextMock } as unknown as RequestHandlerContext,
|
||||
request,
|
||||
responseFactory
|
||||
);
|
||||
|
||||
expect(responseFactory.redirected).toHaveBeenCalledWith({
|
||||
headers: { location: `/mock-server-basepath/${nextRoute}` },
|
||||
});
|
||||
});
|
||||
|
||||
it('correctly enters space and normalizes specified route.', async () => {
|
||||
const responseFactory = httpResourcesMock.createResponseFactory();
|
||||
const contextMock = coreMock.createRequestHandlerContext();
|
||||
|
||||
for (const { query, expectedLocation } of [
|
||||
{
|
||||
query: {
|
||||
next: '/app/../app/management/kibana/objects',
|
||||
},
|
||||
expectedLocation: '/mock-server-basepath/app/management/kibana/objects',
|
||||
},
|
||||
{
|
||||
query: {
|
||||
next: '../../app/../app/management/kibana/objects',
|
||||
},
|
||||
expectedLocation: '/mock-server-basepath/app/management/kibana/objects',
|
||||
},
|
||||
{
|
||||
query: {
|
||||
next: '/../../app/home',
|
||||
},
|
||||
expectedLocation: '/mock-server-basepath/app/home',
|
||||
},
|
||||
{
|
||||
query: {
|
||||
next: '/app/management/kibana/objects/../../kibana/home',
|
||||
},
|
||||
expectedLocation: '/mock-server-basepath/app/management/kibana/home',
|
||||
},
|
||||
]) {
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
query,
|
||||
});
|
||||
await routeHandler(
|
||||
{ core: contextMock } as unknown as RequestHandlerContext,
|
||||
request,
|
||||
responseFactory
|
||||
);
|
||||
|
||||
expect(responseFactory.redirected).toHaveBeenCalledWith({
|
||||
headers: { location: expectedLocation },
|
||||
});
|
||||
|
||||
responseFactory.redirected.mockClear();
|
||||
}
|
||||
});
|
||||
|
||||
it('correctly enters space with default route if specificed route is not relative.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
query: {
|
||||
next: 'http://evil.com/mock-server-basepath/app/kibana',
|
||||
},
|
||||
});
|
||||
|
||||
const responseFactory = httpResourcesMock.createResponseFactory();
|
||||
const contextMock = coreMock.createRequestHandlerContext();
|
||||
contextMock.uiSettings.client.get.mockResolvedValue('/home');
|
||||
|
||||
await routeHandler(
|
||||
{ core: contextMock } as unknown as RequestHandlerContext,
|
||||
request,
|
||||
responseFactory
|
||||
);
|
||||
|
||||
expect(responseFactory.redirected).toHaveBeenCalledWith({
|
||||
headers: { location: '/mock-server-basepath/home' },
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,7 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { HttpResources, IBasePath, Logger } from '@kbn/core/server';
|
||||
import { parseNextURL } from '@kbn/std';
|
||||
|
||||
import { ENTER_SPACE_PATH } from '../../../common';
|
||||
import { wrapError } from '../../lib/errors';
|
||||
|
@ -23,18 +25,28 @@ export function initSpacesViewsRoutes(deps: ViewRouteDeps) {
|
|||
);
|
||||
|
||||
deps.httpResources.register(
|
||||
{ path: ENTER_SPACE_PATH, validate: false },
|
||||
{
|
||||
path: ENTER_SPACE_PATH,
|
||||
validate: {
|
||||
query: schema.maybe(
|
||||
schema.object({ next: schema.maybe(schema.string()) }, { unknowns: 'ignore' })
|
||||
),
|
||||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
try {
|
||||
const { uiSettings } = await context.core;
|
||||
const defaultRoute = await uiSettings.client.get<string>('defaultRoute');
|
||||
|
||||
const basePath = deps.basePath.get(request);
|
||||
const url = `${basePath}${defaultRoute}`;
|
||||
const nextCandidateRoute = parseNextURL(request.url.href);
|
||||
|
||||
const route = nextCandidateRoute === '/' ? defaultRoute : nextCandidateRoute;
|
||||
// need to get reed of ../../ to make sure we will not be out of space basePath
|
||||
const normalizedRoute = new URL(route, 'https://localhost').pathname;
|
||||
|
||||
return response.redirected({
|
||||
headers: {
|
||||
location: url,
|
||||
location: `${basePath}${normalizedRoute}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
"@kbn/shared-ux-avatar-solution",
|
||||
"@kbn/core-http-server",
|
||||
"@kbn/react-kibana-context-render",
|
||||
"@kbn/utility-types-jest",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -15,6 +15,7 @@ export default function enterSpaceFunctionalTests({
|
|||
const kibanaServer = getService('kibanaServer');
|
||||
const PageObjects = getPageObjects(['security', 'spaceSelector']);
|
||||
const spacesService = getService('spaces');
|
||||
const browser = getService('browser');
|
||||
|
||||
describe('Enter Space', function () {
|
||||
this.tags('includeFirefox');
|
||||
|
@ -81,5 +82,58 @@ export default function enterSpaceFunctionalTests({
|
|||
await PageObjects.spaceSelector.clickSpaceAvatar(newSpaceId);
|
||||
await PageObjects.spaceSelector.expectHomePage(newSpaceId);
|
||||
});
|
||||
|
||||
it('allows user to navigate to different space with provided next route', async () => {
|
||||
const spaceId = 'another-space';
|
||||
|
||||
await PageObjects.security.login(undefined, undefined, {
|
||||
expectSpaceSelector: true,
|
||||
});
|
||||
|
||||
const anchorElement = await PageObjects.spaceSelector.getSpaceCardAnchor(spaceId);
|
||||
const path = await anchorElement.getAttribute('href');
|
||||
|
||||
const pathWithNextRoute = `${path}?next=/app/management/kibana/objects`;
|
||||
|
||||
await browser.navigateTo(pathWithNextRoute);
|
||||
|
||||
await PageObjects.spaceSelector.expectRoute(spaceId, '/app/management/kibana/objects');
|
||||
});
|
||||
|
||||
it('allows user to navigate to different space with provided next route, route is normalized', async () => {
|
||||
const spaceId = 'another-space';
|
||||
|
||||
await PageObjects.security.login(undefined, undefined, {
|
||||
expectSpaceSelector: true,
|
||||
});
|
||||
|
||||
const anchorElement = await PageObjects.spaceSelector.getSpaceCardAnchor(spaceId);
|
||||
const path = await anchorElement.getAttribute('href');
|
||||
|
||||
const pathWithNextRoute = `${path}?next=${encodeURIComponent(
|
||||
'/../../../app/management/kibana/objects'
|
||||
)}`;
|
||||
|
||||
await browser.navigateTo(pathWithNextRoute);
|
||||
|
||||
await PageObjects.spaceSelector.expectRoute(spaceId, '/app/management/kibana/objects');
|
||||
});
|
||||
|
||||
it('falls back to the default home page if provided next route is malformed', async () => {
|
||||
const spaceId = 'another-space';
|
||||
|
||||
await PageObjects.security.login(undefined, undefined, {
|
||||
expectSpaceSelector: true,
|
||||
});
|
||||
|
||||
const anchorElement = await PageObjects.spaceSelector.getSpaceCardAnchor(spaceId);
|
||||
const path = await anchorElement.getAttribute('href');
|
||||
|
||||
const pathWithNextRoute = `${path}?next=http://example.com/evil`;
|
||||
|
||||
await browser.navigateTo(pathWithNextRoute);
|
||||
|
||||
await PageObjects.spaceSelector.expectRoute(spaceId, '/app/canvas');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -29,6 +29,18 @@ export class SpaceSelectorPageObject extends FtrService {
|
|||
});
|
||||
}
|
||||
|
||||
async getSpaceCardAnchor(spaceId: string) {
|
||||
return await this.retry.try(async () => {
|
||||
this.log.info(`SpaceSelectorPage:getSpaceCardAnchor(${spaceId})`);
|
||||
const testSubjId = `space-card-${spaceId}`;
|
||||
const anchorElement = await this.find.byCssSelector(
|
||||
`[data-test-subj="${testSubjId}"] .euiCard__titleAnchor`
|
||||
);
|
||||
|
||||
return anchorElement;
|
||||
});
|
||||
}
|
||||
|
||||
async expectHomePage(spaceId: string) {
|
||||
return await this.expectRoute(spaceId, `/app/home#/`);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue