Add `Content-Security-Policy` (CSP) (#81)
Add `Content-Security-Policy` (CSP) that restricts the page to just what it is expected to do. This helps limit the damage that can be done by any XSS attack. Fix https://github.com/matrix-org/internal-config/issues/1341
This commit is contained in:
parent
0962075f8d
commit
a0089b0fe4
|
@ -66,6 +66,8 @@ async function _renderHydrogenToStringUnsafe(renderOptions) {
|
|||
assert(renderOptions);
|
||||
assert(renderOptions.vmRenderScriptFilePath);
|
||||
assert(renderOptions.vmRenderContext);
|
||||
assert(renderOptions.pageOptions);
|
||||
assert(renderOptions.pageOptions.cspNonce);
|
||||
|
||||
const { dom, vmContext } = createDomAndSetupVmContext();
|
||||
|
||||
|
@ -78,7 +80,7 @@ async function _renderHydrogenToStringUnsafe(renderOptions) {
|
|||
dom.document.body.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`
|
||||
<script type="text/javascript">
|
||||
<script type="text/javascript" nonce="${renderOptions.pageOptions.cspNonce}">
|
||||
window.matrixPublicArchiveContext = ${safeJson(serializedContext)}
|
||||
</script>
|
||||
`
|
||||
|
|
|
@ -15,8 +15,7 @@ const runInChildProcess = require('../child-process-runner/run-in-child-process'
|
|||
const RENDER_TIMEOUT = 5000;
|
||||
|
||||
async function renderHydrogenToString(renderOptions) {
|
||||
assert(renderOptions.vmRenderScriptFilePath);
|
||||
assert(renderOptions.vmRenderContext);
|
||||
assert(renderOptions);
|
||||
|
||||
// We expect `config` but we should sanity check that we aren't leaking the access token
|
||||
// to the client if someone naievely copied the whole `config` object to here.
|
||||
|
|
|
@ -18,10 +18,12 @@ async function renderHydrogenVmRenderScriptToPageHtml(
|
|||
assert(pageOptions.title);
|
||||
assert(pageOptions.styles);
|
||||
assert(pageOptions.scripts);
|
||||
assert(pageOptions.cspNonce);
|
||||
|
||||
const hydrogenHtmlOutput = await renderHydrogenToString({
|
||||
vmRenderScriptFilePath,
|
||||
vmRenderContext,
|
||||
pageOptions,
|
||||
});
|
||||
|
||||
const serializableSpans = getSerializableSpans();
|
||||
|
@ -41,17 +43,23 @@ async function renderHydrogenVmRenderScriptToPageHtml(
|
|||
${maybeNoIndexHtml}
|
||||
${sanitizeHtml(`<title>${pageOptions.title}</title>`)}
|
||||
${pageOptions.styles
|
||||
.map((styleUrl) => `<link href="${styleUrl}" rel="stylesheet">`)
|
||||
.map(
|
||||
(styleUrl) =>
|
||||
`<link href="${styleUrl}" rel="stylesheet" nonce="${pageOptions.cspNonce}">`
|
||||
)
|
||||
.join('\n')}
|
||||
</head>
|
||||
<body>
|
||||
${hydrogenHtmlOutput}
|
||||
${pageOptions.scripts
|
||||
.map((scriptUrl) => `<script type="text/javascript" src="${scriptUrl}"></script>`)
|
||||
.map(
|
||||
(scriptUrl) =>
|
||||
`<script type="text/javascript" src="${scriptUrl}" nonce="${pageOptions.cspNonce}"></script>`
|
||||
)
|
||||
.join('\n')}
|
||||
<script type="text/javascript">window.tracingSpansForRequest = ${safeJson(
|
||||
serializedSpans
|
||||
)};</script>
|
||||
<script type="text/javascript" nonce="${pageOptions.cspNonce}">
|
||||
window.tracingSpansForRequest = ${safeJson(serializedSpans)};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const assert = require('assert');
|
||||
|
||||
const config = require('../lib/config');
|
||||
const matrixServerUrl = config.get('matrixServerUrl');
|
||||
assert(matrixServerUrl);
|
||||
|
||||
function contentSecurityPolicyMiddleware(req, res, next) {
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Based on https://web.dev/strict-csp/
|
||||
const directives = [
|
||||
// Default to fully-restrictive and only allow what's needed below
|
||||
`default-src 'none';`,
|
||||
// Only <script> and <style> tags that have the nonce provided
|
||||
//
|
||||
// To ensure compatibility with very old browser versions (4+ years), we add
|
||||
// 'unsafe-inline' as a fallback. All recent browsers will ignore 'unsafe-inline' if
|
||||
// a CSP nonce or hash is present. (via
|
||||
// https://web.dev/strict-csp/#step-4-add-fallbacks-to-support-safari-and-older-browsers)
|
||||
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline';`,
|
||||
// Hydrogen uses a bunch of inline styles and `style-src-attr` isn't well supported
|
||||
// in Firefox to allow it specifically. In the future, when it has better support we
|
||||
// should switch to a strict nonce based style directive.
|
||||
`style-src 'self' 'unsafe-inline';`,
|
||||
// We only need to load fonts from ourself
|
||||
`font-src 'self';`,
|
||||
// We only need to be able to load images/media from ourself and the homeserver media repo
|
||||
`img-src 'self' ${matrixServerUrl};`,
|
||||
`media-src 'self' ${matrixServerUrl};`,
|
||||
// Only allow the room directory search form to submit to ourself
|
||||
`form-action 'self';`,
|
||||
// We have no need ourself to embed in an iframe. And we shouldn't allow others to
|
||||
// iframe embed which can lead to clickjacking. We also have the
|
||||
// `prevent-clickjacking-middleware` to cover this.
|
||||
`frame-ancestors 'none';`,
|
||||
// Extra restriction since we have no plans to change the `<base>`
|
||||
`base-uri 'self'`,
|
||||
];
|
||||
|
||||
res.set('Content-Security-Policy', directives.join(' '));
|
||||
|
||||
// Make this available for down-stream routes to reference and use
|
||||
res.locals.cspNonce = nonce;
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = contentSecurityPolicyMiddleware;
|
|
@ -7,10 +7,12 @@ const asyncHandler = require('../lib/express-async-handler');
|
|||
const { handleTracingMiddleware } = require('../tracing/tracing-middleware');
|
||||
const getVersionTags = require('../lib/get-version-tags');
|
||||
const preventClickjackingMiddleware = require('./prevent-clickjacking-middleware');
|
||||
const contentSecurityPolicyMiddleware = require('./content-security-policy-middleware');
|
||||
|
||||
function installRoutes(app) {
|
||||
app.use(handleTracingMiddleware);
|
||||
app.use(preventClickjackingMiddleware);
|
||||
app.use(contentSecurityPolicyMiddleware);
|
||||
|
||||
let healthCheckResponse;
|
||||
app.get(
|
||||
|
|
|
@ -80,6 +80,7 @@ router.get(
|
|||
title: `Matrix Public Archive`,
|
||||
styles: [hydrogenStylesUrl, stylesUrl, roomDirectoryStylesUrl],
|
||||
scripts: [jsBundleUrl],
|
||||
cspNonce: res.locals.cspNonce,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -287,6 +287,7 @@ router.get(
|
|||
styles: [hydrogenStylesUrl, stylesUrl],
|
||||
scripts: [jsBundleUrl],
|
||||
shouldIndex,
|
||||
cspNonce: res.locals.cspNonce,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -48,6 +48,8 @@ async function timeoutMiddleware(req, res, next) {
|
|||
humanReadableSpans = [noTracingDataAvailableItem];
|
||||
}
|
||||
|
||||
const cspNonce = res.locals.cspNonce;
|
||||
|
||||
const hydrogenStylesUrl = urlJoin(basePath, '/hydrogen-styles.css');
|
||||
const stylesUrl = urlJoin(basePath, '/css/styles.css');
|
||||
|
||||
|
@ -57,8 +59,8 @@ async function timeoutMiddleware(req, res, next) {
|
|||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Server timeout - Matrix Public Archive</title>
|
||||
<link href="${hydrogenStylesUrl}" rel="stylesheet">
|
||||
<link href="${stylesUrl}" rel="stylesheet">
|
||||
<link href="${hydrogenStylesUrl}" rel="stylesheet" nonce="${cspNonce}">
|
||||
<link href="${stylesUrl}" rel="stylesheet" nonce="${cspNonce}">
|
||||
</head>
|
||||
${/* We add the .hydrogen class here just to get normal body styles */ ''}
|
||||
<body class="hydrogen">
|
||||
|
@ -75,9 +77,9 @@ async function timeoutMiddleware(req, res, next) {
|
|||
}</span></h2>`
|
||||
)}
|
||||
|
||||
<script type="text/javascript">window.tracingSpansForRequest = ${safeJson(
|
||||
serializedSpans
|
||||
)};</script>
|
||||
<script type="text/javascript" nonce="${cspNonce}">
|
||||
window.tracingSpansForRequest = ${safeJson(serializedSpans)};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
|
Loading…
Reference in New Issue