🐳 Add configurable CSP and HSTS headers to the frontend image

Closes #11374
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
This commit is contained in:
David Barragán Merino 2026-08-26 20:36:35 +02:00
parent 980ccf15fa
commit e219f32a52
4 changed files with 107 additions and 1 deletions

View File

@ -27,7 +27,7 @@ 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

View File

@ -84,4 +84,59 @@ 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.
#
# 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.
export PENPOT_CSP_MODE=${PENPOT_CSP_MODE:-report-only}
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'"}
case "${PENPOT_CSP_MODE}" in
enforce)
export PENPOT_CSP_DIRECTIVE="add_header Content-Security-Policy \"${PENPOT_CSP_POLICY}\" always;"
;;
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 "$@";

View File

@ -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}

View File

@ -433,6 +433,55 @@ 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.
Two known sources of violations remain, and both are the reason `enforce` is not yet the
default:
- 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.
Set your own policy with `PENPOT_CSP_POLICY` if you need to relax or tighten it, for
example to allow plugins:
```bash
PENPOT_CSP_POLICY: "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; connect-src 'self' https: blob: data:; frame-src 'self' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; worker-src 'self' blob:"
```
### 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.