[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:
Michael Dokolin 2022-02-16 18:06:09 +01:00 committed by GitHub
parent d68a0d390a
commit b8ae75b3f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 186 additions and 358 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -94,10 +94,7 @@ test(`passes browserTimezone to generatePng`, async () => {
],
],
browserTimezone: 'UTC',
conditionalHeaders: expect.objectContaining({
conditions: expect.any(Object),
headers: {},
}),
headers: {},
})
);
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,7 +37,7 @@ describe('Screenshot Observable Pipeline', () => {
} as unknown as jest.Mocked<Logger>;
options = {
browserTimezone: 'UTC',
conditionalHeaders: {},
headers: {},
layout: {},
timeouts: {
loadDelay: 2000,

View file

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

View file

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

View file

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

View file

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