Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[HTTP] Add support for configuring a CDN (part I) #169408

Merged
merged 21 commits into from
Oct 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions packages/core/http/core-http-server-internal/src/cdn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* 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 { CdnConfig } from './cdn';

describe('CdnConfig', () => {
it.each([
['https://cdn.elastic.co', 'cdn.elastic.co'],
['https://foo.bar', 'foo.bar'],
['http://foo.bar', 'foo.bar'],
['https://cdn.elastic.co:9999', 'cdn.elastic.co:9999'],
['https://cdn.elastic.co:9999/with-a-path', 'cdn.elastic.co:9999'],
])('host as expected for %p', (url, expected) => {
expect(CdnConfig.from({ url }).host).toEqual(expected);
});

it.each([
['https://cdn.elastic.co', 'https://cdn.elastic.co'],
['https://foo.bar', 'https://foo.bar'],
['http://foo.bar', 'http://foo.bar'],
['https://cdn.elastic.co:9999', 'https://cdn.elastic.co:9999'],
['https://cdn.elastic.co:9999/with-a-path', 'https://cdn.elastic.co:9999/with-a-path'],
])('base HREF as expected for %p', (url, expected) => {
expect(CdnConfig.from({ url }).baseHref).toEqual(expected);
});

it.each([['foo'], ['#!']])('throws for invalid URLs (%p)', (url) => {
expect(() => CdnConfig.from({ url })).toThrow(/Invalid URL/);
});

it('handles empty urls', () => {
expect(CdnConfig.from({ url: '' }).baseHref).toBeUndefined();
expect(CdnConfig.from({ url: '' }).host).toBeUndefined();
});

it('generates the expected CSP additions', () => {
const cdnConfig = CdnConfig.from({ url: 'https://foo.bar:9999' });
expect(cdnConfig.getCspConfig()).toEqual({
connect_src: ['foo.bar:9999'],
font_src: ['foo.bar:9999'],
img_src: ['foo.bar:9999'],
script_src: ['foo.bar:9999'],
style_src: ['foo.bar:9999'],
worker_src: ['foo.bar:9999'],
});
});

it('generates the expected CSP additions when no URL is provided', () => {
const cdnConfig = CdnConfig.from({ url: '' });
expect(cdnConfig.getCspConfig()).toEqual({});
});
});
50 changes: 50 additions & 0 deletions packages/core/http/core-http-server-internal/src/cdn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* 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 { URL, format } from 'node:url';
import type { CspAdditionalConfig } from './csp';

export interface Input {
url?: string;
}

export class CdnConfig {
private url: undefined | URL;
constructor(url?: string) {
if (url) {
this.url = new URL(url); // This will throw for invalid URLs
}
}

public get host(): undefined | string {
return this.url?.host ?? undefined;
}

public get baseHref(): undefined | string {
if (this.url) {
return this.url.pathname === '/' ? this.url.origin : format(this.url);
}
}

public getCspConfig(): CspAdditionalConfig {
const host = this.host;
if (!host) return {};
return {
font_src: [host],
img_src: [host],
script_src: [host],
style_src: [host],
worker_src: [host],
connect_src: [host],
};
}

public static from(input: Input = {}) {
return new CdnConfig(input.url);
}
}
15 changes: 15 additions & 0 deletions packages/core/http/core-http-server-internal/src/csp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,21 @@ const configSchema = schema.object(
*/
export type CspConfigType = TypeOf<typeof configSchema>;

/**
* @internal
*/
export type CspAdditionalConfig = Pick<
Partial<CspConfigType>,
| 'connect_src'
| 'default_src'
| 'font_src'
| 'frame_src'
| 'img_src'
| 'script_src'
| 'style_src'
| 'worker_src'
>;
Comment on lines +114 to +124
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: I would just have gone with Partial<CspConfigType>, but what you did is probably better 😄

Copy link
Contributor Author

@jloleysens jloleysens Oct 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I had that initially, but there is other funky stuff in there and I wanted scope the overrides to just the policy directives


export const cspConfig: ServiceConfigDescriptor<CspConfigType> = {
// TODO: Move this to server.csp using config deprecations
// ? https://github.com/elastic/kibana/pull/52251
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,31 @@ describe('CspConfig', () => {
});
});
});

describe('with additional config', () => {
test(`adds, for example, CDN host name to directives along with 'self'`, () => {
const config = new CspConfig(defaultConfig, { default_src: ['foo.bar'] });
expect(config.header).toEqual(
"script-src 'report-sample' 'self'; worker-src 'report-sample' 'self' blob:; style-src 'report-sample' 'self' 'unsafe-inline'; default-src 'self' foo.bar"
);
});

test('Empty additional config does not affect existing config', () => {
const config = new CspConfig(defaultConfig, {
/* empty */
});
expect(config.header).toEqual(
"script-src 'report-sample' 'self'; worker-src 'report-sample' 'self' blob:; style-src 'report-sample' 'self' 'unsafe-inline'"
);
});
test('Passing an empty array in additional config does not affect existing config', () => {
const config = new CspConfig(defaultConfig, {
default_src: [],
worker_src: ['foo.bar'],
});
expect(config.header).toEqual(
"script-src 'report-sample' 'self'; worker-src 'report-sample' 'self' blob: foo.bar; style-src 'report-sample' 'self' 'unsafe-inline'"
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import type { ICspConfig } from '@kbn/core-http-server';
import { cspConfig, CspConfigType } from './config';
import { CspAdditionalConfig, cspConfig, CspConfigType } from './config';
import { CspDirectives } from './csp_directives';

const DEFAULT_CONFIG = Object.freeze(cspConfig.schema.validate({}));
Expand All @@ -30,8 +30,8 @@ export class CspConfig implements ICspConfig {
* Returns the default CSP configuration when passed with no config
* @internal
*/
constructor(rawCspConfig: CspConfigType) {
this.#directives = CspDirectives.fromConfig(rawCspConfig);
constructor(rawCspConfig: CspConfigType, ...moreConfigs: CspAdditionalConfig[]) {
this.#directives = CspDirectives.fromConfig(rawCspConfig, ...moreConfigs);
if (rawCspConfig.disableEmbedding) {
this.#directives.clearDirectiveValues('frame-ancestors');
this.#directives.addDirectiveValue('frame-ancestors', `'self'`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Side Public License, v 1.
*/

import { merge } from 'lodash';
import { CspConfigType } from './config';

export type CspDirectiveName =
Expand Down Expand Up @@ -65,7 +66,11 @@ export class CspDirectives {
.join('; ');
}

static fromConfig(config: CspConfigType): CspDirectives {
static fromConfig(
firstConfig: CspConfigType,
...otherConfigs: Array<Partial<CspConfigType>>
): CspDirectives {
const config = otherConfigs.length ? merge(firstConfig, ...otherConfigs) : firstConfig;
const cspDirectives = new CspDirectives();

// combining `default` directive configurations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@

export { CspConfig } from './csp_config';
export { cspConfig } from './config';
export type { CspConfigType } from './config';
export type { CspConfigType, CspAdditionalConfig } from './config';
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ import { hostname } from 'os';
import url from 'url';

import type { Duration } from 'moment';
import { IHttpEluMonitorConfig } from '@kbn/core-http-server/src/elu_monitor';
import type { IHttpEluMonitorConfig } from '@kbn/core-http-server/src/elu_monitor';
import type { HandlerResolutionStrategy } from '@kbn/core-http-router-server-internal';
import { CspConfigType, CspConfig } from './csp';
import { ExternalUrlConfig } from './external_url';
import {
securityResponseHeadersSchema,
parseRawSecurityResponseHeadersConfig,
} from './security_response_headers_config';
import { CdnConfig } from './cdn';

const validBasePathRegex = /^\/.*[^\/]$/;

Expand Down Expand Up @@ -58,6 +59,9 @@ const configSchema = schema.object(
}
},
}),
cdn: schema.object({
url: schema.maybe(schema.uri({ scheme: ['http', 'https'] })),
}),
cors: schema.object(
{
enabled: schema.boolean({ defaultValue: false }),
Expand Down Expand Up @@ -261,6 +265,7 @@ export class HttpConfig implements IHttpConfig {
public basePath?: string;
public publicBaseUrl?: string;
public rewriteBasePath: boolean;
public cdn: CdnConfig;
public ssl: SslConfig;
public compression: {
enabled: boolean;
Expand Down Expand Up @@ -314,7 +319,8 @@ export class HttpConfig implements IHttpConfig {
this.rewriteBasePath = rawHttpConfig.rewriteBasePath;
this.ssl = new SslConfig(rawHttpConfig.ssl || {});
this.compression = rawHttpConfig.compression;
this.csp = new CspConfig({ ...rawCspConfig, disableEmbedding });
this.cdn = CdnConfig.from(rawHttpConfig.cdn);
this.csp = new CspConfig({ ...rawCspConfig, disableEmbedding }, this.cdn.getCspConfig());
this.externalUrl = rawExternalUrlConfig;
this.xsrf = rawHttpConfig.xsrf;
this.requestId = rawHttpConfig.requestId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { AuthStateStorage } from './auth_state_storage';
import { AuthHeadersStorage } from './auth_headers_storage';
import { BasePath } from './base_path_service';
import { getEcsResponseLog } from './logging';
import { StaticAssets, type IStaticAssets } from './static_assets';

/**
* Adds ELU timings for the executed function to the current's context transaction
Expand Down Expand Up @@ -130,7 +131,12 @@ export interface HttpServerSetup {
* @param router {@link IRouter} - a router with registered route handlers.
*/
registerRouterAfterListening: (router: IRouter) => void;
/**
* Register a static directory to be served by the Kibana server
* @note Static assets may be served over CDN
*/
registerStaticDir: (path: string, dirPath: string) => void;
staticAssets: IStaticAssets;
basePath: HttpServiceSetup['basePath'];
csp: HttpServiceSetup['csp'];
createCookieSessionStorageFactory: HttpServiceSetup['createCookieSessionStorageFactory'];
Expand Down Expand Up @@ -230,10 +236,13 @@ export class HttpServer {
this.setupResponseLogging();
this.setupGracefulShutdownHandlers();

const staticAssets = new StaticAssets(basePathService, config.cdn);

return {
registerRouter: this.registerRouter.bind(this),
registerRouterAfterListening: this.registerRouterAfterListening.bind(this),
registerStaticDir: this.registerStaticDir.bind(this),
staticAssets,
registerOnPreRouting: this.registerOnPreRouting.bind(this),
registerOnPreAuth: this.registerOnPreAuth.bind(this),
registerAuth: this.registerAuth.bind(this),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export class HttpService
this.internalPreboot = {
externalUrl: new ExternalUrlConfig(config.externalUrl),
csp: prebootSetup.csp,
staticAssets: prebootSetup.staticAssets,
basePath: prebootSetup.basePath,
registerStaticDir: prebootSetup.registerStaticDir.bind(prebootSetup),
auth: prebootSetup.auth,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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 { StaticAssets } from './static_assets';
import { BasePath } from './base_path_service';
import { CdnConfig } from './cdn';

describe('StaticAssets', () => {
let basePath: BasePath;
let cdnConfig: CdnConfig;
let staticAssets: StaticAssets;
beforeEach(() => {
basePath = new BasePath('/test');
cdnConfig = CdnConfig.from();
staticAssets = new StaticAssets(basePath, cdnConfig);
});
it('provides fallsback to server base path', () => {
expect(staticAssets.getHrefBase()).toEqual('/test');
});

it('provides the correct HREF given a CDN is configured', () => {
cdnConfig = CdnConfig.from({ url: 'https://cdn.example.com/test' });
staticAssets = new StaticAssets(basePath, cdnConfig);
expect(staticAssets.getHrefBase()).toEqual('https://cdn.example.com/test');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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 type { BasePath } from './base_path_service';
import { CdnConfig } from './cdn';

export interface IStaticAssets {
getHrefBase(): string;
}

export class StaticAssets implements IStaticAssets {
jloleysens marked this conversation as resolved.
Show resolved Hide resolved
constructor(private readonly basePath: BasePath, private readonly cdnConfig: CdnConfig) {}
/**
* Returns a href (hypertext reference) intended to be used as the base for constructing
* other hrefs to static assets.
*/
getHrefBase(): string {
if (this.cdnConfig.baseHref) {
return this.cdnConfig.baseHref;
}
return this.basePath.serverBasePath;
}
}
2 changes: 2 additions & 0 deletions packages/core/http/core-http-server-internal/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface InternalHttpServicePreboot
InternalHttpServiceSetup,
| 'auth'
| 'csp'
| 'staticAssets'
| 'basePath'
| 'externalUrl'
| 'registerStaticDir'
Expand All @@ -45,6 +46,7 @@ export interface InternalHttpServiceSetup
extends Omit<HttpServiceSetup, 'createRouter' | 'registerRouteHandlerContext'> {
auth: HttpServerSetup['auth'];
server: HttpServerSetup['server'];
staticAssets: HttpServerSetup['staticAssets'];
externalUrl: ExternalUrlConfig;
createRouter: <Context extends RequestHandlerContextBase = RequestHandlerContextBase>(
path: string,
Expand Down
Loading
Loading