diff --git a/library/lib/SafeScript.js b/library/lib/SafeScript.js
new file mode 100644
index 0000000..92ca18c
--- /dev/null
+++ b/library/lib/SafeScript.js
@@ -0,0 +1,17 @@
+const crypto = require('crypto')
+const React = require('react')
+
+module.exports = { SafeScript, recordScriptHashes }
+
+function SafeScript({ dangerouslySetInnerHTML }) {
+ if (!global.scriptHashes) throw new Error('No script hashes present')
+ global.scriptHashes.add(crypto.createHash('sha256').update(dangerouslySetInnerHTML.__html).digest('base64'))
+ return React.createElement('script', { dangerouslySetInnerHTML })
+}
+
+function recordScriptHashes(newScriptHashses, callback) {
+ global.scriptHashes = newScriptHashses
+ const result = callback()
+ global.scriptHashes = null
+ return result
+}
diff --git a/library/lib/eval-in-fork.js b/library/lib/eval-in-fork.js
index d5e3218..4bf8858 100644
--- a/library/lib/eval-in-fork.js
+++ b/library/lib/eval-in-fork.js
@@ -8,8 +8,14 @@ attempt(() => {
function handleMessage(source) {
attempt(() => {
process.off('message', handleMessage)
- const { template, renderer } = evalSource(source)
- const result = renderer(template)
+
+ const { template, renderer, recordScriptHashes } = evalSource(source)
+ const scriptHashes = new Set()
+ const result = recordScriptHashes(scriptHashes, () =>
+ renderer(template)
+ )
+ if (scriptHashes.size)
+ console.log('[Warning]: generating a static template with inline scripts, if you want to use a CSP header, make it a dynamic template by exporting a function')
process.send(result, e =>
attempt(() => {
diff --git a/library/lib/rollbar.js b/library/lib/rollbar.js
index 63d1ef5..ba8501d 100644
--- a/library/lib/rollbar.js
+++ b/library/lib/rollbar.js
@@ -1,5 +1,6 @@
import fs from 'fs'
import merge from 'rollbar/src/merge'
+import { SafeScript } from './SafeScript'
const snippet = fs.readFileSync(__non_webpack_require__.resolve('rollbar/dist/rollbar.snippet.js'), 'utf8')
@@ -15,5 +16,5 @@ const defaultOptions = {
export default function rollbar(options, nonSerializableRollbarConfig = '/* no non-serializable config */') {
const config = JSON.stringify(merge(defaultOptions, options))
const __html = `var _rollbarConfig = ${config};${nonSerializableRollbarConfig};${snippet}`
- return
+ return
}
diff --git a/library/lib/serve.js b/library/lib/serve.js
index 1115a71..28dd00a 100644
--- a/library/lib/serve.js
+++ b/library/lib/serve.js
@@ -33,6 +33,12 @@ const envRequire = isProduction ? require : require('import-fresh')
const notCached = ['html', 'txt', 'json', 'xml']
+const { contentSecurityPolicy, ...helmetOptionsToUse } = helmetOptions || {}
+const cspMiddleware = contentSecurityPolicy && createCspMiddleware(contentSecurityPolicy)
+const [sendHtmlFunction, sendHtmlFunctionIsAsync] = cspMiddleware
+ ? [sendHtmlWithCspHeaders, true]
+ : [sendHtml, false]
+
if (isProduction) app.use(morgan('combined'))
app.use(helmet(Object.assign(
{
@@ -40,7 +46,7 @@ app.use(helmet(Object.assign(
contentSecurityPolicy: false,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
},
- helmetOptions
+ helmetOptionsToUse
)))
app.use(compression())
app.set('trust proxy', true)
@@ -138,28 +144,27 @@ function serveIndexWithRouting(file, req, res, next) {
const location = parsePath(req.url)
- const [dataOrPromise, template] = getDataAndRouteTemplate(routeTemplate, location, req)
+ const [dataOrPromiseFromTemplate, template] = getDataAndRouteTemplate(routeTemplate, location, req)
+
+ const dataOrPromise =
+ dataOrPromiseFromTemplate.then ? dataOrPromiseFromTemplate :
+ sendHtmlFunctionIsAsync ? Promise.resolve(dataOrPromiseFromTemplate) :
+ dataOrPromiseFromTemplate
if (dataOrPromise.then)
dataOrPromise
- .then(({ status, headers, data }) => {
- const html = template({ location, data })
- res.status(status).set(headers).send(html)
- })
+ .then(data => sendHtmlFunction(req, res, template, location, data))
.catch(error => {
reportServerError(error, req)
serveInternalServerError(error, req, res, next)
})
- else {
+ else
try {
- const { data, status, headers } = dataOrPromise
- const html = template({ location, data })
- res.status(status).set(headers).send(html)
+ sendHtmlFunction(req, res, template, location, dataOrPromise)
} catch (error) {
reportServerError(error, req)
serveInternalServerError(error, req, res, next)
}
- }
}
function getDataAndRouteTemplate(routeTemplate, location, req) {
@@ -191,3 +196,45 @@ function reportServerError(error, req) {
console.error(error)
if (reportError) reportError(error, req)
}
+
+
+async function sendHtml(req, res, template, location, { status, headers, data }) {
+ const scriptHashes = new Set()
+ const html = template({ location, data }, scriptHashes)
+ res.status(status).set(headers).send(html)
+}
+
+async function sendHtmlWithCspHeaders(req, res, template, location, { status, headers, data }) {
+ const scriptHashes = new Set()
+ const html = template({ location, data }, scriptHashes)
+
+ // make script hashes available for CSP middleware
+ res.locals.scriptHashes = Array.from(scriptHashes).map(hash => `'sha256-${hash}'`)
+ await addCspHeaders(req, res)
+
+ res.status(status).set(headers).send(html)
+}
+
+function createCspMiddleware(contentSecurityPolicy) {
+ const contentSecurityPolicyWithHashes = {
+ ...contentSecurityPolicy,
+ directives: {
+ ...contentSecurityPolicy.directives,
+ 'script-src-elem': [
+ ...contentSecurityPolicy.directives['script-src-elem'],
+ (req, res) => res.locals.scriptHashes.join(' '),
+ ]
+ }
+ }
+ return helmet.contentSecurityPolicy(contentSecurityPolicyWithHashes)
+}
+
+function addCspHeaders(req, res) {
+ return new Promise((resolve, reject) => {
+ cspMiddleware(req, res, (e) => {
+ if (e) reject(e)
+ else resolve(undefined)
+ })
+ })
+}
+
diff --git a/library/lib/stylesheet.js b/library/lib/stylesheet.js
index cf41961..b433cdd 100644
--- a/library/lib/stylesheet.js
+++ b/library/lib/stylesheet.js
@@ -5,6 +5,7 @@
*/
import hotCssReplacementClient from './hot-css-replacement-client?transpiled-javascript-string'
+import { SafeScript } from './SafeScript'
const isWatch = process.env.WATCH
@@ -18,10 +19,10 @@ export default __webpack_css_chunk_hashes__
function createHotReloadClient() {
const [ port, cssHashes, chunkName, publicPath ] = [ __webpack_websocket_port__, __webpack_css_chunk_hashes__, __webpack_chunkname__, __webpack_public_path__ ]
return (
-
)
diff --git a/library/lib/universalComponents.js b/library/lib/universalComponents.js
index a04e295..764040c 100644
--- a/library/lib/universalComponents.js
+++ b/library/lib/universalComponents.js
@@ -1,5 +1,6 @@
import { hydrateRoot, createRoot } from 'react-dom/client'
import { safeJsonStringify } from '@kaliber/safe-json-stringify'
+import { SafeScript } from './SafeScript'
const containerMarker = 'data-kaliber-component-container'
const E = /** @type {any} */ ('kaliber-component-container')
@@ -19,7 +20,7 @@ export function ComponentServerWrapper({ componentName, props, renderedComponent
{/* Use render blocking script to set a container marker and remove the custom components.
This ensures the page is never rendered with the intermediate structure */}
-
+
>
)
}
diff --git a/library/webpack-loaders/template-loader.js b/library/webpack-loaders/template-loader.js
index f418c39..bf99983 100644
--- a/library/webpack-loaders/template-loader.js
+++ b/library/webpack-loaders/template-loader.js
@@ -15,5 +15,6 @@ TemplateLoader.pitch = function TemplateLoaderPitch(remainingRequest, precedingR
// This should tell us what we need to use: https://webpack.js.org/configuration/module/#rule-enforce
return `|export { default as template } from '-!${precedingRequest}!${remainingRequest}?template-source'
|export { default as renderer } from '${rendererPath}'
+ |export { recordScriptHashes } from '@kaliber/build/lib/SafeScript'
|`.split(/^[ \t]*\|/m).join('')
}
diff --git a/library/webpack-plugins/template-plugin.js b/library/webpack-plugins/template-plugin.js
index ae87192..737da02 100644
--- a/library/webpack-plugins/template-plugin.js
+++ b/library/webpack-plugins/template-plugin.js
@@ -177,14 +177,16 @@ async function createStaticTemplate(name, source, map) {
function createDynamicTemplate(name, ext) {
return new RawSource(
`|const envRequire = process.env.NODE_ENV === 'production' ? require : require('import-fresh')
- |const { template, renderer } = envRequire('./${name}${ext}')
+ |const { template, renderer, recordScriptHashes } = envRequire('./${name}${ext}')
|
|Object.assign(render, template)
|
|module.exports = render
|
- |function render(props) {
- | return renderer(template(props))
+ |function render(props, scriptHashes) {
+ | return recordScriptHashes(scriptHashes, () =>
+ | renderer(template(props))
+ | )
|}
|`.replace(/^[ \t]*\|/gm, '')
)