diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.isinternalurl.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.isinternalurl.md
new file mode 100644
index 000000000000..85f2cefc1f26
--- /dev/null
+++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.isinternalurl.md
@@ -0,0 +1,24 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) > [isInternalUrl](./kibana-plugin-core-public.iexternalurl.isinternalurl.md)
+
+## IExternalUrl.isInternalUrl() method
+
+Determines if the provided URL is an internal url.
+
+**Signature:**
+
+```typescript
+isInternalUrl(relativeOrAbsoluteUrl: string): boolean;
+```
+
+## Parameters
+
+| Parameter | Type | Description |
+| --- | --- | --- |
+| relativeOrAbsoluteUrl | string | |
+
+**Returns:**
+
+boolean
+
diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md
index 09c32f68cd6f..757080090a04 100644
--- a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md
+++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md
@@ -16,5 +16,6 @@ export interface IExternalUrl
| Method | Description |
| --- | --- |
+| [isInternalUrl(relativeOrAbsoluteUrl)](./kibana-plugin-core-public.iexternalurl.isinternalurl.md) | Determines if the provided URL is an internal url. |
| [validateUrl(relativeOrAbsoluteUrl)](./kibana-plugin-core-public.iexternalurl.validateurl.md) |
Determines if the provided URL is a valid location to send users. Validation is based on the configured allow list in kibana.yml.
If the URL is valid, then a URL will be returned. Otherwise, this will return null.
|
diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequest._inspect.custom_.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequest._inspect.custom_.md
new file mode 100644
index 000000000000..e57ef2b88f24
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequest._inspect.custom_.md
@@ -0,0 +1,28 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) > [\[inspect.custom\]](./kibana-plugin-core-server.kibanarequest._inspect.custom_.md)
+
+## KibanaRequest.\[inspect.custom\]() method
+
+**Signature:**
+
+```typescript
+[inspect.custom](): {
+ id: string;
+ uuid: string;
+ url: string;
+ isSystemRequest: boolean;
+ auth: {
+ isAuthenticated: boolean;
+ };
+ route: Readonly<{
+ path: string;
+ method: RecursiveReadonly;
+ options: RecursiveReadonly>;
+ }>;
+ };
+```
+**Returns:**
+
+{ id: string; uuid: string; url: string; isSystemRequest: boolean; auth: { isAuthenticated: boolean; }; route: Readonly<{ path: string; method: RecursiveReadonly<Method>; options: RecursiveReadonly<KibanaRequestRouteOptions<Method>>; }>; }
+
diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tojson.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tojson.md
new file mode 100644
index 000000000000..22d08274ef78
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tojson.md
@@ -0,0 +1,28 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) > [toJSON](./kibana-plugin-core-server.kibanarequest.tojson.md)
+
+## KibanaRequest.toJSON() method
+
+**Signature:**
+
+```typescript
+toJSON(): {
+ id: string;
+ uuid: string;
+ url: string;
+ isSystemRequest: boolean;
+ auth: {
+ isAuthenticated: boolean;
+ };
+ route: Readonly<{
+ path: string;
+ method: RecursiveReadonly;
+ options: RecursiveReadonly>;
+ }>;
+ };
+```
+**Returns:**
+
+{ id: string; uuid: string; url: string; isSystemRequest: boolean; auth: { isAuthenticated: boolean; }; route: Readonly<{ path: string; method: RecursiveReadonly<Method>; options: RecursiveReadonly<KibanaRequestRouteOptions<Method>>; }>; }
+
diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tostring.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tostring.md
new file mode 100644
index 000000000000..f7ef9011c408
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tostring.md
@@ -0,0 +1,15 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) > [toString](./kibana-plugin-core-server.kibanarequest.tostring.md)
+
+## KibanaRequest.toString() method
+
+**Signature:**
+
+```typescript
+toString(): string;
+```
+**Returns:**
+
+string
+
diff --git a/src/core/public/http/external_url_service.test.ts b/src/core/public/http/external_url_service.test.ts
index ee757c504676..4ce3709ff636 100644
--- a/src/core/public/http/external_url_service.test.ts
+++ b/src/core/public/http/external_url_service.test.ts
@@ -73,6 +73,23 @@ const internalRequestScenarios = [
];
describe('External Url Service', () => {
+ describe('#isInternalUrl', () => {
+ const { setup } = setupService({
+ location: new URL('https://example.com/app/management?q=1&bar=false#some-hash'),
+ serverBasePath: '',
+ policy: [],
+ });
+
+ it('internal request', () => {
+ expect(setup.isInternalUrl('/')).toBeTruthy();
+ expect(setup.isInternalUrl('https://example.com/')).toBeTruthy();
+ });
+
+ it('external request', () => {
+ expect(setup.isInternalUrl('https://elastic.co/')).toBeFalsy();
+ });
+ });
+
describe('#validateUrl', () => {
describe('internal requests with a server base path', () => {
const serverBasePath = '/base-path';
diff --git a/src/core/public/http/external_url_service.ts b/src/core/public/http/external_url_service.ts
index 166e167b3b99..0fb1c85d4825 100644
--- a/src/core/public/http/external_url_service.ts
+++ b/src/core/public/http/external_url_service.ts
@@ -50,20 +50,33 @@ function normalizeProtocol(protocol: string) {
return protocol.endsWith(':') ? protocol.slice(0, -1).toLowerCase() : protocol.toLowerCase();
}
+const createIsInternalUrlValidation = (
+ location: Pick,
+ serverBasePath: string
+) => {
+ return function isInternallUrl(next: string) {
+ const base = new URL(location.href);
+ const url = new URL(next, base);
+
+ return (
+ url.origin === base.origin &&
+ (!serverBasePath || url.pathname.startsWith(`${serverBasePath}/`))
+ );
+ };
+};
+
const createExternalUrlValidation = (
rules: IExternalUrlPolicy[],
location: Pick,
serverBasePath: string
) => {
+ const isInternalUrl = createIsInternalUrlValidation(location, serverBasePath);
+
return function validateExternalUrl(next: string) {
const base = new URL(location.href);
const url = new URL(next, base);
- const isInternalURL =
- url.origin === base.origin &&
- (!serverBasePath || url.pathname.startsWith(`${serverBasePath}/`));
-
- if (isInternalURL) {
+ if (isInternalUrl(next)) {
return url;
}
@@ -90,6 +103,7 @@ export class ExternalUrlService implements CoreService {
const { policy } = injectedMetadata.getExternalUrlConfig();
return {
+ isInternalUrl: createIsInternalUrlValidation(location, serverBasePath),
validateUrl: createExternalUrlValidation(policy, location, serverBasePath),
};
}
diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts
index fff99d84a76a..4bfecd7ed74d 100644
--- a/src/core/public/http/http_service.mock.ts
+++ b/src/core/public/http/http_service.mock.ts
@@ -37,6 +37,7 @@ const createServiceMock = ({
},
externalUrl: {
validateUrl: jest.fn(),
+ isInternalUrl: jest.fn(),
},
addLoadingCountSource: jest.fn(),
getLoadingCount$: jest.fn().mockReturnValue(new BehaviorSubject(0)),
diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts
index 3eb718b318f8..71df131161c5 100644
--- a/src/core/public/http/types.ts
+++ b/src/core/public/http/types.ts
@@ -110,6 +110,13 @@ export interface IBasePath {
* @public
*/
export interface IExternalUrl {
+ /**
+ * Determines if the provided URL is an internal url.
+ *
+ * @param relativeOrAbsoluteUrl
+ */
+ isInternalUrl(relativeOrAbsoluteUrl: string): boolean;
+
/**
* Determines if the provided URL is a valid location to send users.
* Validation is based on the configured allow list in kibana.yml.
diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md
index 4a1ad4238d31..de706ebd1296 100644
--- a/src/core/public/public.api.md
+++ b/src/core/public/public.api.md
@@ -950,6 +950,7 @@ export interface IBasePath {
// @public
export interface IExternalUrl {
+ isInternalUrl(relativeOrAbsoluteUrl: string): boolean;
validateUrl(relativeOrAbsoluteUrl: string): URL | null;
}
diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap
index 70a21438754b..e416dced4f8a 100644
--- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap
+++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap
@@ -19,6 +19,7 @@ exports[`DashboardEmptyScreen renders correctly with edit mode 1`] = `
},
"delete": [MockFunction],
"externalUrl": Object {
+ "isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],
@@ -343,6 +344,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = `
},
"delete": [MockFunction],
"externalUrl": Object {
+ "isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],
@@ -706,6 +708,7 @@ exports[`DashboardEmptyScreen renders correctly with view mode 1`] = `
},
"delete": [MockFunction],
"externalUrl": Object {
+ "isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],
diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap
index f38bdb9ac53f..1f8ab453a100 100644
--- a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap
+++ b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap
@@ -396,6 +396,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th
},
"delete": [MockFunction],
"externalUrl": Object {
+ "isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],
@@ -463,6 +464,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th
},
"delete": [MockFunction],
"externalUrl": Object {
+ "isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],
@@ -530,6 +532,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th
},
"delete": [MockFunction],
"externalUrl": Object {
+ "isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap
index bfdcda0135ef..956b0b0d89ed 100644
--- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap
+++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap
@@ -182,6 +182,7 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = `
},
"delete": [MockFunction],
"externalUrl": Object {
+ "isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],
diff --git a/src/plugins/share/jest.integration.config.js b/src/plugins/share/jest.integration.config.js
new file mode 100644
index 000000000000..9cae3c1afc89
--- /dev/null
+++ b/src/plugins/share/jest.integration.config.js
@@ -0,0 +1,13 @@
+/*
+ * 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 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.
+ */
+
+module.exports = {
+ preset: '@kbn/test/jest_integration',
+ rootDir: '../../..',
+ roots: ['/src/plugins/share'],
+};
diff --git a/src/plugins/share/public/services/short_url_redirect_app.ts b/src/plugins/share/public/services/short_url_redirect_app.ts
index b647e38fc148..1df02e32bf0a 100644
--- a/src/plugins/share/public/services/short_url_redirect_app.ts
+++ b/src/plugins/share/public/services/short_url_redirect_app.ts
@@ -45,6 +45,9 @@ export const createShortUrlRedirectApp = (
}
const url = core.http.basePath.prepend(redirectUrl);
+ if (!core.http.externalUrl.isInternalUrl(url)) {
+ throw new Error(`Can not redirect to external URL: ${url}`);
+ }
location.href = url;
diff --git a/src/plugins/share/server/url_service/http/register_url_service_routes.ts b/src/plugins/share/server/url_service/http/register_url_service_routes.ts
index 35b513bebbc8..4061668d5170 100644
--- a/src/plugins/share/server/url_service/http/register_url_service_routes.ts
+++ b/src/plugins/share/server/url_service/http/register_url_service_routes.ts
@@ -20,7 +20,9 @@ export const registerUrlServiceRoutes = (
router: IRouter,
url: ServerUrlService
) => {
- registerCreateRoute(router, url);
+ const { http } = core;
+
+ registerCreateRoute(router, url, http);
registerGetRoute(router, url);
registerDeleteRoute(router, url);
registerResolveRoute(router, url);
diff --git a/src/plugins/share/server/url_service/http/short_urls/integration_tests/create.test.ts b/src/plugins/share/server/url_service/http/short_urls/integration_tests/create.test.ts
new file mode 100644
index 000000000000..d776aafa653a
--- /dev/null
+++ b/src/plugins/share/server/url_service/http/short_urls/integration_tests/create.test.ts
@@ -0,0 +1,106 @@
+/*
+ * 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 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 supertest from 'supertest';
+import { UnwrapPromise } from '@kbn/utility-types';
+import { setupServer } from 'src/core/server/test_utils';
+import { httpServiceMock } from 'src/core/server/mocks';
+import { MockUrlService } from '../../../../../common/mocks';
+import { registerCreateRoute } from '../register_create_route';
+
+const url = new MockUrlService();
+const http = httpServiceMock.createSetupContract();
+
+type SetupServerReturn = UnwrapPromise>;
+
+describe('POST /api/short_url', () => {
+ let server: SetupServerReturn['server'];
+ let httpSetup: SetupServerReturn['httpSetup'];
+
+ beforeAll(async () => {
+ const setup = await setupServer();
+ server = setup.server;
+ httpSetup = setup.httpSetup;
+
+ url.locators.get = jest.fn().mockImplementation((locatorId) => {
+ if (locatorId === 'LEGACY_SHORT_URL_LOCATOR') {
+ return { id: locatorId };
+ }
+ return undefined;
+ });
+
+ http.basePath.get = jest.fn().mockReturnValue('');
+ registerCreateRoute(httpSetup.createRouter(''), url, http);
+
+ await server.start();
+ });
+
+ afterAll(async () => {
+ await server.stop();
+ });
+
+ it('returns 200 and the short URL data for a valid internal URL', async () => {
+ url.shortUrls.get = jest.fn().mockReturnValue({
+ create: jest.fn().mockResolvedValue({
+ data: { id: 'abc123', url: '/s/abc123' },
+ }),
+ });
+
+ const payload = {
+ locatorId: 'LEGACY_SHORT_URL_LOCATOR',
+ params: {
+ url: '/internal/app/path',
+ },
+ };
+
+ await supertest(httpSetup.server.listener)
+ .post('/api/short_url')
+ .send(payload)
+ .expect(200)
+ .expect((res) => {
+ expect(res.body).toEqual({ id: 'abc123', url: '/s/abc123' });
+ });
+ });
+
+ it('returns 400 if creating a short URL for an external URL', async () => {
+ const payload = {
+ locatorId: 'LEGACY_SHORT_URL_LOCATOR',
+ params: {
+ url: 'http://example.com/app/management/data/short_urls',
+ },
+ };
+
+ await supertest(httpSetup.server.listener)
+ .post('/api/short_url')
+ .send(payload)
+ .expect(400)
+ .expect((res) => {
+ expect(res.text).toContain('Can not create a short URL for an external URL.');
+ });
+ });
+
+ it('returns 409 if the locator is not found', async () => {
+ const payload = {
+ locatorId: 'NON_EXISTENT_LOCATOR',
+ params: {
+ url: '/internal/path',
+ },
+ };
+
+ // Override the locator mock for this test
+ (url.locators.get as jest.Mock).mockReturnValueOnce(undefined);
+
+ await supertest(httpSetup.server.listener)
+ .post('/api/short_url')
+ .send(payload)
+ .expect(409)
+ .expect((res) => {
+ expect(res.text).toContain('Locator not found.');
+ });
+ });
+});
diff --git a/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts b/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts
index 3bf50787ad77..b46c120af066 100644
--- a/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts
+++ b/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts
@@ -7,10 +7,50 @@
*/
import { schema } from '@kbn/config-schema';
-import { IRouter } from 'kibana/server';
+import { HttpServiceSetup, IRouter } from 'kibana/server';
import { ServerUrlService } from '../../types';
-export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => {
+/**
+ * Determine if url is outside of this Kibana install.
+ * Copied from x-pack/plugins/security/common/is_internal_url.ts
+ */
+function isInternalURL(url: string, basePath = '') {
+ // We use the WHATWG parser TWICE with completely different dummy base URLs to ensure that the parsed URL always
+ // inherits the origin of the base URL. This means that the specified URL isn't an absolute URL, or a scheme-relative
+ // URL (//), or a scheme-relative URL with an empty host (///). Browsers may process such URLs unexpectedly due to
+ // backward compatibility reasons (e.g., a browser may treat `///abc.com` as just `abc.com`). For more details, refer
+ // to https://url.spec.whatwg.org/#concept-basic-url-parser and https://url.spec.whatwg.org/#url-representation.
+ let normalizedURL: URL;
+ try {
+ for (const baseURL of ['http://example.org:5601', 'https://example.com']) {
+ normalizedURL = new URL(url, baseURL);
+ if (normalizedURL.origin !== baseURL) {
+ return false;
+ }
+ }
+ } catch {
+ return false;
+ }
+
+ // Now we need to normalize URL to make sure any relative path segments (`..`) cannot escape expected base path.
+ if (basePath) {
+ return (
+ // Normalized pathname can add a leading slash, but we should also make sure it's included in
+ // the original URL too. We can safely use non-null assertion operator here since we know `normalizedURL` is
+ // always defined, otherwise we would have returned `false` already.
+ url.startsWith('/') &&
+ (normalizedURL!.pathname === basePath || normalizedURL!.pathname.startsWith(`${basePath}/`))
+ );
+ }
+
+ return true;
+}
+
+export const registerCreateRoute = (
+ router: IRouter,
+ url: ServerUrlService,
+ http: HttpServiceSetup
+) => {
router.post(
{
path: '/api/short_url',
@@ -42,8 +82,7 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => {
},
},
router.handleLegacyErrors(async (ctx, req, res) => {
- const savedObjects = ctx.core.savedObjects.client;
- const shortUrls = url.shortUrls.get({ savedObjects });
+ const { core } = ctx;
const { locatorId, params, slug } = req.body;
const locator = url.locators.get(locatorId);
@@ -57,6 +96,16 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => {
});
}
+ const urlFromParams = (params as { url: string | undefined }).url;
+ if (urlFromParams && !isInternalURL(urlFromParams, http.basePath.get(req))) {
+ return res.customError({
+ statusCode: 400,
+ body: 'Can not create a short URL for an external URL.',
+ });
+ }
+ const savedObjects = core.savedObjects.client;
+ const shortUrls = url.shortUrls.get({ savedObjects });
+
const shortUrl = await shortUrls.create({
locator,
params,
diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap
index f6c604b4774d..e484c771cb7c 100644
--- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap
+++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap
@@ -279,6 +279,7 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO
},
"delete": [MockFunction],
"externalUrl": Object {
+ "isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],
diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts
index bff507836d17..e2a6d0f147b0 100644
--- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts
+++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts
@@ -66,6 +66,10 @@ class TextExternalUrl implements IExternalUrl {
public validateUrl(url: string): URL | null {
return this.isCorrect ? new URL(url) : null;
}
+
+ public isInternalUrl(_url: string): boolean {
+ return false;
+ }
}
const createDrilldown = (isExternalUrlValid: boolean = true) => {
diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts
index 2bf129097be2..32a07bdeda2d 100644
--- a/x-pack/plugins/reporting/common/constants.ts
+++ b/x-pack/plugins/reporting/common/constants.ts
@@ -115,6 +115,13 @@ export const ILM_POLICY_NAME = 'kibana-reporting';
// Management UI route
export const REPORTING_MANAGEMENT_HOME = '/app/management/insightsAndAlerting/reporting';
+// Allowed locator types for reporting: the "reportable" analytical apps we expect to redirect to during screenshotting
+export const REPORTING_REDIRECT_ALLOWED_LOCATOR_TYPES = [
+ 'DASHBOARD_APP_LOCATOR',
+ 'LENS_APP_LOCATOR',
+ 'VISUALIZE_APP_LOCATOR',
+];
+
export const REPORTING_REDIRECT_LOCATOR_STORE_KEY = '__REPORTING_REDIRECT_LOCATOR_STORE_KEY__';
/**
diff --git a/x-pack/plugins/reporting/public/redirect/redirect_app.test.tsx b/x-pack/plugins/reporting/public/redirect/redirect_app.test.tsx
new file mode 100644
index 000000000000..dbc47c449f02
--- /dev/null
+++ b/x-pack/plugins/reporting/public/redirect/redirect_app.test.tsx
@@ -0,0 +1,108 @@
+/*
+ * 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 React from 'react';
+import { render, waitFor, screen } from '@testing-library/react';
+import { sharePluginMock } from 'src/plugins/share/public/mocks';
+import { scopedHistoryMock } from 'src/core/public/mocks';
+import { RedirectApp } from './redirect_app';
+import { REPORTING_REDIRECT_LOCATOR_STORE_KEY } from '../../common/constants';
+
+const mockApiClient = {
+ getInfo: jest.fn(),
+};
+const mockShare = sharePluginMock.createSetupContract();
+const historyMock = scopedHistoryMock.create();
+function setLocationSearch(search: string) {
+ window.history.pushState({}, '', search);
+}
+
+describe('RedirectApp', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Clear the window property before each test
+ delete (window as any)[REPORTING_REDIRECT_LOCATOR_STORE_KEY];
+ });
+
+ afterEach(() => {
+ // Reset the URL to the root after each test
+ window.history.pushState({}, '', '/');
+ // Clean up window property
+ delete (window as any)[REPORTING_REDIRECT_LOCATOR_STORE_KEY];
+ });
+
+ it('navigates using share.navigate when locator params are present in window object', async () => {
+ setLocationSearch('?jobId=happy');
+ const locatorParams = { id: 'LENS_APP_LOCATOR', params: { foo: 'bar' } };
+ // Set the locator params in window object
+ (window as any)[REPORTING_REDIRECT_LOCATOR_STORE_KEY] = locatorParams;
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(mockShare.navigate).toHaveBeenCalledWith(locatorParams);
+ });
+ });
+
+ it('displays error when locator params are not available', async () => {
+ setLocationSearch('?jobId=fail');
+ // Do not set the locator params
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Redirect error')).toBeInTheDocument();
+ expect(screen.getByText('Could not find locator params for report')).toBeInTheDocument();
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Redirect page error:'),
+ 'Could not find locator params for report'
+ );
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ describe('non-app locator', () => {
+ it('throws error when using non-allowed locator type', async () => {
+ setLocationSearch('');
+ // Set a non-allowed locator type
+ (window as any)[REPORTING_REDIRECT_LOCATOR_STORE_KEY] = {
+ id: 'LEGACY_SHORT_URL_LOCATOR',
+ params: {},
+ };
+
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ render(
+
+ );
+
+ await waitFor(() =>
+ expect(
+ screen.getByText(
+ 'Report job execution can only redirect using a locator for an expected analytical app'
+ )
+ ).toBeInTheDocument()
+ );
+
+ expect(mockShare.navigate).not.toHaveBeenCalled();
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ `Report job execution cannot redirect using LEGACY_SHORT_URL_LOCATOR`
+ );
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Redirect page error:'),
+ 'Report job execution can only redirect using a locator for an expected analytical app'
+ );
+ consoleErrorSpy.mockRestore();
+ });
+ });
+});
diff --git a/x-pack/plugins/reporting/public/redirect/redirect_app.tsx b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx
index cf027e2a4619..03db54adcfee 100644
--- a/x-pack/plugins/reporting/public/redirect/redirect_app.tsx
+++ b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx
@@ -12,7 +12,10 @@ import { EuiTitle, EuiCallOut, EuiCodeBlock } from '@elastic/eui';
import type { ScopedHistory } from 'src/core/public';
-import { REPORTING_REDIRECT_LOCATOR_STORE_KEY } from '../../common/constants';
+import {
+ REPORTING_REDIRECT_LOCATOR_STORE_KEY,
+ REPORTING_REDIRECT_ALLOWED_LOCATOR_TYPES,
+} from '../../common/constants';
import { LocatorParams } from '../../common/types';
import { ReportingAPIClient } from '../lib/reporting_api_client';
@@ -52,12 +55,19 @@ export const RedirectApp: FunctionComponent = ({ share }) => {
throw new Error('Could not find locator params for report');
}
+ if (!REPORTING_REDIRECT_ALLOWED_LOCATOR_TYPES.includes(locatorParams.id)) {
+ // eslint-disable-next-line no-console
+ console.error(`Report job execution cannot redirect using ${locatorParams.id}`);
+ throw new Error(
+ 'Report job execution can only redirect using a locator for an expected analytical app'
+ );
+ }
+
share.navigate(locatorParams);
} catch (e) {
setError(e);
// eslint-disable-next-line no-console
console.error(i18nTexts.consoleMessagePrefix, e.message);
- throw e;
}
}, [share]);