diff --git a/src/core/public/http/external_url_service.test.ts b/src/core/public/http/external_url_service.test.ts index 24ce15f202f80..1385ba5c83fa0 100644 --- a/src/core/public/http/external_url_service.test.ts +++ b/src/core/public/http/external_url_service.test.ts @@ -18,6 +18,7 @@ */ import { ExternalUrlConfig } from 'src/core/server/types'; +import { createSHA256Hash } from '../../utils'; import { injectedMetadataServiceMock } from '../mocks'; @@ -32,8 +33,12 @@ const setupService = ({ serverBasePath: string; policy: ExternalUrlConfig['policy']; }) => { + const hashedPolicies = policy.map((entry) => ({ + ...entry, + host: entry.host ? createSHA256Hash(entry.host) : undefined, + })); const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); - injectedMetadata.getExternalUrlConfig.mockReturnValue({ policy }); + injectedMetadata.getExternalUrlConfig.mockReturnValue({ policy: hashedPolicies }); injectedMetadata.getServerBasePath.mockReturnValue(serverBasePath); const service = new ExternalUrlService(); @@ -308,6 +313,44 @@ describe('External Url Service', () => { expect(result?.toString()).toEqual(urlCandidate); }); + it('allows external urls with a partially matching host and protocol in the allow list', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls that specify a locally addressable host', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'some-host-name', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://some-host-name/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + it('disallows external urls with a matching host and unmatched protocol', () => { const { setup } = setupService({ location, diff --git a/src/core/public/http/external_url_service.ts b/src/core/public/http/external_url_service.ts index 71992a79e8f3e..bb1c99fb09746 100644 --- a/src/core/public/http/external_url_service.ts +++ b/src/core/public/http/external_url_service.ts @@ -20,19 +20,35 @@ import { IExternalUrlPolicy } from 'src/core/server/types'; import { CoreService } from 'src/core/types'; -import { InjectedMetadataSetup } from '../injected_metadata'; +import { createSHA256Hash } from '../../utils'; import { IExternalUrl } from './types'; +import { InjectedMetadataSetup } from '../injected_metadata'; interface SetupDeps { location: Pick; injectedMetadata: InjectedMetadataSetup; } -const isHostMatch = (actualHost: string, ruleHost: string) => { - const hostParts = actualHost.split('.').reverse(); - const ruleParts = ruleHost.split('.').reverse(); +function* getHostHashes(actualHost: string) { + yield createSHA256Hash(actualHost); + let host = actualHost.substr(actualHost.indexOf('.') + 1); + while (host) { + const hash = createSHA256Hash(host); + yield hash; + if (host.indexOf('.') === -1) { + break; + } + host = host.substr(host.indexOf('.') + 1); + } +} - return ruleParts.every((part, idx) => part === hostParts[idx]); +const isHostMatch = (actualHost: string, ruleHostHash: string) => { + for (const hash of getHostHashes(actualHost)) { + if (hash === ruleHostHash) { + return true; + } + } + return false; }; const isProtocolMatch = (actualProtocol: string, ruleProtocol: string) => { diff --git a/src/core/server/external_url/external_url_config.ts b/src/core/server/external_url/external_url_config.ts index f154dc65ef26c..11d005e0f7f04 100644 --- a/src/core/server/external_url/external_url_config.ts +++ b/src/core/server/external_url/external_url_config.ts @@ -17,6 +17,7 @@ * under the License. */ +import { createSHA256Hash } from '../../utils'; import { config } from './config'; const DEFAULT_CONFIG = Object.freeze(config.schema.validate({})); @@ -82,6 +83,14 @@ export class ExternalUrlConfig implements IExternalUrlConfig { * @internal */ constructor(rawConfig: IExternalUrlConfig) { - this.policy = rawConfig.policy; + this.policy = rawConfig.policy.map((entry) => { + if (entry.host) { + return { + ...entry, + host: createSHA256Hash(entry.host), + }; + } + return entry; + }); } } diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 26a661ef7a090..ef34eafc41984 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -177,7 +177,7 @@ export class HttpConfig { this.ssl = new SslConfig(rawHttpConfig.ssl || {}); this.compression = rawHttpConfig.compression; this.csp = new CspConfig(rawCspConfig); - this.externalUrl = new ExternalUrlConfig(rawExternalUrlConfig); + this.externalUrl = rawExternalUrlConfig; this.xsrf = rawHttpConfig.xsrf; this.requestId = rawHttpConfig.requestId; } diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 5e9d3093b092f..ae2e82d8b2241 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -44,7 +44,11 @@ import { import { RequestHandlerContext } from '../../server'; import { registerCoreHandlers } from './lifecycle_handlers'; -import { ExternalUrlConfigType, config as externalUrlConfig } from '../external_url'; +import { + ExternalUrlConfigType, + config as externalUrlConfig, + ExternalUrlConfig, +} from '../external_url'; interface SetupDeps { context: ContextSetup; @@ -105,7 +109,7 @@ export class HttpService this.internalSetup = { ...serverContract, - externalUrl: config.externalUrl, + externalUrl: new ExternalUrlConfig(config.externalUrl), createRouter: (path: string, pluginId: PluginOpaqueId = this.coreContext.coreId) => { const enhanceHandler = this.requestHandlerContext!.createHandler.bind(null, pluginId); diff --git a/src/core/utils/crypto.test.ts b/src/core/utils/crypto.test.ts new file mode 100644 index 0000000000000..11775ed53690b --- /dev/null +++ b/src/core/utils/crypto.test.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createSHA256Hash } from './crypto'; + +describe('createSHA256Hash', () => { + it('creates a hex-encoded hash by default', () => { + expect(createSHA256Hash('foo')).toMatchInlineSnapshot( + `"2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"` + ); + }); + + it('allows the output encoding to be changed', () => { + expect(createSHA256Hash('foo', 'base64')).toMatchInlineSnapshot( + `"LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564="` + ); + }); + + it('accepts a buffer as input', () => { + const data = Buffer.from('foo', 'utf8'); + expect(createSHA256Hash(data)).toEqual(createSHA256Hash('foo')); + }); +}); diff --git a/src/core/utils/crypto.ts b/src/core/utils/crypto.ts new file mode 100644 index 0000000000000..de9eee2efad5a --- /dev/null +++ b/src/core/utils/crypto.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import crypto, { HexBase64Latin1Encoding } from 'crypto'; + +export const createSHA256Hash = ( + input: string | Buffer, + outputEncoding: HexBase64Latin1Encoding = 'hex' +) => { + let data: Buffer; + if (typeof input === 'string') { + data = Buffer.from(input, 'utf8'); + } else { + data = input; + } + return crypto.createHash('sha256').update(data).digest(outputEncoding); +}; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index c620e4e5ee155..04cd7fd194096 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -25,4 +25,5 @@ export { IContextContainer, IContextProvider, } from './context'; +export { createSHA256Hash } from './crypto'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories';