Skip to content

Commit

Permalink
[HTTP] Add support for configuring a CDN (part I) (#169408)
Browse files Browse the repository at this point in the history
  • Loading branch information
jloleysens committed Oct 21, 2023
1 parent af84131 commit 8727c68
Show file tree
Hide file tree
Showing 24 changed files with 467 additions and 56 deletions.

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

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';
10 changes: 8 additions & 2 deletions packages/core/http/core-http-server-internal/src/http_config.ts
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');
});
});
28 changes: 28 additions & 0 deletions packages/core/http/core-http-server-internal/src/static_assets.ts
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 {
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

0 comments on commit 8727c68

Please sign in to comment.