backend-app-api: split out readHelmetOptions
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+57
@@ -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/);
|
||||
});
|
||||
});
|
||||
+108
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user