Skip to content

Commit

Permalink
rework csp report header to work with inline script hashes and strict…
Browse files Browse the repository at this point in the history
…-dynamic to allow those inline scripts to load further scripts
  • Loading branch information
Richard Bangay committed Jul 10, 2024
1 parent 2f27a0c commit 1c9eaa9
Show file tree
Hide file tree
Showing 6 changed files with 115 additions and 89 deletions.
63 changes: 38 additions & 25 deletions server/html.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { createHash } from 'crypto';
import type { Globals } from '../shared/globals';

interface HtmlAndScriptHashes {
body: string;
hashes: string[];
}

declare let WEBPACK_BUILD: string;

/**
Expand All @@ -11,33 +17,40 @@ declare let WEBPACK_BUILD: string;
*/

const insertGlobals = (globals: Globals) => {
return `<script>
window.guardian = ${JSON.stringify(globals)}
</script>`;
return `window.guardian = ${JSON.stringify(globals)}`;
};

const html: (_: {
const htmlAndScriptHashes: (_: {
readonly title: string;
readonly src: string;
readonly globals: Globals;
}) => string = ({ title, src, globals }) => `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title}</title>
${insertGlobals(globals)}
<link rel="shortcut icon" type="image/png" href="https://assets.guim.co.uk/images/favicons/46bd2faa1ab438684a6d4528a655a8bd/32x32.ico" />
</head>
<body style="margin:0">
<div id="app"></div>
</body>
<script src="${src}?release=${WEBPACK_BUILD}"></script>
</html>
`;

export { html };
}) => HtmlAndScriptHashes = ({ title, src, globals }) => {
const mainScriptBundleSrc = `var s = document.createElement("script"); s.src = "${src}?release=${WEBPACK_BUILD}";document.body.appendChild(s);`;
const setGuardianWindowSrc = insertGlobals(globals);
return {
body: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title}</title>
<script>${setGuardianWindowSrc}</script>
<link rel="shortcut icon" type="image/png" href="https://assets.guim.co.uk/images/favicons/46bd2faa1ab438684a6d4528a655a8bd/32x32.ico" />
</head>
<body style="margin:0">
<div id="app"></div>
</body>
<script>${mainScriptBundleSrc}</script>
</html>
`,
hashes: [
createHash('sha256').update(mainScriptBundleSrc).digest('base64'),
createHash('sha256').update(setGuardianWindowSrc).digest('base64'),
],
};
};

export { htmlAndScriptHashes };
13 changes: 5 additions & 8 deletions server/routes/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as Sentry from '@sentry/node';
import { Router } from 'express';
import { featureSwitches } from '@/shared/featureSwitches';
import type { MembersDataApiResponse } from '@/shared/productResponse';
import { isProduct, MDA_TEST_USER_HEADER } from '@/shared/productResponse';
import {
Expand Down Expand Up @@ -354,12 +353,10 @@ router.get(
router.post('/reminders/cancel', cancelReminderHandler);
router.post('/reminders/reactivate', reactivateReminderHandler);

if (featureSwitches.cspSecurityAudit) {
router.post('/csp-audit-report-endpoint', (req, res) => {
const parsedBody = JSON.parse(req.body.toString());
log.warn(JSON.stringify(parsedBody));
res.status(204).end();
});
}
router.post('/csp-audit-report-endpoint', (req, res) => {
const parsedBody = JSON.parse(req.body.toString());
log.warn(JSON.stringify(parsedBody));
res.status(204).end();
});

export { router };
41 changes: 24 additions & 17 deletions server/routes/helpCentreFrontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { Router } from 'express';
import type { Request, Response } from 'express';
import { DEFAULT_PAGE_TITLE } from '../../shared/helpCentreConfig';
import { conf } from '../config';
import { html } from '../html';
import { htmlAndScriptHashes } from '../html';
import { withIdentity } from '../middleware/identityMiddleware';
import { createCsp } from '../server';
import {
clientDSN,
getRecaptchaPublicKey,
Expand All @@ -12,25 +13,31 @@ import {

const router = Router();

router.use(withIdentity());

router.use(async (_: Request, res: Response) => {
router.use(withIdentity(), async (_: Request, res: Response) => {

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
const title = DEFAULT_PAGE_TITLE;
const src = '/static/help-centre.js';

res.send(
html({
title,
src,
globals: {
domain: conf.DOMAIN,
dsn: clientDSN,
identityDetails: res.locals.identity,
recaptchaPublicKey: await getRecaptchaPublicKey(),
...(await getStripePublicKeys()),
},
}),
);
const htmlStrAndScriptHashes = htmlAndScriptHashes({
title,
src,
globals: {
domain: conf.DOMAIN,
dsn: clientDSN,
identityDetails: res.locals.identity,
recaptchaPublicKey: await getRecaptchaPublicKey(),
...(await getStripePublicKeys()),
},
});

res.set({
'Report-To':
'{ "group": "csp-endpoint", "endpoints": [ { "url": "/api/csp-audit-report-endpoint" } ] }',
'Content-Security-Policy-Report-Only': createCsp(
htmlStrAndScriptHashes.hashes,
),
});

res.send(htmlStrAndScriptHashes);
});

export { router };
37 changes: 23 additions & 14 deletions server/routes/mmaFrontend.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Request, Response } from 'express';
import { Router } from 'express';
import { conf } from '../config';
import { html } from '../html';
import { htmlAndScriptHashes } from '../html';
import { withIdentity } from '../middleware/identityMiddleware';
import { createCsp } from '../server';
import { csrfValidateMiddleware } from '../util';
import {
clientDSN,
Expand All @@ -23,19 +24,27 @@ router.use(withIdentity(), async (req: Request, res: Response) => {
sameSite: 'strict',
});

res.send(
html({
title,
src,
globals: {
domain: conf.DOMAIN,
dsn: clientDSN,
identityDetails: res.locals.identity,
recaptchaPublicKey: await getRecaptchaPublicKey(),
...(await getStripePublicKeys()),
},
}),
);
const htmlStrAndScriptHashes = htmlAndScriptHashes({
title,
src,
globals: {
domain: conf.DOMAIN,
dsn: clientDSN,
identityDetails: res.locals.identity,
recaptchaPublicKey: await getRecaptchaPublicKey(),
...(await getStripePublicKeys()),
},
});

res.set({
'Report-To':
'{ "group": "csp-endpoint", "endpoints": [ { "url": "/api/csp-audit-report-endpoint" } ] }',
'Content-Security-Policy-Report-Only': createCsp(
htmlStrAndScriptHashes.hashes,
),
});

res.send(htmlStrAndScriptHashes.body);
});

export { router };
48 changes: 25 additions & 23 deletions server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import cookieParser from 'cookie-parser';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { default as express } from 'express';
import helmet from 'helmet';
import { featureSwitches } from '../shared/featureSwitches';
import { MAX_FILE_ATTACHMENT_SIZE_KB } from '../shared/fileUploadUtils';
import { conf } from './config';
import { log } from './log';
Expand All @@ -18,6 +17,7 @@ const server = express();
const oktaConfig = await getConfig();

declare let WEBPACK_BUILD: string;

if (conf.SERVER_DSN) {
Sentry.init({
dsn: conf.SERVER_DSN,
Expand All @@ -40,29 +40,31 @@ if (conf.DOMAIN === 'thegulocal.com') {

server.use(helmet());

if (featureSwitches.cspSecurityAudit) {
const cspDefaultSrcAllowList = [
"'self'",
'https://sourcepoint.theguardian.com',
'https://gnm-app.quantummetric.com',
'https://assets.guim.co.uk',
'https://ophan.theguardian.com',
].join(' ');
const csp = [
'report-uri /api/csp-audit-report-endpoint',
'report-to csp-endpoint',
`default-src ${cspDefaultSrcAllowList}`,
`style-src 'unsafe-inline'`, // this is unsafe but needed for now for emotion
];
server.use(function (_: Request, res: Response, next: NextFunction) {
res.set({
'Report-To':
'{ "group": "csp-endpoint", "endpoints": [ { "url": "/api/csp-audit-report-endpoint" } ] }',
'Content-Security-Policy-Report-Only': `${csp.join('; ')};`,
});
next();
export const createCsp = (hashes: string[]) => {
return `
script-src ${hashes
.map((hash) => `'sha256-${hash}'`)
.join(' ')} 'strict-dynamic';
style-src 'unsafe-inline';
object-src 'none';
base-uri 'none';
`;
};

server.use(function (_: Request, res: Response, next: NextFunction) {
/*
* This sets a default csp header, this is overriden in:
* - mmaFrontend.ts
* - helpcentreFrontend.ts
* Where a more specific policy with script hashes can be added
*/
res.set({
'Report-To':
'{ "group": "csp-endpoint", "endpoints": [ { "url": "/api/csp-audit-report-endpoint" } ] }',
'Content-Security-Policy-Report-Only': createCsp([]),
});
}
next();
});

const serveStaticAssets: RequestHandler = express.static(__dirname + '/static');

Expand Down
2 changes: 0 additions & 2 deletions shared/featureSwitches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,12 @@ type FeatureSwitchName =
| 'appSubscriptions'
| 'supporterPlusUpdateAmount'
| 'digisubSave'
| 'cspSecurityAudit'
| 'supporterplusCancellationOffer';

export const featureSwitches: Record<FeatureSwitchName, boolean> = {
exampleFeature: false,
appSubscriptions: true,
supporterPlusUpdateAmount: true,
digisubSave: true,
cspSecurityAudit: true,
supporterplusCancellationOffer: true,
};

0 comments on commit 1c9eaa9

Please sign in to comment.