diff --git a/server/html.ts b/server/html.ts index 107ee749a..fabbfd634 100644 --- a/server/html.ts +++ b/server/html.ts @@ -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; /** @@ -11,33 +17,40 @@ declare let WEBPACK_BUILD: string; */ const insertGlobals = (globals: Globals) => { - return ``; + return `window.guardian = ${JSON.stringify(globals)}`; }; -const html: (_: { +const htmlAndScriptHashes: (_: { readonly title: string; readonly src: string; readonly globals: Globals; -}) => string = ({ title, src, globals }) => ` - - - - - - ${title} - - ${insertGlobals(globals)} - - - - -
- - - - -`; - -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: ` + + + + + + ${title} + + + + + + +
+ + + + `, + hashes: [ + createHash('sha256').update(mainScriptBundleSrc).digest('base64'), + createHash('sha256').update(setGuardianWindowSrc).digest('base64'), + ], + }; +}; + +export { htmlAndScriptHashes }; diff --git a/server/routes/api.ts b/server/routes/api.ts index b288d6db6..ff1965edd 100644 --- a/server/routes/api.ts +++ b/server/routes/api.ts @@ -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 { @@ -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 }; diff --git a/server/routes/helpCentreFrontend.ts b/server/routes/helpCentreFrontend.ts index 8c00826a6..189fbd480 100644 --- a/server/routes/helpCentreFrontend.ts +++ b/server/routes/helpCentreFrontend.ts @@ -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, @@ -12,25 +13,31 @@ import { const router = Router(); -router.use(withIdentity()); - -router.use(async (_: Request, res: Response) => { +router.use(withIdentity(), async (_: Request, res: Response) => { 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 }; diff --git a/server/routes/mmaFrontend.ts b/server/routes/mmaFrontend.ts index cc50428ab..b004bcefa 100644 --- a/server/routes/mmaFrontend.ts +++ b/server/routes/mmaFrontend.ts @@ -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, @@ -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 }; diff --git a/server/server.ts b/server/server.ts index 7698c3e2c..a44dbba53 100644 --- a/server/server.ts +++ b/server/server.ts @@ -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'; @@ -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, @@ -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'); diff --git a/shared/featureSwitches.ts b/shared/featureSwitches.ts index 079b66b9a..ed0e7cf6d 100644 --- a/shared/featureSwitches.ts +++ b/shared/featureSwitches.ts @@ -28,7 +28,6 @@ type FeatureSwitchName = | 'appSubscriptions' | 'supporterPlusUpdateAmount' | 'digisubSave' - | 'cspSecurityAudit' | 'supporterplusCancellationOffer'; export const featureSwitches: Record = { @@ -36,6 +35,5 @@ export const featureSwitches: Record = { appSubscriptions: true, supporterPlusUpdateAmount: true, digisubSave: true, - cspSecurityAudit: true, supporterplusCancellationOffer: true, };