From 3fccfd13a334eba6cf910f5ef5da2ca69232ed61 Mon Sep 17 00:00:00 2001 From: Lauren Zugai Date: Fri, 2 Aug 2019 12:32:54 -0500 Subject: [PATCH] chore(fxa-payments-server): fixes #1923 - add CSP to the payments server Remove payments/content consolidated CSP, fix Prettier-ified files, create separate middleware for payments Fix prettier things Add Stripe CSP things to payments server Add more config vars into proper directives Add stripe checkout API Remove unneeded directive Add tests to csp.test.js Remove unneeded test no-CSP lines Remove isCspRequired check in payment server (not content server) csp.enabled true by default Update CSP violations to report to content server Template literal return for getOrigin Update Stripe url doc --- packages/fxa-content-server/server/lib/csp.js | 1 + .../server/lib/csp/blocking.js | 2 +- .../fxa-content-server/tests/server/csp.js | 11 +- .../server/config/index.js | 58 +++++++++- .../fxa-payments-server/server/lib/csp.js | 18 +++ .../server/lib/csp.test.js | 92 +++++++++++++++ .../server/lib/csp/blocking.js | 106 ++++++++++++++++++ .../server/lib/csp/report-only.js | 20 ++++ .../fxa-payments-server/server/lib/server.js | 35 +++++- 9 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 packages/fxa-payments-server/server/lib/csp.js create mode 100644 packages/fxa-payments-server/server/lib/csp.test.js create mode 100644 packages/fxa-payments-server/server/lib/csp/blocking.js create mode 100644 packages/fxa-payments-server/server/lib/csp/report-only.js diff --git a/packages/fxa-content-server/server/lib/csp.js b/packages/fxa-content-server/server/lib/csp.js index 9429278be9c..aadc9388edf 100644 --- a/packages/fxa-content-server/server/lib/csp.js +++ b/packages/fxa-content-server/server/lib/csp.js @@ -20,6 +20,7 @@ module.exports = function(config) { const cspMiddleware = helmet.contentSecurityPolicy(config.rules); return htmlOnly((req, res, next) => { + cspMiddleware(req, res, next); if (isCspRequired(req)) { cspMiddleware(req, res, next); } else { diff --git a/packages/fxa-content-server/server/lib/csp/blocking.js b/packages/fxa-content-server/server/lib/csp/blocking.js index 98cc7df2e15..9a2e08527a7 100644 --- a/packages/fxa-content-server/server/lib/csp/blocking.js +++ b/packages/fxa-content-server/server/lib/csp/blocking.js @@ -11,7 +11,7 @@ const url = require('url'); function getOrigin(link) { const parsed = url.parse(link); - return parsed.protocol + '//' + parsed.host; + return `${parsed.protocol}//${parsed.host}`; } /** diff --git a/packages/fxa-content-server/tests/server/csp.js b/packages/fxa-content-server/tests/server/csp.js index 2996b828e13..9b4bc32cb99 100644 --- a/packages/fxa-content-server/tests/server/csp.js +++ b/packages/fxa-content-server/tests/server/csp.js @@ -5,8 +5,8 @@ const { registerSuite } = intern.getInterface('object'); const assert = intern.getPlugin('chai').assert; const config = require('../../server/lib/configuration'); -const BlockingRules = require('../../server/lib/csp/blocking'); const path = require('path'); +const blockingRules = require('../../server/lib/csp/blocking'); const proxyquire = require('proxyquire'); const csp = proxyquire(path.join(process.cwd(), 'server', 'lib', 'csp'), { @@ -34,15 +34,12 @@ suite.tests['blockingRules'] = function() { const CDN_SERVER = 'https://static.accounts.firefox.com'; config.set('static_resource_url', CDN_SERVER); - const blockingRules = BlockingRules(config); - const Sources = blockingRules.Sources; + const { Sources, directives, reportOnly } = blockingRules(config); - // Ensure none of the Sources map to `undefined`G + // Ensure none of the Sources map to `undefined` assert.notProperty(Sources, 'undefined'); - assert.isFalse(blockingRules.reportOnly); - - const directives = blockingRules.directives; + assert.isFalse(reportOnly); const connectSrc = directives.connectSrc; assert.lengthOf(connectSrc, 7); diff --git a/packages/fxa-payments-server/server/config/index.js b/packages/fxa-payments-server/server/config/index.js index 24424aaf5de..5501362379a 100644 --- a/packages/fxa-payments-server/server/config/index.js +++ b/packages/fxa-payments-server/server/config/index.js @@ -15,11 +15,41 @@ const conf = convict({ env: 'CLIENT_ADDRESS_DEPTH', format: Number, }, + csp: { + enabled: { + default: true, + doc: 'Send "Content-Security-Policy" header', + env: 'CSP_ENABLED', + }, + /*eslint-disable sorting/sort-object-props*/ + reportUri: { + default: 'http://accounts.firefox.com/_/csp-violation', + doc: 'Location of "report-uri" for full, blocking CSP rules', + env: 'CSP_REPORT_URI', + }, + reportOnly: { + default: false, + doc: + 'DEPRECATED - Only send the "Content-Security-Policy-Report-Only" header', + env: 'CSP_REPORT_ONLY', + }, + reportOnlyEnabled: { + default: false, + doc: 'Send "Content-Security-Policy-Report-Only" header', + env: 'CSP_REPORT_ONLY_ENABLED', + }, + reportOnlyUri: { + default: 'http://accounts.firefox.com/_/csp-violation-report-only', + doc: 'Location of "report-uri" for report-only CSP rules', + env: 'CSP_REPORT_ONLY_URI', + }, + /*eslint-enable sorting/sort-object-props*/ + }, env: { default: 'production', doc: 'The current node.js environment', env: 'NODE_ENV', - format: ['development', 'production'], + format: ['development', 'production', 'test'], }, hstsMaxAge: { default: 31536000, // a year @@ -130,6 +160,14 @@ const conf = convict({ format: 'url', }, }, + profileImages: { + url: { + default: 'http://127.0.0.1:1112', + doc: 'The url of the Firefox Account Profile Image Server', + env: 'FXA_PROFILE_IMAGES_URL', + format: 'url', + }, + }, }, staticResources: { directory: { @@ -158,6 +196,24 @@ const conf = convict({ env: 'STRIPE_API_KEY', format: String, }, + apiUrl: { + default: 'https://api.stripe.com', + doc: 'The Stripe API url', + env: 'STRIPE_API_URL', + format: 'url', + }, + hooksUrl: { + default: 'https://hooks.stripe.com', + doc: 'The Stripe hooks url', + env: 'STRIPE_HOOKS_URL', + format: 'url', + }, + scriptUrl: { + default: 'https://js.stripe.com', + doc: 'The Stripe script url', + env: 'STRIPE_SCRIPT_URL', + format: 'url', + }, }, }); diff --git a/packages/fxa-payments-server/server/lib/csp.js b/packages/fxa-payments-server/server/lib/csp.js new file mode 100644 index 00000000000..8d72c2845bd --- /dev/null +++ b/packages/fxa-payments-server/server/lib/csp.js @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Middleware to take care of CSP. CSP headers are not sent unless config +// option 'csp.enabled' is set (default true). + +'use strict'; +const helmet = require('helmet'); +const htmlOnly = require('./html-middleware'); + +module.exports = function(config) { + const cspMiddleware = helmet.contentSecurityPolicy(config.rules); + + return htmlOnly((req, res, next) => { + cspMiddleware(req, res, next); + }); +}; diff --git a/packages/fxa-payments-server/server/lib/csp.test.js b/packages/fxa-payments-server/server/lib/csp.test.js new file mode 100644 index 00000000000..09d9a5b5e2c --- /dev/null +++ b/packages/fxa-payments-server/server/lib/csp.test.js @@ -0,0 +1,92 @@ +const config = require('../config'); +const blockingRules = require('./csp/blocking'); + +describe('CSP blocking rules', () => { + // force the CDN to be enabled for tests. + const CDN_SERVER = 'https://static.accounts.firefox.com'; + config.set('staticResources.url', CDN_SERVER); + const { Sources, directives, reportOnly } = blockingRules(config); + + it('does not have a Sources value equal `undefined`', () => { + expect(Sources).not.toHaveProperty('undefined'); + expect(reportOnly).toBeFalsy(); + }); + + it('has correct connectSrc directives', () => { + const { connectSrc } = directives; + + expect(connectSrc).toHaveLength(6); + expect(connectSrc).toContain(Sources.SELF); + expect(connectSrc).toContain(Sources.AUTH_SERVER); + expect(connectSrc).toContain(Sources.OAUTH_SERVER); + expect(connectSrc).toContain(Sources.PROFILE_SERVER); + expect(connectSrc).toContain(Sources.PAIRING_SERVER_WEBSOCKET); + expect(connectSrc).toContain(Sources.STRIPE_API_URL); + }); + + it('has correct defaultSrc directives', () => { + const { defaultSrc } = directives; + + expect(defaultSrc).toHaveLength(1); + expect(defaultSrc).toContain(Sources.SELF); + }); + + it('has correct fontSrc directives', () => { + const { fontSrc } = directives; + + expect(fontSrc).toHaveLength(2); + expect(fontSrc).toContain(Sources.SELF); + expect(fontSrc).toContain(CDN_SERVER); + }); + + it('has correct frameSrc directives', () => { + const { frameSrc } = directives; + + expect(frameSrc).toHaveLength(2); + expect(frameSrc).toContain(Sources.STRIPE_SCRIPT_URL); + expect(frameSrc).toContain(Sources.STRIPE_HOOKS_URL); + }); + + it('has correct imgSrc directives', () => { + const { imgSrc } = directives; + + expect(imgSrc).toHaveLength(5); + expect(imgSrc).toContain(Sources.SELF); + expect(imgSrc).toContain(Sources.DATA); + expect(imgSrc).toContain(Sources.GRAVATAR); + expect(imgSrc).toContain(Sources.PROFILE_IMAGES_SERVER); + expect(imgSrc).toContain(CDN_SERVER); + }); + + it('has correct mediaSrc directives', () => { + const { mediaSrc } = directives; + + expect(mediaSrc).toHaveLength(1); + expect(mediaSrc).toContain(Sources.BLOB); + }); + + it('has correct objectSrc directives', () => { + const { mediaSrc } = directives; + + expect(mediaSrc).toHaveLength(1); + expect(mediaSrc).toContain(Sources.BLOB); + }); + + it('has correct scriptSrc directives', () => { + const { scriptSrc } = directives; + + expect(scriptSrc).toHaveLength(3); + expect(scriptSrc).toContain(Sources.SELF); + expect(scriptSrc).toContain(Sources.STRIPE_SCRIPT_URL); + expect(scriptSrc).toContain(CDN_SERVER); + }); + + it('has correct styleSrc directives', () => { + const { styleSrc } = directives; + + expect(styleSrc).toHaveLength(3); + expect(styleSrc).toContain(Sources.SELF); + expect(styleSrc).toContain(Sources.UNSAFE_INLINE); + expect(styleSrc).toContain(CDN_SERVER); + }); +}); diff --git a/packages/fxa-payments-server/server/lib/csp/blocking.js b/packages/fxa-payments-server/server/lib/csp/blocking.js new file mode 100644 index 00000000000..bf30ac84348 --- /dev/null +++ b/packages/fxa-payments-server/server/lib/csp/blocking.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Middleware to take care of CSP. CSP headers are not sent unless config +// option 'csp.enabled' is set (default true). + +const url = require('url'); + +function getOrigin(link) { + const parsed = url.parse(link); + return `${parsed.protocol}//${parsed.host}`; +} + +/** + * blockingCspMiddleware is where to declare rules that will cause a resource + * to be blocked if it runs afowl of a rule. + */ +module.exports = function(config) { + const AUTH_SERVER = getOrigin(config.get('servers.auth.url')); + const BLOB = 'blob:'; + const CDN_URL = config.get('staticResources.url'); + const DATA = 'data:'; + const GRAVATAR = 'https://secure.gravatar.com'; + const OAUTH_SERVER = getOrigin(config.get('servers.oauth.url')); + const PROFILE_SERVER = getOrigin(config.get('servers.profile.url')); + const PROFILE_IMAGES_SERVER = getOrigin( + config.get('servers.profileImages.url') + ); + const PUBLIC_URL = config.get('listen.publicUrl'); + const PAIRING_SERVER_WEBSOCKET = PUBLIC_URL.replace(/^http/, 'ws'); + + const STRIPE_API_URL = getOrigin(config.get('stripe.apiUrl')); + const STRIPE_HOOKS_URL = getOrigin(config.get('stripe.hooksUrl')); + const STRIPE_SCRIPT_URL = getOrigin(config.get('stripe.scriptUrl')); + + // + // Double quoted values + // + const NONE = "'none'"; + // keyword sources - https://www.w3.org/TR/CSP2/#keyword_source + // Note: "'unsafe-eval'" is not used in this module, and "'unsafe-inline'" is + // needed for Stripe inline styles. + const SELF = "'self'"; + const UNSAFE_INLINE = "'unsafe-inline'"; + + function addCdnRuleIfRequired(target) { + if (CDN_URL !== PUBLIC_URL) { + target.push(CDN_URL); + } + return target; + } + + const rules = { + directives: { + connectSrc: [ + SELF, + AUTH_SERVER, + OAUTH_SERVER, + PROFILE_SERVER, + PAIRING_SERVER_WEBSOCKET, + STRIPE_API_URL, + ], + defaultSrc: [SELF], + fontSrc: addCdnRuleIfRequired([SELF]), + frameSrc: [STRIPE_SCRIPT_URL, STRIPE_HOOKS_URL], + imgSrc: addCdnRuleIfRequired([ + SELF, + DATA, + // Gravatar support was removed in #4927, but we don't want + // to break the site for users who already use a Gravatar as + // their profile image. + GRAVATAR, + PROFILE_IMAGES_SERVER, + ]), + mediaSrc: [BLOB], + objectSrc: [NONE], + reportUri: config.get('csp.reportUri'), + scriptSrc: addCdnRuleIfRequired([SELF, STRIPE_SCRIPT_URL]), + styleSrc: addCdnRuleIfRequired([SELF, UNSAFE_INLINE]), + }, + reportOnly: false, + // Sources are exported for unit tests + Sources: { + //eslint-disable-line sorting/sort-object-props + AUTH_SERVER, + BLOB, + CDN_URL, + DATA, + GRAVATAR, + NONE, + OAUTH_SERVER, + PAIRING_SERVER_WEBSOCKET, + PROFILE_IMAGES_SERVER, + PROFILE_SERVER, + PUBLIC_URL, + STRIPE_API_URL, + STRIPE_HOOKS_URL, + STRIPE_SCRIPT_URL, + SELF, + UNSAFE_INLINE, + }, + }; + + return rules; +}; diff --git a/packages/fxa-payments-server/server/lib/csp/report-only.js b/packages/fxa-payments-server/server/lib/csp/report-only.js new file mode 100644 index 00000000000..97329e87215 --- /dev/null +++ b/packages/fxa-payments-server/server/lib/csp/report-only.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * reportOnlyCspMiddleware is where to declare experimental rules that + * will not cause a resource to be blocked if it runs afowl of a rule, but + * will cause the resource to be reported. + * + * If no directives other than `reportUri` are declared, the CSP reportOnly + * middleware will not be added. + */ +module.exports = function(config) { + return { + directives: { + reportUri: config.get('csp.reportOnlyUri'), + }, + reportOnly: true, + }; +}; diff --git a/packages/fxa-payments-server/server/lib/server.js b/packages/fxa-payments-server/server/lib/server.js index 9b50205735f..ed095353b8a 100644 --- a/packages/fxa-payments-server/server/lib/server.js +++ b/packages/fxa-payments-server/server/lib/server.js @@ -22,6 +22,11 @@ module.exports = () => { const sentry = require('@sentry/node'); const serveStatic = require('serve-static'); + const bodyParser = require('body-parser'); + const csp = require('../lib/csp'); + const cspRulesBlocking = require('../lib/csp/blocking')(config); + const cspRulesReportOnly = require('../lib/csp/report-only')(config); + const app = express(); // Each of these config values (e.g., 'servers.content') will be exposed as the given @@ -77,15 +82,37 @@ module.exports = () => { helmet.noSniff(), - // TODO CSP - require('./no-robots') + require('./no-robots'), + + bodyParser.text({ + type: 'text/plain', + }), + + bodyParser.json({ + // the 3 entries: + // json file types, + // all json content-types + // csp reports + type: ['json', '*/json', 'application/csp-report'], + }) ); + if (config.get('csp.enabled')) { + app.use(csp({ rules: cspRulesBlocking })); + } + if (config.get('csp.reportOnlyEnabled')) { + // There has to be more than a `reportUri` + // to enable reportOnly CSP. + if (Object.keys(cspRulesReportOnly.directives).length > 1) { + app.use(csp({ rules: cspRulesReportOnly })); + } + } + if (isCorsRequired()) { // JS, CSS and web font resources served from a CDN // will be ignored unless CORS headers are present. const corsOptions = { - origin: config.get('public_url'), + origin: config.get('listen.publicUrl'), }; app @@ -116,7 +143,7 @@ module.exports = () => { userReq /*, userRes*/ ) { const contentType = proxyRes.headers['content-type']; - if (!contentType || !contentType.startsWith('text/html')) { + if (! contentType || ! contentType.startsWith('text/html')) { return proxyResData; } if (userReq.url.startsWith('/sockjs-node/')) {