From 9f25c42f8ce7a1fdcd3b339c5459747d07c24996 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Wed, 17 Jan 2018 16:24:08 +0200 Subject: [PATCH 1/6] Add body-parser middleware --- package.json | 1 + yarn.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 677e6119..14bbacbe 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "array.prototype.find": "^2.0.4", "autosize": "^4.0.0", "basic-auth": "^2.0.0", + "body-parser": "^1.18.2", "classnames": "^2.2.5", "compression": "^1.7.1", "cookie-parser": "^1.4.3", diff --git a/yarn.lock b/yarn.lock index 5fe1c431..2a69f674 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1180,7 +1180,7 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: version "4.11.8" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" -body-parser@1.18.2: +body-parser@1.18.2, body-parser@^1.18.2: version "1.18.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454" dependencies: From ca510877b66502bc313b210eed3a57f0da5c5f1e Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Wed, 17 Jan 2018 16:24:25 +0200 Subject: [PATCH 2/6] Log code and data as well --- server/log.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/log.js b/server/log.js index 7a5655f0..ea232dfd 100644 --- a/server/log.js +++ b/server/log.js @@ -70,5 +70,7 @@ exports.error = (e, code, data) => { Raven.captureException(e, { tags: { code }, extra: data }); } else { console.error(e); + console.error(code); + console.error(data); } }; From 4fb2a4ce95c80627ec1ee809a30187cf68435b58 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Wed, 17 Jan 2018 16:24:43 +0200 Subject: [PATCH 3/6] Update image placeholder URLs --- src/util/test-data.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/test-data.js b/src/util/test-data.js index ae0caaa1..145c2a4b 100644 --- a/src/util/test-data.js +++ b/src/util/test-data.js @@ -62,13 +62,13 @@ export const createImage = id => ({ name: 'square', height: 408, width: 408, - url: 'https://placehold.it/408x408', + url: 'https://via.placeholder.com/408x408', }, { name: 'square2x', height: 816, width: 816, - url: 'https://placehold.it/816x816', + url: 'https://via.placeholder.com/816x816', }, ], }, From 37cd1fdb4c1f09a04a09d7abc59eb8197ce43a3e Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Wed, 17 Jan 2018 16:25:17 +0200 Subject: [PATCH 4/6] Use https placeholder images --- .../SectionThumbnailLinks.example.js | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/components/SectionThumbnailLinks/SectionThumbnailLinks.example.js b/src/components/SectionThumbnailLinks/SectionThumbnailLinks.example.js index a3b93fb9..996a8b2e 100644 --- a/src/components/SectionThumbnailLinks/SectionThumbnailLinks.example.js +++ b/src/components/SectionThumbnailLinks/SectionThumbnailLinks.example.js @@ -8,13 +8,13 @@ export const TwoNamedLinksWithHeadings = { linksPerRow: 2, links: [ { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?1' } }, text: 'Link 1', }, { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?2' } }, text: 'Link 2', @@ -32,19 +32,19 @@ export const ThreeExternalLinksWithHeadings = { linksPerRow: 3, links: [ { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'ExternalLink', href: 'http://example.com/1' }, text: 'Link 1', }, { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'ExternalLink', href: 'http://example.com/2' }, text: 'Link 2', }, { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'ExternalLink', href: 'http://example.com/3' }, text: 'Link 3', @@ -62,25 +62,25 @@ export const FourLinks = { linksPerRow: 2, links: [ { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?1' } }, text: 'Link 1 with quite a long text that tests how the items below align', }, { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?2' } }, text: 'Link 2', }, { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?3' } }, text: 'Link 3', }, { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?4' } }, text: 'Link 4', @@ -96,38 +96,38 @@ export const SixLinks = { linksPerRow: 3, links: [ { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?1' } }, text: 'Link 1', }, { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?2' } }, searchQuery: '?2', text: 'Link 2', }, { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?3' } }, text: 'Link 3', }, { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?4' } }, text: 'Link 4', }, { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?5' } }, text: 'Link 5', }, { - imageUrl: 'http://lorempixel.com/648/448/', + imageUrl: 'https://lorempixel.com/648/448/', imageAltText, linkProps: { type: 'NamedLink', name: 'SearchPage', to: { search: '?6' } }, text: 'Link 6', From 4552a9cf292fd898036fe3104c01731330bc3ef3 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Wed, 17 Jan 2018 16:35:42 +0200 Subject: [PATCH 5/6] Add Content Security Policy (CSP) middleware --- server/csp.js | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ server/index.js | 31 +++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 server/csp.js 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`); From 510b2d73037cd2409e59cac9726caf191150e11e Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Thu, 18 Jan 2018 11:13:54 +0200 Subject: [PATCH 6/6] Add CSP docs --- docs/README.md | 1 + docs/content-security-policy.md | 43 +++++++++++++++++++++++++++++++++ public/index.html | 8 ++++++ 3 files changed, 52 insertions(+) create mode 100644 docs/content-security-policy.md diff --git a/docs/README.md b/docs/README.md index a6cd401c..f5c88060 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,3 +19,4 @@ Documentation for specific topics can be found in the following files: * [Colors and icons](colors-and-icons.md) * [Starting a new customisation project](starting-a-new-customisation-project.md) * [Google Maps](google-maps.md) +* [Content Security Policy (CSP)](content-security-policy.md) diff --git a/docs/content-security-policy.md b/docs/content-security-policy.md new file mode 100644 index 00000000..196a9bf1 --- /dev/null +++ b/docs/content-security-policy.md @@ -0,0 +1,43 @@ +# Content Security Policy (CSP) + +According to [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP): + +> Content Security Policy (CSP) is an added layer of security that helps to detect and mitigate +> certain types of attacks, including Cross Site Scripting (XSS) and data injection attacks. These +> attacks are used for everything from data theft to site defacement or distribution of malware. + +This document describes how to use a CSP with the Starter App. By default the CSP is disabled. By +turning it on, the default whitelist in [server/csp.js](../server/csp.js) works with the all the +URLs and tools that come with the application. + +## Setup + +The CSP is configured using the `REACT_APP_CSP` environment variable. + +Possible values: + +* not set: disabled +* `REACT_APP_CSP=report`: Enabled, but policy violations are only reported +* `REACT_APP_CSP=block`: Enabled. Policy violations are reported and requests that violate the + policy are blocked + +If error logging with Sentry is enabled (See [Error logging with Sentry](sentry.md)), the reports +are sent to Sentry. Otherwise the reports are just logged in the backend. + +## Extending the policy + +If you want to whitelist new sources (for example adding a new external analytics service) and keep +the CSP enabled, you should add the domains to the white list in [server/csp.js](../server/csp.js). + +The easiest way to do this is to first turn on the policy in report mode and then see what policy +violations are logged to the browser developer console or to the backend policy violation report +URL. + +## Resources + +To understand what CSP is and how browsers work, here are some resources: + +* https://content-security-policy.com/ +* https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP +* https://ponyfoo.com/articles/content-security-policy-in-express-apps +* https://helmetjs.github.io/docs/csp/ diff --git a/public/index.html b/public/index.html index ed031279..09ed1a31 100644 --- a/public/index.html +++ b/public/index.html @@ -158,5 +158,13 @@ + +