diff --git a/server/csp.js b/server/csp.js new file mode 100644 index 00000000..233de295 --- /dev/null +++ b/server/csp.js @@ -0,0 +1,91 @@ +const helmet = require('helmet'); +const { config } = require('./importer'); + +const dev = process.env.REACT_APP_ENV === 'development'; +const googleAnalyticsEnabled = !!process.env.REACT_APP_GOOGLE_ANALYTICS_ID; + +/** + * Middleware for creating a Content Security Policy + * + * @param {String} reportUri URL where the browser will POST the + * policy violation reports + * + * @param {Boolean} enforceSsl When SSL is enforced, all mixed content + * is blocked/reported by the policy + * + * @param {Boolean} reportOnly In the report mode, requests are only + * reported to the report URL instead of blocked + */ +module.exports = (reportUri, enforceSsl, reportOnly) => { + const self = "'self'"; + const inline = "'unsafe-inline'"; + const sharetribeApi = config.sdk.baseUrl; + const sharetribeAssets = 'https://assets-sharetribecom.sharetribe.com/'; + const imgix = '*.imgix.net/'; + const loremPixel = 'https://lorempixel.com/'; + const placeholder = 'https://via.placeholder.com/'; + const googleAnalytics = 'https://www.google-analytics.com/'; + const googleMaps = 'https://maps.googleapis.com/'; + const googleStaticCsi = 'https://csi.gstatic.com/'; + const googleStaticFonts = 'https://fonts.gstatic.com/'; + const googleStaticMaps = 'https://maps.gstatic.com/'; + const googleFonts = 'https://fonts.googleapis.com/'; + const stripeJs = 'https://js.stripe.com/'; + const stripeQ = 'https://q.stripe.com/'; + + const scriptSrc = [self, inline, googleMaps, stripeJs]; + + const styleSrc = [self, inline, googleFonts]; + + const imgSrc = [ + self, + 'data:', + imgix, + loremPixel, + placeholder, + stripeQ, + googleMaps, + googleStaticCsi, + googleStaticMaps, + ]; + + const connectSrc = [self, sharetribeApi, googleMaps]; + + const fontSrc = [self, sharetribeAssets, googleStaticFonts]; + + const frameSrc = [self, stripeJs]; + + if (googleAnalyticsEnabled) { + // Only whitelist Google Analytics URLs when the analytics is + // enabled. + scriptSrc.push(googleAnalytics); + imgSrc.push(googleAnalytics); + } + if (dev) { + // The default Core docker-compose setup server images from this + // URL locally. + imgSrc.push('*.localhost:8000'); + } + + const directives = { + scriptSrc, + styleSrc, + imgSrc, + connectSrc, + fontSrc, + frameSrc, + formAction: [self], + defaultSrc: [self], + baseUri: [self], + reportUri, + }; + + if (enforceSsl) { + directives.blockAllMixedContent = true; + } + + return helmet.contentSecurityPolicy({ + directives, + reportOnly, + }); +}; diff --git a/server/index.js b/server/index.js index 9a1004f1..8872b253 100644 --- a/server/index.js +++ b/server/index.js @@ -20,6 +20,7 @@ const express = require('express'); const helmet = require('helmet'); const compression = require('compression'); const cookieParser = require('cookie-parser'); +const bodyParser = require('body-parser'); const enforceSsl = require('express-enforces-ssl'); const path = require('path'); const sharetribeSdk = require('sharetribe-sdk'); @@ -31,6 +32,7 @@ const dataLoader = require('./dataLoader'); const fs = require('fs'); const log = require('./log'); const { sitemapStructure } = require('./sitemap'); +const csp = require('./csp'); const buildPath = path.resolve(__dirname, '..', 'build'); const env = process.env.REACT_APP_ENV || 'production'; @@ -41,6 +43,9 @@ const CLIENT_ID = const BASE_URL = process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL || 'http://localhost:8088'; const USING_SSL = process.env.REACT_APP_SHARETRIBE_USING_SSL === 'true'; const TRUST_PROXY = process.env.SERVER_SHARETRIBE_TRUST_PROXY || null; +const CSP = process.env.REACT_APP_CSP; +const cspReportUrl = '/csp-report'; +const cspEnabled = CSP === 'block' || CSP === 'report'; const app = express(); const errorPage = fs.readFileSync(path.join(buildPath, '500.html'), 'utf-8'); @@ -60,6 +65,23 @@ app.use(log.requestHandler()); // See: https://www.npmjs.com/package/helmet app.use(helmet()); +if (cspEnabled) { + // When a CSP directive is violated, the browser posts a JSON body + // to the defined report URL and we need to parse this body. + app.use( + bodyParser.json({ + type: ['json', 'application/csp-report'], + }) + ); + + // CSP can be turned on in report or block mode. In report mode, the + // browser checks the policy and calls the report URL when the + // policy is violated, but doesn't block any requests. In block + // mode, the browser also blocks the requests. + const reportOnly = CSP === 'report'; + app.use(csp(cspReportUrl, USING_SSL, reportOnly)); +} + // Redirect HTTP to HTTPS if USING_SSL is `true`. // This also works behind reverse proxies (load balancers) as they are for example used by Heroku. // In such cases, however, the TRUST_PROXY parameter has to be set (see below) @@ -193,6 +215,15 @@ app.get('*', (req, res) => { // will be logged there. app.use(log.errorHandler()); +if (cspEnabled) { + // Handler for CSP violation reports. + app.post(cspReportUrl, (req, res) => { + const report = req.body ? req.body['csp-report'] : null; + log.error(new Error('CSP violation'), 'csp-violation', report); + res.status(204).end(); + }); +} + app.listen(PORT, () => { const mode = dev ? 'development' : 'production'; console.log(`Listening to port ${PORT} in ${mode} mode`);