backend-app-api: split out readCorsOptions

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-01-05 01:08:34 +01:00
parent 950e0392aa
commit 1fed5327c3
3 changed files with 162 additions and 105 deletions
@@ -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<string, string[]>;
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<T extends object>(obj: T): T {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined),
) as T;
}
@@ -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();
});
});
@@ -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 ?? '')),
);
};
}