From 1fed5327c37827106eab88ee76855113b8d329e7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Jan 2023 01:08:34 +0100 Subject: [PATCH] backend-app-api: split out readCorsOptions Signed-off-by: Patrik Oldsberg --- .../implementations/rootHttpRouter/config.ts | 105 ------------------ .../rootHttpRouter/readCorsOptions.test.ts | 81 ++++++++++++++ .../rootHttpRouter/readCorsOptions.ts | 81 ++++++++++++++ 3 files changed, 162 insertions(+), 105 deletions(-) create mode 100644 packages/backend-app-api/src/services/implementations/rootHttpRouter/readCorsOptions.test.ts create mode 100644 packages/backend-app-api/src/services/implementations/rootHttpRouter/readCorsOptions.ts diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/config.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/config.ts index a147b67ea1..931fb48a63 100644 --- a/packages/backend-app-api/src/services/implementations/rootHttpRouter/config.ts +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/config.ts @@ -15,8 +15,6 @@ */ import { Config } from '@backstage/config'; -import { CorsOptions } from 'cors'; -import { Minimatch } from 'minimatch'; export type BaseOptions = { listenPort?: string | number; @@ -45,47 +43,6 @@ export type CertificateAttributes = { */ export type CspOptions = Record; -type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[]; - -type CustomOrigin = ( - requestOrigin: string | undefined, - callback: (err: Error | null, origin?: StaticOrigin) => void, -) => void; - -/** - * Attempts to read a CORS options object from the root of a config object. - * - * @param config - The root of a backend config object - * @returns A CORS options object, or undefined if not specified - * - * @example - * ```json - * { - * cors: { - * origin: "http://localhost:3000", - * credentials: true - * } - * } - * ``` - */ -export function readCorsOptions(config: Config): CorsOptions | undefined { - const cc = config.getOptionalConfig('cors'); - if (!cc) { - return undefined; - } - - return removeUnknown({ - origin: createCorsOriginMatcher(getOptionalStringOrStrings(cc, 'origin')), - methods: getOptionalStringOrStrings(cc, 'methods'), - allowedHeaders: getOptionalStringOrStrings(cc, 'allowedHeaders'), - exposedHeaders: getOptionalStringOrStrings(cc, 'exposedHeaders'), - credentials: cc.getOptionalBoolean('credentials'), - maxAge: cc.getOptionalNumber('maxAge'), - preflightContinue: cc.getOptionalBoolean('preflightContinue'), - optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'), - }); -} - /** * Attempts to read a CSP options object from the root of a config object. * @@ -121,65 +78,3 @@ export function readCspOptions( return result; } - -function getOptionalStringOrStrings( - config: Config, - key: string, -): string | string[] | undefined { - const value = config.getOptional(key); - if (value === undefined || isStringOrStrings(value)) { - return value; - } - throw new Error(`Expected string or array of strings, got ${typeof value}`); -} - -function createCorsOriginMatcher( - originValue: string | string[] | undefined, -): CustomOrigin | undefined { - if (originValue === undefined) { - return originValue; - } - - if (!isStringOrStrings(originValue)) { - throw new Error( - `Expected string or array of strings, got ${typeof originValue}`, - ); - } - - const allowedOrigin = - typeof originValue === 'string' ? [originValue] : originValue; - - const allowedOriginPatterns = - allowedOrigin?.map( - pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }), - ) ?? []; - - return (origin, callback) => { - return callback( - null, - allowedOriginPatterns.some(pattern => pattern.match(origin ?? '')), - ); - }; -} - -function isStringOrStrings(value: any): value is string | string[] { - return typeof value === 'string' || isStringArray(value); -} - -function isStringArray(value: any): value is string[] { - if (!Array.isArray(value)) { - return false; - } - for (const v of value) { - if (typeof v !== 'string') { - return false; - } - } - return true; -} - -function removeUnknown(obj: T): T { - return Object.fromEntries( - Object.entries(obj).filter(([, v]) => v !== undefined), - ) as T; -} diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/readCorsOptions.test.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/readCorsOptions.test.ts new file mode 100644 index 0000000000..1c70d020c4 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/readCorsOptions.test.ts @@ -0,0 +1,81 @@ +/* + * 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 } from './readCorsOptions'; + +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/readCorsOptions.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/readCorsOptions.ts new file mode 100644 index 0000000000..17c3157ebd --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/readCorsOptions.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2023 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 { CorsOptions } from 'cors'; +import { Minimatch } from 'minimatch'; + +/** + * Attempts to read a CORS options object from the backend configuration object. + * + * @param config - The backend configuration object. + * @returns A CORS options object, or undefined if no cors configuration is present. + * + * @example + * ```ts + * const corsOptions = readCorsOptions(config.getConfig('backend')); + * ``` + */ +export function readCorsOptions(config: Config): CorsOptions | undefined { + const cc = config.getOptionalConfig('cors'); + if (!cc) { + return undefined; + } + + return { + origin: createCorsOriginMatcher(readStringArray(cc, 'origin')), + methods: readStringArray(cc, 'methods'), + allowedHeaders: readStringArray(cc, 'allowedHeaders'), + exposedHeaders: readStringArray(cc, 'exposedHeaders'), + credentials: cc.getOptionalBoolean('credentials'), + maxAge: cc.getOptionalNumber('maxAge'), + preflightContinue: cc.getOptionalBoolean('preflightContinue'), + optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'), + }; +} + +function readStringArray(config: Config, key: string): string[] | undefined { + const value = config.getOptional(key); + if (typeof value === 'string') { + return [value]; + } else if (!value) { + return undefined; + } + return config.getStringArray(key); +} + +function createCorsOriginMatcher(allowedOriginPatterns: string[] | undefined) { + if (!allowedOriginPatterns) { + return undefined; + } + + const allowedOriginMatchers = allowedOriginPatterns.map( + pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }), + ); + + return ( + origin: string | undefined, + callback: ( + err: Error | null, + origin: boolean | string | RegExp | (boolean | string | RegExp)[], + ) => void, + ) => { + return callback( + null, + allowedOriginMatchers.some(pattern => pattern.match(origin ?? '')), + ); + }; +}