Compare commits

...
Author SHA1 Message Date
David Barragán Merino a2243f0c56 🐳 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>
2026-09-02 09:48:32 +02:00
David Barragán Merino a8ccb5e0cd 🐳 Add configurable CSP and HSTS headers to the frontend image
Ship both headers from the image so that every deployment starts from a sensible default instead of each installation deriving its own policy. Report-only mode never blocks a request, so this changes no behaviour for existing deployments, and HSTS stays absent unless PENPOT_PUBLIC_URI declares an https scheme.

The policy can be narrow because the frontend already reverse proxies its own external dependencies, so 'self' covers them. What it must permit beyond that comes from the code: 'wasm-unsafe-eval' for the render engine, 'unsafe-inline' styles for the inline style attributes of the UI, and blob:/data: for thumbnails, exports and fonts.

Closes #11374
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
AI-assisted-by: Claude
2026-09-01 16:13:04 +02:00
5 changed files with 183 additions and 1 deletions

No files matched your search

+10 -1
View File
@@ -27,11 +27,20 @@ COPY ./files/nginx.conf.template /tmp/nginx.conf.template
COPY ./files/nginx-resolvers.conf.template /tmp/resolvers.conf.template
COPY ./files/nginx-admin-console-locations.conf.template /tmp/nginx-admin-console-locations.conf.template
COPY ./files/nginx-mcp-locations.conf.template /tmp/nginx-mcp-locations.conf.template
COPY ./files/nginx-security-headers.conf /etc/nginx/nginx-security-headers.conf
COPY ./files/nginx-security-headers.conf.template /tmp/nginx-security-headers.conf.template
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; \
+78
View File
@@ -84,4 +84,82 @@ export PENPOT_INTERNAL_RESOLVER=${PENPOT_INTERNAL_RESOLVER:-$PENPOT_DEFAULT_INTE
envsubst "\$PENPOT_INTERNAL_RESOLVER" \
< /tmp/resolvers.conf.template > /etc/nginx/overrides/http.d/resolvers.conf
#########################################
## Security Headers Config
#########################################
# The default policy describes what a stock Penpot deployment actually
# needs: 'wasm-unsafe-eval' for the render engine, 'unsafe-inline' styles
# for the inline style attributes emitted by the UI, and blob:/data: for
# thumbnails, exports and font handling. Everything else is same-origin,
# because the Google Fonts and GitHub templates endpoints are reverse
# proxied by this very server.
#
# 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
PENPOT_CSP_POLICY_IS_CUSTOM="true"
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'${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: 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
;;
report-only)
export PENPOT_CSP_DIRECTIVE="add_header Content-Security-Policy-Report-Only \"${PENPOT_CSP_POLICY}\" always;"
;;
disabled)
export PENPOT_CSP_DIRECTIVE=""
;;
*)
echo "penpot: invalid PENPOT_CSP_MODE '${PENPOT_CSP_MODE}'; expected one of: enforce, report-only, disabled" >&2
exit 1
;;
esac
# HSTS is only meaningful when the deployment is served over HTTPS, so it
# defaults to enabled when PENPOT_PUBLIC_URI declares an https scheme and
# to disabled otherwise. Set PENPOT_HSTS_VALUE explicitly to override it,
# for example to add includeSubDomains or preload, or to an empty value
# to disable it on an https deployment.
if [[ "${PENPOT_PUBLIC_URI:-}" == https://* ]]; then
export PENPOT_HSTS_VALUE=${PENPOT_HSTS_VALUE-"max-age=31536000"}
else
export PENPOT_HSTS_VALUE=${PENPOT_HSTS_VALUE-""}
fi
if [ -n "${PENPOT_HSTS_VALUE}" ]; then
export PENPOT_HSTS_DIRECTIVE="add_header Strict-Transport-Security \"${PENPOT_HSTS_VALUE}\" always;"
else
export PENPOT_HSTS_DIRECTIVE=""
fi
envsubst "\$PENPOT_CSP_DIRECTIVE,\$PENPOT_HSTS_DIRECTIVE" \
< /tmp/nginx-security-headers.conf.template > /etc/nginx/nginx-security-headers.conf
exec "$@";
@@ -2,3 +2,5 @@ add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header X-Frame-Options SAMEORIGIN always;
${PENPOT_CSP_DIRECTIVE}
${PENPOT_HSTS_DIRECTIVE}
+58
View File
@@ -433,6 +433,64 @@ PENPOT_FLAGS: [...] enable-air-gapped-conf
When Penpot starts, it will leave out the Nginx configuration related to external requests. This means that,
with this flag enabled, the Penpot configuration will disable as well the libraries and templates dashboard and the use of Google fonts.
## Security headers
The frontend container always emits `X-Content-Type-Options`, `Referrer-Policy`,
`Permissions-Policy` and `X-Frame-Options`. Two additional headers are configurable.
### Content Security Policy
Penpot ships a Content Security Policy in **report-only** mode by default. In this mode
browsers report violations to the developer console but do not block anything, which makes
it safe to enable everywhere while the policy is being tuned.
```bash
PENPOT_CSP_MODE: report-only # report-only (default) | enforce | disabled
```
The default policy is same-origin except for what the application genuinely requires:
`'wasm-unsafe-eval'` for the render engine, `'unsafe-inline'` styles for the inline style
attributes emitted by the UI, and `blob:`/`data:` for thumbnails, exports and fonts. The
external Google Fonts and GitHub templates endpoints do not need entries of their own
because they are reverse proxied by the frontend container.
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.
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. 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' <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> is only usable on
deployments that do not use plugins. Enforcing the default policy anywhere else will
break them.
</p>
### HTTP Strict Transport Security
HSTS is enabled automatically when `PENPOT_PUBLIC_URI` uses the `https` scheme, and
disabled otherwise. Override the header value directly to customise it, or set it to an
empty value to disable it:
```bash
PENPOT_HSTS_VALUE: "max-age=63072000; includeSubDomains; preload"
```
Note that `includeSubDomains` and `preload` affect every host under your domain and are
hard to roll back, so they are not enabled by default.
## High availability
The mechanisms for installing Penpot in HA depend largely on how each infrastructure is managed.
+35
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() {