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:
elena-shostak 2024-04-22 12:11:50 +02:00 committed by GitHub
parent 5b40e10120
commit f91f86b28c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 395 additions and 57 deletions

View file

@ -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';

View file

@ -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';

View file

@ -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

View file

@ -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('/');
});
});
});

View file

@ -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}/`;
}

View file

@ -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', {

View file

@ -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);

View file

@ -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>

View file

@ -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}"

View file

@ -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';

View file

@ -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,

View file

@ -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) },
});
}

View 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' },
});
});
});

View file

@ -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) {

View file

@ -34,6 +34,7 @@
"@kbn/shared-ux-avatar-solution",
"@kbn/core-http-server",
"@kbn/react-kibana-context-render",
"@kbn/utility-types-jest",
],
"exclude": [
"target/**/*",

View file

@ -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');
});
});
}

View file

@ -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#/`);
}