diff --git a/.env.sample b/.env.sample index 11daa60..62a4758 100644 --- a/.env.sample +++ b/.env.sample @@ -33,8 +33,10 @@ EXPRESS_MAXIMUM_LOGS_FILE_SIZE=5242880 # 5MB EXPRESS_MAXIMUM_LOG_FILES_NUMBER=5 EXPRESS_LOGS_FILE_PATH='/home/.express/reveal-express-server.log -# https://github.com/helmetjs/helmet#reference +# https://github.com/onaio/express-server/blob/f93e6120c683ca2ada29e0e0fa1c99cf0726f5ec/src/configs/settings.ts#L3C15-L3C15 EXPRESS_CONTENT_SECURITY_POLICY_CONFIG=`{"default-src":["'self'", "smartregister.org", "github.com"]}` +EXPRESS_CONTENT_SECURITY_POLICY_CONFIG=`{"default-src":["'self'", "smartregister.org", "github.com"], useDefaults:false, reportOnly: true}` +EXPRESS_CONTENT_SECURITY_POLICY_CONFIG=`false` EXPRESS_REDIS_STAND_ALONE_URL=redis://username:authpassword@127.0.0.1:6379/4 diff --git a/src/app/index.ts b/src/app/index.ts index 082f9c3..810b3fc 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -36,12 +36,12 @@ import { EXPRESS_SESSION_SECRET, EXPRESS_REDIS_STAND_ALONE_URL, EXPRESS_REDIS_SENTINEL_CONFIG, - EXPRESS_CONTENT_SECURITY_POLICY_CONFIG, EXPRESS_RESPONSE_HEADERS, EXPRESS_OPENSRP_SCOPES, } from '../configs/envs'; import { SESSION_IS_EXPIRED, TOKEN_NOT_FOUND, TOKEN_REFRESH_FAILED } from '../constants'; import { parseOauthClientData, sessionLogout } from './utils'; +import { readCspOptionsConfig } from '../configs/settings'; const opensrpAuth = new ClientOAuth2({ accessTokenUri: EXPRESS_OPENSRP_ACCESS_TOKEN_URL, @@ -60,14 +60,13 @@ const app = express(); app.use(compression()); // Compress all routes // helps mitigate cross-site scripting attacks and other known vulnerabilities +const cspConfig = readCspOptionsConfig(); app.use( helmet({ // override default contentSecurityPolicy directive like script-src to include cloudflare cdn and github static content // might consider turning this off to allow individual front-ends set Content-Security-Policy on meta tags themselves if list grows long // - contentSecurityPolicy: { - directives: EXPRESS_CONTENT_SECURITY_POLICY_CONFIG, - }, + contentSecurityPolicy: cspConfig, crossOriginEmbedderPolicy: false, }), ); diff --git a/src/app/tests/index.test.ts b/src/app/tests/index.test.ts index 4d44a7f..58bf64b 100644 --- a/src/app/tests/index.test.ts +++ b/src/app/tests/index.test.ts @@ -4,6 +4,7 @@ import ClientOauth2 from 'client-oauth2'; import request from 'supertest'; import express from 'express'; import Redis from 'ioredis'; +import { resolve } from 'path'; import { EXPRESS_FRONTEND_OPENSRP_CALLBACK_URL, EXPRESS_SESSION_LOGIN_URL, @@ -424,6 +425,54 @@ describe('src/index.ts', () => { done(); }); + it('can disable express csp configs', (done) => { + jest.resetModules(); + jest.mock('../../configs/envs', () => ({ + ...jest.requireActual('../../configs/envs'), + EXPRESS_CONTENT_SECURITY_POLICY_CONFIG: 'false', + EXPRESS_REACT_BUILD_PATH: resolve(__dirname, '../../configs/__mocks__/build'), + })); + const { default: app2 } = jest.requireActual('../index'); + request(app2) + .get('/') + .expect(200) + .expect((res) => { + const csp = res.headers['content-security-policy']; + expect(csp).toBeUndefined(); + }) + .catch((err: Error) => { + throw err; + }) + .finally(() => { + done(); + }); + }); + + it('can report csp conflicts instead of failing', (done) => { + jest.resetModules(); + jest.mock('../../configs/envs', () => ({ + ...jest.requireActual('../../configs/envs'), + EXPRESS_CONTENT_SECURITY_POLICY_CONFIG: `{"reportOnly": true, "useDefaults": false, "default-src": ["''self''"]}`, + EXPRESS_REACT_BUILD_PATH: resolve(__dirname, '../../configs/__mocks__/build'), + })); + const { default: app2 } = jest.requireActual('../index'); + request(app2) + .get('/') + .expect(200) + .expect((res) => { + const csp = res.headers['content-security-policy']; + const cspOnly = res.headers['content-security-policy-report-only']; + expect(csp).toBeUndefined(); + expect(cspOnly).toEqual(`default-src ''self''`); + }) + .catch((err: Error) => { + throw err; + }) + .finally(() => { + done(); + }); + }); + it('uses single redis node as session storage', (done) => { jest.resetModules(); jest.mock('../../configs/envs', () => ({ diff --git a/src/configs/__mocks__/envs.ts b/src/configs/__mocks__/envs.ts index 5019022..b1ddb75 100644 --- a/src/configs/__mocks__/envs.ts +++ b/src/configs/__mocks__/envs.ts @@ -67,10 +67,7 @@ export const EXPRESS_MAXIMUM_LOG_FILES_NUMBER = 5; export const EXPRESS_LOGS_FILE_PATH = './logs/default-error.log'; export const EXPRESS_COMBINED_LOGS_FILE_PATH = './logs/default-error-and-info.log'; -export const EXPRESS_CONTENT_SECURITY_POLICY_CONFIG = { - 'default-src': ["'self'"], - reportUri: 'https://example.com', -}; +export const EXPRESS_CONTENT_SECURITY_POLICY_CONFIG = `{"default-src":["'self'"],"reportUri":"https://example.com"}`; export const EXPRESS_RESPONSE_HEADERS = { 'Report-To': diff --git a/src/configs/envs.ts b/src/configs/envs.ts index 0e74194..804a0e4 100644 --- a/src/configs/envs.ts +++ b/src/configs/envs.ts @@ -69,13 +69,7 @@ export const EXPRESS_LOGS_FILE_PATH = process.env.EXPRESS_LOGS_FILE_PATH || './l export const EXPRESS_COMBINED_LOGS_FILE_PATH = process.env.EXPRESS_COMBINED_LOGS_FILE_PATH || './logs/default-error-and-info.log'; -const defaultCsp = JSON.stringify({ - 'default-src': ['none'], -}); - -export const EXPRESS_CONTENT_SECURITY_POLICY_CONFIG = JSON.parse( - process.env.EXPRESS_CONTENT_SECURITY_POLICY_CONFIG || defaultCsp, -); +export const { EXPRESS_CONTENT_SECURITY_POLICY_CONFIG } = process.env; // see https://github.com/luin/ioredis#connect-to-redis export const { EXPRESS_REDIS_STAND_ALONE_URL } = process.env; diff --git a/src/configs/settings.ts b/src/configs/settings.ts new file mode 100644 index 0000000..485b143 --- /dev/null +++ b/src/configs/settings.ts @@ -0,0 +1,20 @@ +import { EXPRESS_CONTENT_SECURITY_POLICY_CONFIG } from './envs'; + +export type CspSettings = Record> & { + useDefaults?: boolean; + reportOnly?: boolean; +}; + +/** parse and return helmets' csp policy from an env string */ +export const readCspOptionsConfig = () => { + /** leave default behavior in place as the default, to disable env, dev needs to pass false as the value */ + if (EXPRESS_CONTENT_SECURITY_POLICY_CONFIG === undefined) { + return {}; + } + if (EXPRESS_CONTENT_SECURITY_POLICY_CONFIG === 'false') { + return false; + } + const cspConfig = JSON.parse(EXPRESS_CONTENT_SECURITY_POLICY_CONFIG) as CspSettings; + const { useDefaults, reportOnly, ...rest } = cspConfig; + return { directives: rest, useDefaults, reportOnly }; +};