🐳 Cover the inline scripts of the served pages with CSP hashes

The frontend build now emits the sha256 hashes of the inline scripts of every page it writes into resources/public, the image moves them out of the document root, and the entrypoint splices them into the default script-src. This removes one of the two reasons why enforcing mode was not usable.

The hashes are computed on the rendered output rather than on the mustache templates, since the digest covers the exact bytes served between the script tags. All four served pages contribute, not just index.html: challenge.html handles the redirect and render.html and rasterizer.html are loaded by the exporter, so leaving them out would have broken export under enforcing mode. The storybook previews are excluded because that container does not serve them.

A bundle predating this change yields no hashes and the policy stays as it was, so older bundles keep building.

Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
This commit is contained in:
David Barragán Merino 2026-09-01 16:32:11 +02:00
parent a8ccb5e0cd
commit a2243f0c56
4 changed files with 74 additions and 17 deletions

View File

@ -32,6 +32,15 @@ COPY ./files/nginx-mime.types /etc/nginx/mime.types
COPY ./files/nginx-external-locations.conf /etc/nginx/overrides/location.d/external-locations.conf
COPY ./files/nginx-entrypoint.sh /entrypoint.sh
# The CSP hashes of the inline scripts of index.html are emitted by the
# frontend build. Move them out of the document root: nginx must read them,
# the browser has no reason to.
RUN if [ -f /var/www/app/csp-script-hashes.txt ]; then \
mv /var/www/app/csp-script-hashes.txt /etc/nginx/csp-script-hashes.txt; \
else \
echo "WARNING: the frontend bundle does not provide csp-script-hashes.txt" >&2; \
fi
RUN chown -R 1001:0 /var/cache/nginx; \
chmod -R g+w /var/cache/nginx; \
chown -R 1001:0 /etc/nginx; \

View File

@ -95,13 +95,22 @@ envsubst "\$PENPOT_INTERNAL_RESOLVER" \
# because the Google Fonts and GitHub templates endpoints are reverse
# proxied by this very server.
#
# It ships in report-only mode: the inline <script type="module"> and
# <script type="importmap"> blocks of index.html are still reported as
# violations, and deployments with plugins enabled additionally report
# eval and remote fetch violations from the SES sandbox. Enforcing mode
# stays opt-in until both are resolved.
# The hashes of the inline scripts of index.html are emitted by the frontend
# build and moved to /etc/nginx at image build time. A bundle predating that
# change simply yields no hashes, in which case those scripts would be
# reported (or blocked under enforce) as before.
#
# It ships in report-only mode because deployments with plugins enabled still
# report eval and remote fetch violations from the SES sandbox. Enforcing mode
# stays opt-in until that is resolved.
export PENPOT_CSP_MODE=${PENPOT_CSP_MODE:-report-only}
if [ -r /etc/nginx/csp-script-hashes.txt ]; then
PENPOT_CSP_SCRIPT_HASHES=" $(tr -d '\n' < /etc/nginx/csp-script-hashes.txt)"
else
PENPOT_CSP_SCRIPT_HASHES=""
fi
# Remember whether the policy comes from the deployment before the default
# is applied, so the warning below only fires for the default one.
if [ -n "${PENPOT_CSP_POLICY:-}" ]; then
@ -110,14 +119,14 @@ else
PENPOT_CSP_POLICY_IS_CUSTOM="false"
fi
export PENPOT_CSP_POLICY=${PENPOT_CSP_POLICY:-"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' blob: data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src 'self'; manifest-src 'self'"}
export PENPOT_CSP_POLICY=${PENPOT_CSP_POLICY:-"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'${PENPOT_CSP_SCRIPT_HASHES}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' blob: data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src 'self'; manifest-src 'self'"}
case "${PENPOT_CSP_MODE}" in
enforce)
export PENPOT_CSP_DIRECTIVE="add_header Content-Security-Policy \"${PENPOT_CSP_POLICY}\" always;"
if [ "${PENPOT_CSP_POLICY_IS_CUSTOM}" = "false" ]; then
echo "penpot: WARNING: PENPOT_CSP_MODE=enforce is not supported with the default policy yet." >&2
echo "penpot: the inline scripts of index.html are not covered by it, so the application will fail to load." >&2
echo "penpot: deployments using plugins will break, because the plugin sandbox requires 'unsafe-eval'." >&2
echo "penpot: set PENPOT_CSP_POLICY to your own policy, or keep the default report-only mode." >&2
fi
;;

View File

@ -454,24 +454,28 @@ attributes emitted by the UI, and `blob:`/`data:` for thumbnails, exports and fo
external Google Fonts and GitHub templates endpoints do not need entries of their own
because they are reverse proxied by the frontend container.
Two known sources of violations remain, and both are the reason `enforce` is not yet the
default:
The inline scripts of the pages served by the container are covered by sha256 hashes
generated during the frontend build, so they need no exception of their own.
- The `index.html` inline `<script type="module">` and `<script type="importmap">` blocks
are not covered by the policy yet.
- Deployments with plugins enabled report `eval` and remote fetch violations, because the
plugin sandbox evaluates third-party code and loads it from arbitrary hosts.
One known source of violations remains, and it is the reason `enforce` is not yet the
default: deployments with plugins enabled report `eval` and remote fetch violations,
because the plugin sandbox evaluates third-party code and loads it from arbitrary hosts.
Set your own policy with `PENPOT_CSP_POLICY` if you need to relax or tighten it, for
example to allow plugins:
example to allow plugins. Note that a custom policy replaces the default one entirely,
including the generated hashes, so take them from
`Content-Security-Policy-Report-Only` on a running container and paste them in place of
`<hashes>`. Note as well that `base-uri`, `form-action` and `frame-ancestors` have no
fallback to `default-src`, so a shorter policy silently loses them:
```bash
PENPOT_CSP_POLICY: "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https: blob: data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src 'self' https:; manifest-src 'self'"
PENPOT_CSP_POLICY: "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' <hashes>; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https: blob: data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src 'self' https:; manifest-src 'self'"
```
<p class="advice">
Because of the above, <code class="language-bash">enforce</code> requires a policy of your
own. Enforcing the default policy will prevent the application from loading.
Because of the above, <code class="language-bash">enforce</code> is only usable on
deployments that do not use plugins. Enforcing the default policy anywhere else will
break them.
</p>
### HTTP Strict Transport Security

View File

@ -1,4 +1,5 @@
import proc from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import ph from "node:path";
import os from "node:os";
@ -412,12 +413,37 @@ async function generateSvgSprites() {
);
}
// Collect the CSP hashes of the inline scripts of a rendered template into
// the given set. The hash covers the exact bytes between the script tags, so
// it has to be computed on the rendered output and never on the mustache
// source. Scripts carrying a src attribute are external and are covered by
// 'self' instead.
function collectCspHashes(html, hashes) {
const pattern = /<script\b(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
for (const match of html.matchAll(pattern)) {
const digest = crypto
.createHash("sha256")
.update(match[1], "utf8")
.digest("base64");
hashes.add(`'sha256-${digest}'`);
}
return hashes;
}
async function generateTemplates() {
await fs.mkdir("./resources/public/", { recursive: true });
const manifest = await generateManifest();
let content;
// Every template written into resources/public/ is served by the frontend
// container under the same Content Security Policy, so all of them have to
// contribute their hashes. The storybook previews are excluded because they
// are not served by that container.
const cspHashes = new Set();
const iconsSprite = await fs.readFile(
"resources/public/images/sprites/symbol/icons.svg",
"utf8",
@ -447,6 +473,7 @@ async function generateTemplates() {
);
await fs.writeFile("./resources/public/index.html", content);
collectCspHashes(content, cspHashes);
content = await renderTemplate(
"resources/templates/challenge.mustache",
@ -454,6 +481,7 @@ async function generateTemplates() {
partials,
);
await fs.writeFile("./resources/public/challenge.html", content);
collectCspHashes(content, cspHashes);
content = await renderTemplate(
"resources/templates/preview-body.mustache",
@ -475,6 +503,7 @@ async function generateTemplates() {
);
await fs.writeFile("./resources/public/render.html", content);
collectCspHashes(content, cspHashes);
content = await renderTemplate(
"resources/templates/rasterizer.mustache",
@ -482,6 +511,12 @@ async function generateTemplates() {
);
await fs.writeFile("./resources/public/rasterizer.html", content);
collectCspHashes(content, cspHashes);
await fs.writeFile(
"./resources/public/csp-script-hashes.txt",
[...cspHashes].join(" ") + "\n",
);
}
export async function compileStorybookStyles() {