backend-app-api: split out readHelmetOptions

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-01-05 11:22:54 +01:00
parent 1fed5327c3
commit 4097435eeb
6 changed files with 167 additions and 188 deletions
+1
View File
@@ -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",
@@ -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();
});
});
});
@@ -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<string, string[]>;
/**
* 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<string, string[] | false> | undefined {
const cc = config.getOptionalConfig('csp');
if (!cc) {
return undefined;
}
const result: Record<string, string[] | false> = {};
for (const key of cc.keys()) {
if (cc.get(key) === false) {
result[key] = false;
} else {
result[key] = cc.getStringArray(key);
}
}
return result;
}
@@ -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/);
});
});
@@ -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<string, string[] | false> | 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<string, string[] | false> = {};
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;
}
+1
View File
@@ -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