Merge pull request #652 from sharetribe/csp-policy

Content Security Policy (CSP)
This commit is contained in:
Kimmo Puputti 2018-01-18 14:18:28 +02:00 committed by GitHub
commit 57d504d18a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 195 additions and 18 deletions

View file

@ -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)

View file

@ -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/

View file

@ -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",

View file

@ -158,5 +158,13 @@
<script src="https://js.stripe.com/v3/"></script>
<!--!preloadedStateScript-->
<!--!script-->
<!--
Note when adding new external scripts/styles/fonts/etc.:
If a Content Security Policy (CSP) is turned on, the new URLs
should be whitelisted in the policy.
See docs/content-security-policy.md for more information.
-->
</body>
</html>

91
server/csp.js Normal file
View file

@ -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,
});
};

View file

@ -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`);

View file

@ -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);
}
};

View file

@ -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',

View file

@ -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',
},
],
},

View file

@ -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: