mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Screenshotting] Simplify authentication headers handling (#125673)
* Move stripping unsafe headers to the screenshotting plugin * Encapsulate conditional headers handling in the screenshotting plugin * Add KibanaRequest support on input
This commit is contained in:
parent
d68a0d390a
commit
b8ae75b3f7
28 changed files with 186 additions and 358 deletions
|
@ -32,28 +32,6 @@ export const ALLOWED_JOB_CONTENT_TYPES = [
|
|||
'text/plain',
|
||||
];
|
||||
|
||||
// See:
|
||||
// https://github.com/chromium/chromium/blob/3611052c055897e5ebbc5b73ea295092e0c20141/services/network/public/cpp/header_util_unittest.cc#L50
|
||||
// For a list of headers that chromium doesn't like
|
||||
export const KBN_SCREENSHOT_HEADER_BLOCK_LIST = [
|
||||
'accept-encoding',
|
||||
'connection',
|
||||
'content-length',
|
||||
'content-type',
|
||||
'host',
|
||||
'referer',
|
||||
// `Transfer-Encoding` is hop-by-hop header that is meaningful
|
||||
// only for a single transport-level connection, and shouldn't
|
||||
// be stored by caches or forwarded by proxies.
|
||||
'transfer-encoding',
|
||||
'trailer',
|
||||
'te',
|
||||
'upgrade',
|
||||
'keep-alive',
|
||||
];
|
||||
|
||||
export const KBN_SCREENSHOT_HEADER_BLOCK_LIST_STARTS_WITH_PATTERN = ['proxy-'];
|
||||
|
||||
export const UI_SETTINGS_SEARCH_INCLUDE_FROZEN = 'search:includeFrozen';
|
||||
export const UI_SETTINGS_CUSTOM_PDF_LOGO = 'xpackReporting:customPdfLogo';
|
||||
export const UI_SETTINGS_CSV_SEPARATOR = 'csv:separator';
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
/*
|
||||
* 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 { ReportingConfig } from '../../';
|
||||
import { createMockConfig, createMockConfigSchema } from '../../test_helpers';
|
||||
import { getConditionalHeaders } from './';
|
||||
|
||||
let mockConfig: ReportingConfig;
|
||||
|
||||
beforeEach(async () => {
|
||||
const reportingConfig = { kibanaServer: { hostname: 'custom-hostname' } };
|
||||
const mockSchema = createMockConfigSchema(reportingConfig);
|
||||
mockConfig = createMockConfig(mockSchema);
|
||||
});
|
||||
|
||||
describe('conditions', () => {
|
||||
test(`uses hostname from reporting config if set`, async () => {
|
||||
const permittedHeaders = {
|
||||
foo: 'bar',
|
||||
baz: 'quix',
|
||||
};
|
||||
|
||||
const conditionalHeaders = getConditionalHeaders(mockConfig, permittedHeaders);
|
||||
|
||||
expect(conditionalHeaders.conditions.hostname).toEqual(
|
||||
mockConfig.get('kibanaServer', 'hostname')
|
||||
);
|
||||
expect(conditionalHeaders.conditions.port).toEqual(mockConfig.get('kibanaServer', 'port'));
|
||||
expect(conditionalHeaders.conditions.protocol).toEqual(
|
||||
mockConfig.get('kibanaServer', 'protocol')
|
||||
);
|
||||
expect(conditionalHeaders.conditions.basePath).toEqual(
|
||||
mockConfig.kbnConfig.get('server', 'basePath')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config formatting', () => {
|
||||
test(`lowercases kibanaServer.hostname`, async () => {
|
||||
const reportingConfig = { kibanaServer: { hostname: 'GREAT-HOSTNAME' } };
|
||||
const mockSchema = createMockConfigSchema(reportingConfig);
|
||||
mockConfig = createMockConfig(mockSchema);
|
||||
|
||||
const conditionalHeaders = getConditionalHeaders(mockConfig, {});
|
||||
expect(conditionalHeaders.conditions.hostname).toEqual('great-hostname');
|
||||
});
|
||||
});
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* 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 { ReportingConfig } from '../../';
|
||||
import { ConditionalHeaders } from './';
|
||||
|
||||
export const getConditionalHeaders = (
|
||||
config: ReportingConfig,
|
||||
filteredHeaders: Record<string, string>
|
||||
) => {
|
||||
const { kbnConfig } = config;
|
||||
const [hostname, port, basePath, protocol] = [
|
||||
config.get('kibanaServer', 'hostname'),
|
||||
config.get('kibanaServer', 'port'),
|
||||
kbnConfig.get('server', 'basePath'),
|
||||
config.get('kibanaServer', 'protocol'),
|
||||
] as [string, number, string, string];
|
||||
|
||||
const conditionalHeaders: ConditionalHeaders = {
|
||||
headers: filteredHeaders,
|
||||
conditions: {
|
||||
hostname: hostname ? hostname.toLowerCase() : hostname,
|
||||
port,
|
||||
basePath,
|
||||
protocol,
|
||||
},
|
||||
};
|
||||
|
||||
return conditionalHeaders;
|
||||
};
|
|
@ -7,12 +7,10 @@
|
|||
|
||||
import { ReportingCore } from '../..';
|
||||
import {
|
||||
createMockConfig,
|
||||
createMockConfigSchema,
|
||||
createMockLevelLogger,
|
||||
createMockReportingCore,
|
||||
} from '../../test_helpers';
|
||||
import { getConditionalHeaders } from '.';
|
||||
import { getCustomLogo } from './get_custom_logo';
|
||||
|
||||
let mockReportingPlugin: ReportingCore;
|
||||
|
@ -24,7 +22,7 @@ beforeEach(async () => {
|
|||
});
|
||||
|
||||
test(`gets logo from uiSettings`, async () => {
|
||||
const permittedHeaders = {
|
||||
const headers = {
|
||||
foo: 'bar',
|
||||
baz: 'quix',
|
||||
};
|
||||
|
@ -40,17 +38,7 @@ test(`gets logo from uiSettings`, async () => {
|
|||
get: mockGet,
|
||||
});
|
||||
|
||||
const conditionalHeaders = getConditionalHeaders(
|
||||
createMockConfig(createMockConfigSchema()),
|
||||
permittedHeaders
|
||||
);
|
||||
|
||||
const { logo } = await getCustomLogo(
|
||||
mockReportingPlugin,
|
||||
conditionalHeaders,
|
||||
'spaceyMcSpaceIdFace',
|
||||
logger
|
||||
);
|
||||
const { logo } = await getCustomLogo(mockReportingPlugin, headers, 'spaceyMcSpaceIdFace', logger);
|
||||
|
||||
expect(mockGet).toBeCalledWith('xpackReporting:customPdfLogo');
|
||||
expect(logo).toBe('purple pony');
|
||||
|
|
|
@ -5,25 +5,21 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Headers } from 'src/core/server';
|
||||
import { ReportingCore } from '../../';
|
||||
import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../common/constants';
|
||||
import { LevelLogger } from '../../lib';
|
||||
import { ConditionalHeaders } from '../common';
|
||||
|
||||
export const getCustomLogo = async (
|
||||
reporting: ReportingCore,
|
||||
conditionalHeaders: ConditionalHeaders,
|
||||
headers: Headers,
|
||||
spaceId: string | undefined,
|
||||
logger: LevelLogger
|
||||
) => {
|
||||
const fakeRequest = reporting.getFakeRequest(
|
||||
{ headers: conditionalHeaders.headers },
|
||||
spaceId,
|
||||
logger
|
||||
);
|
||||
const fakeRequest = reporting.getFakeRequest({ headers }, spaceId, logger);
|
||||
const uiSettingsClient = await reporting.getUiSettingsClient(fakeRequest, logger);
|
||||
const logo: string = await uiSettingsClient.get(UI_SETTINGS_CUSTOM_PDF_LOGO);
|
||||
|
||||
// continue the pipeline
|
||||
return { conditionalHeaders, logo };
|
||||
return { headers, logo };
|
||||
};
|
||||
|
|
|
@ -6,9 +6,7 @@
|
|||
*/
|
||||
|
||||
export { decryptJobHeaders } from './decrypt_job_headers';
|
||||
export { getConditionalHeaders } from './get_conditional_headers';
|
||||
export { getFullUrls } from './get_full_urls';
|
||||
export { omitBlockedHeaders } from './omit_blocked_headers';
|
||||
export { validateUrls } from './validate_urls';
|
||||
export { generatePngObservable } from './generate_png';
|
||||
export { getCustomLogo } from './get_custom_logo';
|
||||
|
@ -17,15 +15,3 @@ export interface TimeRangeParams {
|
|||
min?: Date | string | number | null;
|
||||
max?: Date | string | number | null;
|
||||
}
|
||||
|
||||
export interface ConditionalHeadersConditions {
|
||||
protocol: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export interface ConditionalHeaders {
|
||||
headers: Record<string, string>;
|
||||
conditions: ConditionalHeadersConditions;
|
||||
}
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* 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 { omitBlockedHeaders } from './index';
|
||||
|
||||
test(`omits blocked headers`, async () => {
|
||||
const permittedHeaders = {
|
||||
foo: 'bar',
|
||||
baz: 'quix',
|
||||
};
|
||||
|
||||
const blockedHeaders = {
|
||||
'accept-encoding': '',
|
||||
connection: 'upgrade',
|
||||
'content-length': '',
|
||||
'content-type': '',
|
||||
host: '',
|
||||
'transfer-encoding': '',
|
||||
'proxy-connection': 'bananas',
|
||||
'proxy-authorization': 'some-base64-encoded-thing',
|
||||
trailer: 's are for trucks',
|
||||
};
|
||||
|
||||
const filteredHeaders = omitBlockedHeaders({
|
||||
...permittedHeaders,
|
||||
...blockedHeaders,
|
||||
});
|
||||
|
||||
expect(filteredHeaders).toEqual(permittedHeaders);
|
||||
});
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* 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 { omitBy } from 'lodash';
|
||||
import {
|
||||
KBN_SCREENSHOT_HEADER_BLOCK_LIST,
|
||||
KBN_SCREENSHOT_HEADER_BLOCK_LIST_STARTS_WITH_PATTERN,
|
||||
} from '../../../common/constants';
|
||||
|
||||
export const omitBlockedHeaders = (decryptedHeaders: Record<string, string>) => {
|
||||
const filteredHeaders: Record<string, string> = omitBy(
|
||||
decryptedHeaders,
|
||||
(_value, header: string) =>
|
||||
header &&
|
||||
(KBN_SCREENSHOT_HEADER_BLOCK_LIST.includes(header) ||
|
||||
KBN_SCREENSHOT_HEADER_BLOCK_LIST_STARTS_WITH_PATTERN.some((pattern) =>
|
||||
header?.startsWith(pattern)
|
||||
))
|
||||
);
|
||||
return filteredHeaders;
|
||||
};
|
|
@ -87,10 +87,7 @@ test(`passes browserTimezone to generatePng`, async () => {
|
|||
expect.objectContaining({
|
||||
urls: ['localhost:80undefined/app/kibana#/something'],
|
||||
browserTimezone: 'UTC',
|
||||
conditionalHeaders: expect.objectContaining({
|
||||
conditions: expect.any(Object),
|
||||
headers: {},
|
||||
}),
|
||||
headers: {},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
|
@ -11,13 +11,7 @@ import { finalize, map, mergeMap, takeUntil, tap } from 'rxjs/operators';
|
|||
import { PNG_JOB_TYPE, REPORTING_TRANSACTION_TYPE } from '../../../../common/constants';
|
||||
import { TaskRunResult } from '../../../lib/tasks';
|
||||
import { RunTaskFn, RunTaskFnFactory } from '../../../types';
|
||||
import {
|
||||
decryptJobHeaders,
|
||||
getConditionalHeaders,
|
||||
getFullUrls,
|
||||
omitBlockedHeaders,
|
||||
generatePngObservable,
|
||||
} from '../../common';
|
||||
import { decryptJobHeaders, getFullUrls, generatePngObservable } from '../../common';
|
||||
import { TaskPayloadPNG } from '../types';
|
||||
|
||||
export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPNG>> =
|
||||
|
@ -33,16 +27,14 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPNG>> =
|
|||
const jobLogger = parentLogger.clone([PNG_JOB_TYPE, 'execute', jobId]);
|
||||
const process$: Rx.Observable<TaskRunResult> = Rx.of(1).pipe(
|
||||
mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)),
|
||||
map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)),
|
||||
map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)),
|
||||
mergeMap((conditionalHeaders) => {
|
||||
mergeMap((headers) => {
|
||||
const [url] = getFullUrls(config, job);
|
||||
|
||||
apmGetAssets?.end();
|
||||
apmGeneratePng = apmTrans?.startSpan('generate-png-pipeline', 'execute');
|
||||
|
||||
return generatePngObservable(reporting, jobLogger, {
|
||||
conditionalHeaders,
|
||||
headers,
|
||||
urls: [url],
|
||||
browserTimezone: job.browserTimezone,
|
||||
layout: job.layout,
|
||||
|
|
|
@ -94,10 +94,7 @@ test(`passes browserTimezone to generatePng`, async () => {
|
|||
],
|
||||
],
|
||||
browserTimezone: 'UTC',
|
||||
conditionalHeaders: expect.objectContaining({
|
||||
conditions: expect.any(Object),
|
||||
headers: {},
|
||||
}),
|
||||
headers: {},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
|
@ -11,12 +11,7 @@ import { finalize, map, mergeMap, takeUntil, tap } from 'rxjs/operators';
|
|||
import { PNG_JOB_TYPE_V2, REPORTING_TRANSACTION_TYPE } from '../../../common/constants';
|
||||
import { TaskRunResult } from '../../lib/tasks';
|
||||
import { RunTaskFn, RunTaskFnFactory } from '../../types';
|
||||
import {
|
||||
decryptJobHeaders,
|
||||
getConditionalHeaders,
|
||||
omitBlockedHeaders,
|
||||
generatePngObservable,
|
||||
} from '../common';
|
||||
import { decryptJobHeaders, generatePngObservable } from '../common';
|
||||
import { getFullRedirectAppUrl } from '../common/v2/get_full_redirect_app_url';
|
||||
import { TaskPayloadPNGV2 } from './types';
|
||||
|
||||
|
@ -33,9 +28,7 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPNGV2>> =
|
|||
const jobLogger = parentLogger.clone([PNG_JOB_TYPE_V2, 'execute', jobId]);
|
||||
const process$: Rx.Observable<TaskRunResult> = Rx.of(1).pipe(
|
||||
mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)),
|
||||
map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)),
|
||||
map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)),
|
||||
mergeMap((conditionalHeaders) => {
|
||||
mergeMap((headers) => {
|
||||
const url = getFullRedirectAppUrl(config, job.spaceId, job.forceNow);
|
||||
const [locatorParams] = job.locatorParams;
|
||||
|
||||
|
@ -43,7 +36,7 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPNGV2>> =
|
|||
apmGeneratePng = apmTrans?.startSpan('generate-png-pipeline', 'execute');
|
||||
|
||||
return generatePngObservable(reporting, jobLogger, {
|
||||
conditionalHeaders,
|
||||
headers,
|
||||
browserTimezone: job.browserTimezone,
|
||||
layout: job.layout,
|
||||
urls: [[url, locatorParams]],
|
||||
|
|
|
@ -11,13 +11,7 @@ import { catchError, map, mergeMap, takeUntil, tap } from 'rxjs/operators';
|
|||
import { PDF_JOB_TYPE, REPORTING_TRANSACTION_TYPE } from '../../../../common/constants';
|
||||
import { TaskRunResult } from '../../../lib/tasks';
|
||||
import { RunTaskFn, RunTaskFnFactory } from '../../../types';
|
||||
import {
|
||||
decryptJobHeaders,
|
||||
getConditionalHeaders,
|
||||
getFullUrls,
|
||||
omitBlockedHeaders,
|
||||
getCustomLogo,
|
||||
} from '../../common';
|
||||
import { decryptJobHeaders, getFullUrls, getCustomLogo } from '../../common';
|
||||
import { generatePdfObservable } from '../lib/generate_pdf';
|
||||
import { TaskPayloadPDF } from '../types';
|
||||
|
||||
|
@ -34,12 +28,8 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPDF>> =
|
|||
|
||||
const process$: Rx.Observable<TaskRunResult> = Rx.of(1).pipe(
|
||||
mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)),
|
||||
map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)),
|
||||
map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)),
|
||||
mergeMap((conditionalHeaders) =>
|
||||
getCustomLogo(reporting, conditionalHeaders, job.spaceId, jobLogger)
|
||||
),
|
||||
mergeMap(({ logo, conditionalHeaders }) => {
|
||||
mergeMap((headers) => getCustomLogo(reporting, headers, job.spaceId, jobLogger)),
|
||||
mergeMap(({ headers, logo }) => {
|
||||
const urls = getFullUrls(config, job);
|
||||
|
||||
const { browserTimezone, layout, title } = job;
|
||||
|
@ -53,7 +43,7 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPDF>> =
|
|||
{
|
||||
urls,
|
||||
browserTimezone,
|
||||
conditionalHeaders,
|
||||
headers,
|
||||
layout,
|
||||
},
|
||||
logo
|
||||
|
|
|
@ -11,12 +11,7 @@ import { catchError, map, mergeMap, takeUntil, tap } from 'rxjs/operators';
|
|||
import { PDF_JOB_TYPE_V2, REPORTING_TRANSACTION_TYPE } from '../../../common/constants';
|
||||
import { TaskRunResult } from '../../lib/tasks';
|
||||
import { RunTaskFn, RunTaskFnFactory } from '../../types';
|
||||
import {
|
||||
decryptJobHeaders,
|
||||
getConditionalHeaders,
|
||||
omitBlockedHeaders,
|
||||
getCustomLogo,
|
||||
} from '../common';
|
||||
import { decryptJobHeaders, getCustomLogo } from '../common';
|
||||
import { generatePdfObservable } from './lib/generate_pdf';
|
||||
import { TaskPayloadPDFV2 } from './types';
|
||||
|
||||
|
@ -33,12 +28,8 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPDFV2>> =
|
|||
|
||||
const process$: Rx.Observable<TaskRunResult> = Rx.of(1).pipe(
|
||||
mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)),
|
||||
map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)),
|
||||
map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)),
|
||||
mergeMap((conditionalHeaders) =>
|
||||
getCustomLogo(reporting, conditionalHeaders, job.spaceId, jobLogger)
|
||||
),
|
||||
mergeMap(({ logo, conditionalHeaders }) => {
|
||||
mergeMap((headers) => getCustomLogo(reporting, headers, job.spaceId, jobLogger)),
|
||||
mergeMap(({ logo, headers }) => {
|
||||
const { browserTimezone, layout, title, locatorParams } = job;
|
||||
apmGetAssets?.end();
|
||||
|
||||
|
@ -51,7 +42,7 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPDFV2>> =
|
|||
locatorParams,
|
||||
{
|
||||
browserTimezone,
|
||||
conditionalHeaders,
|
||||
headers,
|
||||
layout,
|
||||
},
|
||||
logo
|
||||
|
|
|
@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n';
|
|||
import { ReportingCore } from '../..';
|
||||
import { APP_WRAPPER_CLASS } from '../../../../../../src/core/server';
|
||||
import { API_DIAGNOSE_URL } from '../../../common/constants';
|
||||
import { omitBlockedHeaders, generatePngObservable } from '../../export_types/common';
|
||||
import { generatePngObservable } from '../../export_types/common';
|
||||
import { getAbsoluteUrlFactory } from '../../export_types/common/get_absolute_url';
|
||||
import { LevelLogger as Logger } from '../../lib';
|
||||
import { authorizedUserPreRouting } from '../lib/authorized_user_pre_routing';
|
||||
|
@ -24,9 +24,8 @@ export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Log
|
|||
path: `${API_DIAGNOSE_URL}/screenshot`,
|
||||
validate: {},
|
||||
},
|
||||
authorizedUserPreRouting(reporting, async (_user, _context, req, res) => {
|
||||
authorizedUserPreRouting(reporting, async (_user, _context, request, res) => {
|
||||
const config = reporting.getConfig();
|
||||
const decryptedHeaders = req.headers as Record<string, string>;
|
||||
const [basePath, protocol, hostname, port] = [
|
||||
config.kbnConfig.get('server', 'basePath'),
|
||||
config.get('kibanaServer', 'protocol'),
|
||||
|
@ -51,19 +50,9 @@ export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Log
|
|||
},
|
||||
};
|
||||
|
||||
const conditionalHeaders = {
|
||||
headers: omitBlockedHeaders(decryptedHeaders),
|
||||
conditions: {
|
||||
hostname,
|
||||
port: +port,
|
||||
basePath,
|
||||
protocol,
|
||||
},
|
||||
};
|
||||
|
||||
return generatePngObservable(reporting, logger, {
|
||||
conditionalHeaders,
|
||||
layout,
|
||||
request,
|
||||
browserTimezone: 'America/Los_Angeles',
|
||||
urls: [hashUrl],
|
||||
})
|
||||
|
|
|
@ -5,29 +5,18 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { map, truncate } from 'lodash';
|
||||
import { truncate } from 'lodash';
|
||||
import open from 'opn';
|
||||
import puppeteer, { ElementHandle, EvaluateFn, Page, SerializableOrJSHandle } from 'puppeteer';
|
||||
import { parse as parseUrl } from 'url';
|
||||
import { Logger } from 'src/core/server';
|
||||
import { Headers, Logger } from 'src/core/server';
|
||||
import {
|
||||
KBN_SCREENSHOT_MODE_HEADER,
|
||||
ScreenshotModePluginSetup,
|
||||
} from '../../../../../../src/plugins/screenshot_mode/server';
|
||||
import { ConfigType } from '../../config';
|
||||
import { allowRequest } from '../network_policy';
|
||||
|
||||
export interface ConditionalHeadersConditions {
|
||||
protocol: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export interface ConditionalHeaders {
|
||||
headers: Record<string, string>;
|
||||
conditions: ConditionalHeadersConditions;
|
||||
}
|
||||
import { stripUnsafeHeaders } from './strip_unsafe_headers';
|
||||
|
||||
export type Context = Record<string, unknown>;
|
||||
|
||||
|
@ -52,8 +41,8 @@ export interface Viewport {
|
|||
}
|
||||
|
||||
interface OpenOptions {
|
||||
conditionalHeaders: ConditionalHeaders;
|
||||
context?: Context;
|
||||
headers: Headers;
|
||||
waitForSelector: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
@ -104,6 +93,7 @@ export class HeadlessChromiumDriver {
|
|||
constructor(
|
||||
private screenshotMode: ScreenshotModePluginSetup,
|
||||
private config: ConfigType,
|
||||
private basePath: string,
|
||||
private readonly page: Page
|
||||
) {}
|
||||
|
||||
|
@ -123,7 +113,7 @@ export class HeadlessChromiumDriver {
|
|||
*/
|
||||
async open(
|
||||
url: string,
|
||||
{ conditionalHeaders, context, waitForSelector: pageLoadSelector, timeout }: OpenOptions,
|
||||
{ headers, context, waitForSelector: pageLoadSelector, timeout }: OpenOptions,
|
||||
logger: Logger
|
||||
): Promise<void> {
|
||||
logger.info(`opening url ${url}`);
|
||||
|
@ -142,7 +132,7 @@ export class HeadlessChromiumDriver {
|
|||
}
|
||||
|
||||
await this.page.setRequestInterception(true);
|
||||
this.registerListeners(conditionalHeaders, logger);
|
||||
this.registerListeners(url, headers, logger);
|
||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
if (this.config.browser.chromium.inspect) {
|
||||
|
@ -243,14 +233,13 @@ export class HeadlessChromiumDriver {
|
|||
});
|
||||
}
|
||||
|
||||
private registerListeners(conditionalHeaders: ConditionalHeaders, logger: Logger) {
|
||||
private registerListeners(url: string, customHeaders: Headers, logger: Logger) {
|
||||
if (this.listenersAttached) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
// FIXME: retrieve the client in open() and pass in the client
|
||||
const client = this.page._client;
|
||||
const client = this.page.client();
|
||||
|
||||
// We have to reach into the Chrome Devtools Protocol to apply headers as using
|
||||
// puppeteer's API will cause map tile requests to hang indefinitely:
|
||||
|
@ -277,19 +266,17 @@ export class HeadlessChromiumDriver {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this._shouldUseCustomHeaders(conditionalHeaders.conditions, interceptedUrl)) {
|
||||
if (this._shouldUseCustomHeaders(url, interceptedUrl)) {
|
||||
logger.trace(`Using custom headers for ${interceptedUrl}`);
|
||||
const headers = map(
|
||||
{
|
||||
...interceptedRequest.request.headers,
|
||||
...conditionalHeaders.headers,
|
||||
[KBN_SCREENSHOT_MODE_HEADER]: 'true',
|
||||
},
|
||||
(value, name) => ({
|
||||
name,
|
||||
value,
|
||||
})
|
||||
);
|
||||
const headers = Object.entries({
|
||||
...interceptedRequest.request.headers,
|
||||
...stripUnsafeHeaders(customHeaders),
|
||||
[KBN_SCREENSHOT_MODE_HEADER]: 'true',
|
||||
}).flatMap(([name, rawValue]) => {
|
||||
const values = Array.isArray(rawValue) ? rawValue : [rawValue ?? ''];
|
||||
|
||||
return values.map((value) => ({ name, value }));
|
||||
});
|
||||
|
||||
try {
|
||||
await client.send('Fetch.continueRequest', {
|
||||
|
@ -353,13 +340,27 @@ export class HeadlessChromiumDriver {
|
|||
);
|
||||
}
|
||||
|
||||
private _shouldUseCustomHeaders(conditions: ConditionalHeadersConditions, url: string) {
|
||||
const { hostname, protocol, port, pathname } = parseUrl(url);
|
||||
private _shouldUseCustomHeaders(sourceUrl: string, targetUrl: string) {
|
||||
const {
|
||||
hostname: sourceHostname,
|
||||
protocol: sourceProtocol,
|
||||
port: sourcePort,
|
||||
} = parseUrl(sourceUrl);
|
||||
const {
|
||||
hostname: targetHostname,
|
||||
protocol: targetProtocol,
|
||||
port: targetPort,
|
||||
pathname: targetPathname,
|
||||
} = parseUrl(targetUrl);
|
||||
|
||||
if (targetPathname === null) {
|
||||
throw new Error(`URL missing pathname: ${targetUrl}`);
|
||||
}
|
||||
|
||||
// `port` is null in URLs that don't explicitly state it,
|
||||
// however we can derive the port from the protocol (http/https)
|
||||
// IE: https://feeds-staging.elastic.co/kibana/v8.0.0.json
|
||||
const derivedPort = (() => {
|
||||
const derivedPort = (protocol: string | null, port: string | null, url: string) => {
|
||||
if (port) {
|
||||
return port;
|
||||
}
|
||||
|
@ -372,36 +373,15 @@ export class HeadlessChromiumDriver {
|
|||
return '443';
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (derivedPort === null) throw new Error(`URL missing port: ${url}`);
|
||||
if (pathname === null) throw new Error(`URL missing pathname: ${url}`);
|
||||
throw new Error(`URL missing port: ${url}`);
|
||||
};
|
||||
|
||||
return (
|
||||
hostname === conditions.hostname &&
|
||||
protocol === `${conditions.protocol}:` &&
|
||||
this._shouldUseCustomHeadersForPort(conditions, derivedPort) &&
|
||||
pathname.startsWith(`${conditions.basePath}/`)
|
||||
sourceHostname === targetHostname &&
|
||||
sourceProtocol === targetProtocol &&
|
||||
derivedPort(sourceProtocol, sourcePort, sourceUrl) ===
|
||||
derivedPort(targetProtocol, targetPort, targetUrl) &&
|
||||
targetPathname.startsWith(`${this.basePath}/`)
|
||||
);
|
||||
}
|
||||
|
||||
private _shouldUseCustomHeadersForPort(
|
||||
conditions: ConditionalHeadersConditions,
|
||||
port: string | undefined
|
||||
) {
|
||||
if (conditions.protocol === 'http' && conditions.port === 80) {
|
||||
return (
|
||||
port === undefined || port === null || port === '' || port === conditions.port.toString()
|
||||
);
|
||||
}
|
||||
|
||||
if (conditions.protocol === 'https' && conditions.port === 443) {
|
||||
return (
|
||||
port === undefined || port === null || port === '' || port === conditions.port.toString()
|
||||
);
|
||||
}
|
||||
|
||||
return port === conditions.port.toString();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,7 +61,7 @@ describe('HeadlessChromiumDriverFactory', () => {
|
|||
|
||||
(puppeteer as jest.Mocked<typeof puppeteer>).launch.mockResolvedValue(mockBrowser);
|
||||
|
||||
factory = new HeadlessChromiumDriverFactory(screenshotMode, config, logger, path);
|
||||
factory = new HeadlessChromiumDriverFactory(screenshotMode, config, logger, path, '');
|
||||
jest.spyOn(factory, 'getBrowserLogger').mockReturnValue(Rx.EMPTY);
|
||||
jest.spyOn(factory, 'getProcessLogger').mockReturnValue(Rx.EMPTY);
|
||||
jest.spyOn(factory, 'getPageExit').mockReturnValue(Rx.EMPTY);
|
||||
|
|
|
@ -108,7 +108,8 @@ export class HeadlessChromiumDriverFactory {
|
|||
private screenshotMode: ScreenshotModePluginSetup,
|
||||
private config: ConfigType,
|
||||
private logger: Logger,
|
||||
private binaryPath: string
|
||||
private binaryPath: string,
|
||||
private basePath: string
|
||||
) {
|
||||
if (this.config.browser.chromium.disableSandbox) {
|
||||
logger.warn(`Enabling the Chromium sandbox provides an additional layer of protection.`);
|
||||
|
@ -243,7 +244,12 @@ export class HeadlessChromiumDriverFactory {
|
|||
this.getProcessLogger(browser, logger).subscribe();
|
||||
|
||||
// HeadlessChromiumDriver: object to "drive" a browser page
|
||||
const driver = new HeadlessChromiumDriver(this.screenshotMode, this.config, page);
|
||||
const driver = new HeadlessChromiumDriver(
|
||||
this.screenshotMode,
|
||||
this.config,
|
||||
this.basePath,
|
||||
page
|
||||
);
|
||||
|
||||
// Rx.Observable<never>: stream to interrupt page capture
|
||||
const unexpectedExit$ = this.getPageExit(browser, page);
|
||||
|
|
|
@ -9,7 +9,7 @@ export const getChromiumDisconnectedError = () =>
|
|||
new Error('Browser was closed unexpectedly! Check the server logs for more info.');
|
||||
|
||||
export { HeadlessChromiumDriver } from './driver';
|
||||
export type { ConditionalHeaders, Context } from './driver';
|
||||
export type { Context } from './driver';
|
||||
export { DEFAULT_VIEWPORT, HeadlessChromiumDriverFactory } from './driver_factory';
|
||||
export type { PerformanceMetrics } from './driver_factory';
|
||||
export { ChromiumArchivePaths } from './paths';
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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 { stripUnsafeHeaders } from './strip_unsafe_headers';
|
||||
|
||||
describe('stripUnsafeHeaders', () => {
|
||||
it.each`
|
||||
header | value
|
||||
${'accept-encoding'} | ${''}
|
||||
${'connection'} | ${'upgrade'}
|
||||
${'content-length'} | ${''}
|
||||
${'content-type'} | ${''}
|
||||
${'host'} | ${''}
|
||||
${'transfer-encoding'} | ${''}
|
||||
${'proxy-connection'} | ${'bananas'}
|
||||
${'proxy-authorization'} | ${'some-base64-encoded-thing'}
|
||||
${'trailer'} | ${'s are for trucks'}
|
||||
`('should strip unsafe header "$header"', ({ header, value }) => {
|
||||
const headers = { [header]: value };
|
||||
|
||||
expect(stripUnsafeHeaders(headers)).toEqual({});
|
||||
});
|
||||
|
||||
it.each`
|
||||
header | value
|
||||
${'foo'} | ${'bar'}
|
||||
${'baz'} | ${'quix'}
|
||||
`('should keep safe header "$header"', ({ header, value }) => {
|
||||
const headers = { [header]: value };
|
||||
|
||||
expect(stripUnsafeHeaders(headers)).toEqual(headers);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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 { omitBy } from 'lodash';
|
||||
import type { Headers } from 'src/core/server';
|
||||
|
||||
// @see https://github.com/chromium/chromium/blob/3611052c055897e5ebbc5b73ea295092e0c20141/services/network/public/cpp/header_util_unittest.cc#L50
|
||||
// For a list of headers that chromium doesn't like
|
||||
const UNSAFE_HEADERS = [
|
||||
'accept-encoding',
|
||||
'connection',
|
||||
'content-length',
|
||||
'content-type',
|
||||
'host',
|
||||
'referer',
|
||||
// `Transfer-Encoding` is hop-by-hop header that is meaningful
|
||||
// only for a single transport-level connection, and shouldn't
|
||||
// be stored by caches or forwarded by proxies.
|
||||
'transfer-encoding',
|
||||
'trailer',
|
||||
'te',
|
||||
'upgrade',
|
||||
'keep-alive',
|
||||
];
|
||||
|
||||
const UNSAFE_HEADERS_PATTERNS = [/^proxy-/i];
|
||||
|
||||
export function stripUnsafeHeaders(headers: Headers): Headers {
|
||||
return omitBy(
|
||||
headers,
|
||||
(_value, header: string) =>
|
||||
header &&
|
||||
(UNSAFE_HEADERS.includes(header) ||
|
||||
UNSAFE_HEADERS_PATTERNS.some((pattern) => pattern.test(header)))
|
||||
);
|
||||
}
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
export { download } from './download';
|
||||
export { install } from './install';
|
||||
export type { ConditionalHeaders, Context, PerformanceMetrics } from './chromium';
|
||||
export type { Context, PerformanceMetrics } from './chromium';
|
||||
export {
|
||||
getChromiumDisconnectedError,
|
||||
ChromiumArchivePaths,
|
||||
|
|
|
@ -54,7 +54,7 @@ export class ScreenshottingPlugin implements Plugin<void, ScreenshottingStart, S
|
|||
this.config = context.config.get();
|
||||
}
|
||||
|
||||
setup({}: CoreSetup, { screenshotMode }: SetupDeps) {
|
||||
setup({ http }: CoreSetup, { screenshotMode }: SetupDeps) {
|
||||
this.screenshotMode = screenshotMode;
|
||||
this.browserDriverFactory = (async () => {
|
||||
const paths = new ChromiumArchivePaths();
|
||||
|
@ -63,8 +63,15 @@ export class ScreenshottingPlugin implements Plugin<void, ScreenshottingStart, S
|
|||
createConfig(this.logger, this.config),
|
||||
install(paths, logger, getChromiumPackage()),
|
||||
]);
|
||||
const basePath = http.basePath.serverBasePath;
|
||||
|
||||
return new HeadlessChromiumDriverFactory(this.screenshotMode, config, logger, binaryPath);
|
||||
return new HeadlessChromiumDriverFactory(
|
||||
this.screenshotMode,
|
||||
config,
|
||||
logger,
|
||||
binaryPath,
|
||||
basePath
|
||||
);
|
||||
})();
|
||||
this.browserDriverFactory.catch((error) => {
|
||||
this.logger.error('Error in screenshotting setup, it may not function properly.');
|
||||
|
|
|
@ -37,7 +37,7 @@ describe('Screenshot Observable Pipeline', () => {
|
|||
} as unknown as jest.Mocked<Logger>;
|
||||
options = {
|
||||
browserTimezone: 'UTC',
|
||||
conditionalHeaders: {},
|
||||
headers: {},
|
||||
layout: {},
|
||||
timeouts: {
|
||||
loadDelay: 2000,
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
tap,
|
||||
toArray,
|
||||
} from 'rxjs/operators';
|
||||
import type { Logger } from 'src/core/server';
|
||||
import type { KibanaRequest, Logger } from 'src/core/server';
|
||||
import { LayoutParams } from '../../common';
|
||||
import type { ConfigType } from '../config';
|
||||
import type { HeadlessChromiumDriverFactory, PerformanceMetrics } from '../browsers';
|
||||
|
@ -30,6 +30,11 @@ import { Semaphore } from './semaphore';
|
|||
|
||||
export interface ScreenshotOptions extends ScreenshotObservableOptions {
|
||||
layout: LayoutParams;
|
||||
|
||||
/**
|
||||
* Source Kibana request object from where the headers will be extracted.
|
||||
*/
|
||||
request?: KibanaRequest;
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
|
@ -77,6 +82,7 @@ export class Screenshots {
|
|||
browserTimezone,
|
||||
timeouts: { openUrl: openUrlTimeout },
|
||||
} = options;
|
||||
const headers = { ...(options.request?.headers ?? {}), ...(options.headers ?? {}) };
|
||||
|
||||
return this.browserDriverFactory
|
||||
.createPage(
|
||||
|
@ -93,7 +99,10 @@ export class Screenshots {
|
|||
apmCreatePage?.end();
|
||||
unexpectedExit$.subscribe({ error: () => apmTrans?.end() });
|
||||
|
||||
const screen = new ScreenshotObservableHandler(driver, this.logger, layout, options);
|
||||
const screen = new ScreenshotObservableHandler(driver, this.logger, layout, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
return from(options.urls).pipe(
|
||||
concatMap((url, index) =>
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
import { interval, throwError, of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import type { Logger } from 'src/core/server';
|
||||
import type { ConditionalHeaders } from '../browsers';
|
||||
import { createMockBrowserDriver } from '../browsers/mock';
|
||||
import { createMockLayout } from '../layouts/mock';
|
||||
import { ScreenshotObservableHandler, ScreenshotObservableOptions } from './observable';
|
||||
|
@ -24,10 +23,7 @@ describe('ScreenshotObservableHandler', () => {
|
|||
layout = createMockLayout();
|
||||
logger = { error: jest.fn() } as unknown as jest.Mocked<Logger>;
|
||||
options = {
|
||||
conditionalHeaders: {
|
||||
headers: { testHeader: 'testHeadValue' },
|
||||
conditions: {} as unknown as ConditionalHeaders['conditions'],
|
||||
},
|
||||
headers: { testHeader: 'testHeadValue' },
|
||||
timeouts: {
|
||||
loadDelay: 5000,
|
||||
openUrl: 30000,
|
||||
|
|
|
@ -8,8 +8,8 @@
|
|||
import type { Transaction } from 'elastic-apm-node';
|
||||
import { defer, forkJoin, throwError, Observable } from 'rxjs';
|
||||
import { catchError, mergeMap, switchMapTo, timeoutWith } from 'rxjs/operators';
|
||||
import type { Logger } from 'src/core/server';
|
||||
import type { ConditionalHeaders, Context, HeadlessChromiumDriver } from '../browsers';
|
||||
import type { Headers, Logger } from 'src/core/server';
|
||||
import type { Context, HeadlessChromiumDriver } from '../browsers';
|
||||
import { getChromiumDisconnectedError, DEFAULT_VIEWPORT } from '../browsers';
|
||||
import type { Layout } from '../layouts';
|
||||
import type { ElementsPositionAndAttribute } from './get_element_position_data';
|
||||
|
@ -60,7 +60,7 @@ export interface ScreenshotObservableOptions {
|
|||
/**
|
||||
* Custom headers to be sent with each request.
|
||||
*/
|
||||
conditionalHeaders: ConditionalHeaders;
|
||||
headers?: Headers;
|
||||
|
||||
/**
|
||||
* Timeouts for each phase of the screenshot.
|
||||
|
@ -177,7 +177,7 @@ export class ScreenshotObservableHandler {
|
|||
index,
|
||||
url,
|
||||
{ ...(context ?? {}), layout: this.layout.id },
|
||||
this.options.conditionalHeaders
|
||||
this.options.headers ?? {}
|
||||
);
|
||||
}).pipe(this.waitUntil(this.options.timeouts.openUrl, 'open URL'));
|
||||
}
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
*/
|
||||
|
||||
import apm from 'elastic-apm-node';
|
||||
import type { Logger } from 'src/core/server';
|
||||
import type { Headers, Logger } from 'src/core/server';
|
||||
import type { HeadlessChromiumDriver } from '../browsers';
|
||||
import type { ConditionalHeaders, Context } from '../browsers';
|
||||
import type { Context } from '../browsers';
|
||||
import { DEFAULT_PAGELOAD_SELECTOR } from './constants';
|
||||
|
||||
export const openUrl = async (
|
||||
|
@ -18,7 +18,7 @@ export const openUrl = async (
|
|||
index: number,
|
||||
url: string,
|
||||
context: Context,
|
||||
conditionalHeaders: ConditionalHeaders
|
||||
headers: Headers
|
||||
): Promise<void> => {
|
||||
// If we're moving to another page in the app, we'll want to wait for the app to tell us
|
||||
// it's loaded the next page.
|
||||
|
@ -27,7 +27,7 @@ export const openUrl = async (
|
|||
const span = apm.startSpan('open_url', 'wait');
|
||||
|
||||
try {
|
||||
await browser.open(url, { conditionalHeaders, context, waitForSelector, timeout }, logger);
|
||||
await browser.open(url, { context, headers, waitForSelector, timeout }, logger);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
throw new Error(`An error occurred when trying to open the Kibana URL: ${err.message}`);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue