diff --git a/.changeset/rich-deers-run.md b/.changeset/rich-deers-run.md new file mode 100644 index 0000000000..e8a15c4464 --- /dev/null +++ b/.changeset/rich-deers-run.md @@ -0,0 +1,14 @@ +--- +'@backstage/backend-common': patch +--- + +Add glob patterns support to config CORS options. It's possible to send patterns like: + +```yaml +backend: + cors: + origin: + - https://*.my-domain.com + - http://localhost:700[0-9] + - https://sub-domain-+([0-9]).my-domain.com +``` diff --git a/packages/backend-common/src/service/lib/config.test.ts b/packages/backend-common/src/service/lib/config.test.ts index f2f0c88cfb..b80d475517 100644 --- a/packages/backend-common/src/service/lib/config.test.ts +++ b/packages/backend-common/src/service/lib/config.test.ts @@ -15,7 +15,7 @@ */ import { ConfigReader } from '@backstage/config'; -import { readCspOptions } from './config'; +import { readCorsOptions, readCspOptions } from './config'; describe('config', () => { describe('readCspOptions', () => { @@ -42,4 +42,67 @@ describe('config', () => { 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-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index c14fb1af2f..57b814da63 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -16,6 +16,7 @@ import { Config } from '@backstage/config'; import { CorsOptions } from 'cors'; +import { Minimatch } from 'minimatch'; export type BaseOptions = { listenPort?: string | number; @@ -46,6 +47,13 @@ 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; + /** * Reads some base options out of a config object. * @@ -112,7 +120,7 @@ export function readCorsOptions(config: Config): CorsOptions | undefined { } return removeUnknown({ - origin: getOptionalStringOrStrings(cc, 'origin'), + origin: createCorsOriginMatcher(getOptionalStringOrStrings(cc, 'origin')), methods: getOptionalStringOrStrings(cc, 'methods'), allowedHeaders: getOptionalStringOrStrings(cc, 'allowedHeaders'), exposedHeaders: getOptionalStringOrStrings(cc, 'exposedHeaders'), @@ -207,16 +215,45 @@ function getOptionalStringOrStrings( key: string, ): string | string[] | undefined { const value = config.getOptional(key); - if ( - value === undefined || - typeof value === 'string' || - isStringArray(value) - ) { + 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;