diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 20c723bd51..7d56da10f1 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -45,6 +45,7 @@ "express": "^4.17.1", "express-promise-router": "^4.1.0", "fs-extra": "10.1.0", + "helmet": "^6.0.0", "minimatch": "^5.0.0", "node-forge": "^1.3.1", "selfsigned": "^2.0.0", diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/config.test.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/config.test.ts deleted file mode 100644 index b80d475517..0000000000 --- a/packages/backend-app-api/src/services/implementations/rootHttpRouter/config.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed 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 { ConfigReader } from '@backstage/config'; -import { readCorsOptions, readCspOptions } from './config'; - -describe('config', () => { - describe('readCspOptions', () => { - it('reads valid values', () => { - const config = new ConfigReader({ csp: { key: ['value'] } }); - expect(readCspOptions(config)).toEqual( - expect.objectContaining({ - key: ['value'], - }), - ); - }); - - it('accepts false', () => { - const config = new ConfigReader({ csp: { key: false } }); - expect(readCspOptions(config)).toEqual( - expect.objectContaining({ - key: false, - }), - ); - }); - - it('rejects invalid value types', () => { - const config = new ConfigReader({ csp: { key: [4] } }); - expect(() => readCspOptions(config)).toThrow(/wanted string-array/); - }); - }); - - describe('readCorsOptions', () => { - it('reads single string', () => { - const mockCallback = jest.fn(); - const config = new ConfigReader({ cors: { origin: 'https://*.value*' } }); - const cors = readCorsOptions(config); - expect(cors).toEqual( - expect.objectContaining({ - origin: expect.any(Function), - }), - ); - const origin = cors?.origin as Function; - origin('https://a.value', mockCallback); // valid origin - origin('http://a.value', mockCallback); // invalid origin - origin(undefined, mockCallback); // when not origin needs to reject the call - - expect(mockCallback.mock.calls[0][0]).toBe(null); - expect(mockCallback.mock.calls[1][0]).toBe(null); - - expect(mockCallback.mock.calls[0][1]).toBe(true); - expect(mockCallback.mock.calls[1][1]).toBe(false); - expect(mockCallback.mock.calls[2][1]).toBe(false); - }); - - it('reads string array', () => { - const mockCallback = jest.fn(); - const config = new ConfigReader({ - cors: { - origin: ['http?(s)://*.value?(-+([0-9])).com', 'http://*.value'], - }, - }); - const cors = readCorsOptions(config); - expect(cors).toEqual( - expect.objectContaining({ - origin: expect.any(Function), - }), - ); - const origin = cors?.origin as Function; - origin('https://a.b.c.value-9.com', mockCallback); - origin('http://a.value-999.com', mockCallback); - origin('http://a.value', mockCallback); - origin('http://a.valuex', mockCallback); - - expect(mockCallback.mock.calls[0][0]).toBe(null); - expect(mockCallback.mock.calls[1][0]).toBe(null); - expect(mockCallback.mock.calls[2][0]).toBe(null); - expect(mockCallback.mock.calls[3][0]).toBe(null); - - expect(mockCallback.mock.calls[0][1]).toBe(true); - expect(mockCallback.mock.calls[1][1]).toBe(true); - expect(mockCallback.mock.calls[2][1]).toBe(true); - expect(mockCallback.mock.calls[3][1]).toBe(false); - }); - - it('reads undefined origin', () => { - const config = new ConfigReader({ - cors: {}, - }); - const cors = readCorsOptions(config); - expect(cors).toEqual(expect.objectContaining({})); - expect(cors?.origin).toBeUndefined(); - }); - }); -}); diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/config.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/config.ts deleted file mode 100644 index 931fb48a63..0000000000 --- a/packages/backend-app-api/src/services/implementations/rootHttpRouter/config.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed 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 { Config } from '@backstage/config'; - -export type BaseOptions = { - listenPort?: string | number; - listenHost?: string; -}; - -export type HttpsSettings = { - certificate: CertificateGenerationOptions | CertificateReferenceOptions; -}; - -export type CertificateReferenceOptions = { - key: string; - cert: string; -}; - -export type CertificateGenerationOptions = { - hostname: string; -}; - -export type CertificateAttributes = { - commonName: string; -}; - -/** - * A map from CSP directive names to their values. - */ -export type CspOptions = Record; - -/** - * Attempts to read a CSP options object from the root of a config object. - * - * @param config - The root of a backend config object - * @returns A CSP options object, or undefined if not specified. Values can be - * false as well, which means to remove the default behavior for that - * key. - * - * @example - * ```yaml - * backend: - * csp: - * connect-src: ["'self'", 'http:', 'https:'] - * upgrade-insecure-requests: false - * ``` - */ -export function readCspOptions( - config: Config, -): Record | undefined { - const cc = config.getOptionalConfig('csp'); - if (!cc) { - return undefined; - } - - const result: Record = {}; - for (const key of cc.keys()) { - if (cc.get(key) === false) { - result[key] = false; - } else { - result[key] = cc.getStringArray(key); - } - } - - return result; -} diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/readHelmetOptions.test.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/readHelmetOptions.test.ts new file mode 100644 index 0000000000..d5e0e29264 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/readHelmetOptions.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed 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 { ConfigReader } from '@backstage/config'; +import { readHelmetOptions } from './readHelmetOptions'; + +describe('readHelmetOptions', () => { + it('should add additional directives', () => { + const config = new ConfigReader({ + csp: { + key: ['value'], + 'img-src': false, + 'script-src-attr': ['custom'], + }, + }); + expect(readHelmetOptions(config)).toEqual({ + contentSecurityPolicy: { + useDefaults: false, + directives: { + 'default-src': ["'self'"], + 'base-uri': ["'self'"], + 'font-src': ["'self'", 'https:', 'data:'], + 'frame-ancestors': ["'self'"], + // 'img-src': ["'self'", 'data:'], + 'object-src': ["'none'"], + 'script-src': ["'self'", "'unsafe-eval'"], + 'style-src': ["'self'", 'https:', "'unsafe-inline'"], + 'script-src-attr': ['custom'], + 'upgrade-insecure-requests': [], + key: ['value'], + }, + }, + crossOriginEmbedderPolicy: false, + crossOriginOpenerPolicy: false, + crossOriginResourcePolicy: false, + originAgentCluster: false, + }); + }); + + it('rejects invalid value types', () => { + const config = new ConfigReader({ csp: { key: [4] } }); + expect(() => readHelmetOptions(config)).toThrow(/wanted string-array/); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/readHelmetOptions.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/readHelmetOptions.ts new file mode 100644 index 0000000000..e86997d685 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/readHelmetOptions.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed 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 { Config } from '@backstage/config'; +import helmet from 'helmet'; +import { HelmetOptions } from 'helmet'; +import { ContentSecurityPolicyOptions } from 'helmet/dist/types/middlewares/content-security-policy'; + +/** + * Attempts to read Helmet options from the backend configuration object. + * + * @param config - The backend configuration object. + * @returns A Helmet options object, or undefined if no Helmet configuration is present. + * + * @example + * ```ts + * const helmetOptions = readHelmetOptions(config.getConfig('backend')); + * ``` + */ +export function readHelmetOptions(config: Config): HelmetOptions { + const cspOptions = readCspDirectives(config); + return { + contentSecurityPolicy: { + useDefaults: false, + directives: applyCspDirectives(cspOptions), + }, + // These are all disabled in order to maintain backwards compatibility + // when bumping helmet v5. We can't enable these by default because + // there is no way for users to configure them. + // TODO(Rugvip): We should give control of this setup to consumers + crossOriginEmbedderPolicy: false, + crossOriginOpenerPolicy: false, + crossOriginResourcePolicy: false, + originAgentCluster: false, + }; +} + +type CspDirectives = Record | undefined; + +/** + * Attempts to read a CSP directives from the backend configuration object. + * + * @example + * ```yaml + * backend: + * csp: + * connect-src: ["'self'", 'http:', 'https:'] + * upgrade-insecure-requests: false + * ``` + */ +function readCspDirectives(config: Config): CspDirectives { + const cc = config.getOptionalConfig('csp'); + if (!cc) { + return undefined; + } + + const result: Record = {}; + for (const key of cc.keys()) { + if (cc.get(key) === false) { + result[key] = false; + } else { + result[key] = cc.getStringArray(key); + } + } + + return result; +} + +export function applyCspDirectives( + directives: CspDirectives, +): ContentSecurityPolicyOptions['directives'] { + const result: ContentSecurityPolicyOptions['directives'] = + helmet.contentSecurityPolicy.getDefaultDirectives(); + + // TODO(Rugvip): We currently use non-precompiled AJV for validation in the frontend, which uses eval. + // It should be replaced by any other solution that doesn't require unsafe-eval. + result['script-src'] = ["'self'", "'unsafe-eval'"]; + + // TODO(Rugvip): This is removed so that we maintained backwards compatibility + // when bumping to helmet v5, we could remove this as well as + // skip setting `useDefaults: false` in the future. + delete result['form-action']; + + if (directives) { + for (const [key, value] of Object.entries(directives)) { + if (value === false) { + delete result[key]; + } else { + result[key] = value; + } + } + } + + return result; +} diff --git a/yarn.lock b/yarn.lock index 53e68b28f8..0179e179e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3393,6 +3393,7 @@ __metadata: express: ^4.17.1 express-promise-router: ^4.1.0 fs-extra: 10.1.0 + helmet: ^6.0.0 minimatch: ^5.0.0 node-forge: ^1.3.1 selfsigned: ^2.0.0