🐳 Allow extending the CSP directives without replacing the policy

Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
This commit is contained in:
David Barragán Merino 2026-09-17 17:27:10 +02:00 committed by David Barragán Merino
parent 1e8acbb2db
commit b402637fe4
2 changed files with 118 additions and 13 deletions

View File

@ -119,16 +119,58 @@ 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'"}
# Directives a deployment may extend. The default policy carries hashes that
# change on every build, so a deployment that needs an extra origin cannot
# hardcode the whole policy without recomputing them at each release. These
# variables let it declare only what it adds.
#
# base-uri, form-action, object-src and frame-ancestors are deliberately not
# extensible: there is no legitimate reason to relax them, and doing so
# silently removes the protection they provide. A deployment that really
# needs it can still set PENPOT_CSP_POLICY and own the whole policy.
if [ "${PENPOT_CSP_POLICY_IS_CUSTOM}" = "false" ]; then
export PENPOT_CSP_POLICY="default-src 'self'\
; base-uri 'self'\
; object-src 'none'\
; frame-ancestors 'self'\
; form-action 'self'\
; manifest-src 'self'\
; script-src 'self' 'wasm-unsafe-eval'${PENPOT_CSP_SCRIPT_HASHES}${PENPOT_CSP_SCRIPT_SRC_EXTRA:+ ${PENPOT_CSP_SCRIPT_SRC_EXTRA}}\
; style-src 'self' 'unsafe-inline'${PENPOT_CSP_STYLE_SRC_EXTRA:+ ${PENPOT_CSP_STYLE_SRC_EXTRA}}\
; img-src 'self' data: blob:${PENPOT_CSP_IMG_SRC_EXTRA:+ ${PENPOT_CSP_IMG_SRC_EXTRA}}\
; font-src 'self'${PENPOT_CSP_FONT_SRC_EXTRA:+ ${PENPOT_CSP_FONT_SRC_EXTRA}}\
; connect-src 'self' blob: data:${PENPOT_CSP_CONNECT_SRC_EXTRA:+ ${PENPOT_CSP_CONNECT_SRC_EXTRA}}\
; frame-src 'self'${PENPOT_CSP_FRAME_SRC_EXTRA:+ ${PENPOT_CSP_FRAME_SRC_EXTRA}}\
; worker-src 'self' blob:\
; media-src 'self' blob:${PENPOT_CSP_REPORT_URI:+; report-uri ${PENPOT_CSP_REPORT_URI}}"
else
for _var in SCRIPT_SRC_EXTRA STYLE_SRC_EXTRA IMG_SRC_EXTRA FONT_SRC_EXTRA \
CONNECT_SRC_EXTRA FRAME_SRC_EXTRA REPORT_URI; do
eval "_value=\${PENPOT_CSP_${_var}:-}"
if [ -n "${_value}" ]; then
echo "penpot: WARNING: PENPOT_CSP_${_var} is ignored because PENPOT_CSP_POLICY defines the whole policy." >&2
fi
done
unset _var _value
export PENPOT_CSP_POLICY
fi
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 degrades the plugin runtime." >&2
echo "penpot: it initialises on every page load and needs 'unsafe-eval', which the default policy does not grant." >&2
echo "penpot: set PENPOT_CSP_POLICY to your own policy if you need plugins, or keep report-only." >&2
fi
# The plugin runtime initialises on every page load, whether or not a
# plugin is opened, and its sandbox needs 'unsafe-eval'. Look at the
# policy that will actually be served rather than at where it came
# from, since it can be granted through the default policy, through
# PENPOT_CSP_SCRIPT_SRC_EXTRA or through a policy of your own.
case "${PENPOT_CSP_POLICY}" in
*"'unsafe-eval'"*) ;;
*)
echo "penpot: WARNING: PENPOT_CSP_MODE=enforce degrades the plugin runtime." >&2
echo "penpot: it initialises on every page load and needs 'unsafe-eval', which this policy does not grant." >&2
echo "penpot: add it through PENPOT_CSP_SCRIPT_SRC_EXTRA if you need plugins, or keep report-only." >&2
;;
esac
;;
report-only)
export PENPOT_CSP_DIRECTIVE="add_header Content-Security-Policy-Report-Only \"${PENPOT_CSP_POLICY}\" always;"

View File

@ -468,17 +468,80 @@ OIDC single sign-on needs no exception: the provider is reached by navigating aw
Penpot, which no directive of this policy governs, the response returns as a redirect, and
both discovery and the token exchange happen on the backend rather than in the browser.
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:
#### Extending the policy
Most deployments need to add an origin rather than rewrite the policy: a plugin host, an
analytics endpoint, a corporate font server. Declare only the addition and the rest of the
default policy, hashes included, stays in place:
```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'"
PENPOT_CSP_CONNECT_SRC_EXTRA: "https://analytics.example.com"
```
The extensible directives are `script-src`, `style-src`, `img-src`, `font-src`,
`connect-src` and `frame-src`. `base-uri`, `form-action`, `object-src` and
`frame-ancestors` are not extensible, since relaxing them removes the protection they
provide and no ordinary deployment needs to.
`PENPOT_CSP_REPORT_URI` adds a `report-uri` directive, which is how a deployment collects
violations from real traffic while the policy is still in report-only mode.
#### Running plugins under an enforcing policy
Plugins need four directives, and under enforcing mode a missing one fails quietly rather
than reporting an error. The symptoms are worth knowing: the sandbox refuses to start
without `script-src`, installing a plugin fails with a network error without
`connect-src`, its icon does not appear without `img-src`, and its interface stays blank
without `frame-src`.
```bash
PENPOT_CSP_SCRIPT_SRC_EXTRA: "'unsafe-eval'"
PENPOT_CSP_CONNECT_SRC_EXTRA: "https://plugins.example.com"
PENPOT_CSP_IMG_SRC_EXTRA: "https://plugins.example.com"
PENPOT_CSP_FRAME_SRC_EXTRA: "https://plugins.example.com"
```
`'unsafe-eval'` is required because the plugin sandbox evaluates plugin code, and it
applies to the whole application rather than to plugins alone. Note also that the plugin
runtime initialises on every page load whether or not a plugin is opened, so without it
the sandbox reports violations even on a deployment where nobody uses plugins.
Listing the origins explicitly restricts which plugins can run, which the browser then
enforces. A deployment that cannot know in advance where its users install plugins from
needs the permissive form instead:
```bash
PENPOT_CSP_SCRIPT_SRC_EXTRA: "'unsafe-eval'"
PENPOT_CSP_CONNECT_SRC_EXTRA: "https:"
PENPOT_CSP_IMG_SRC_EXTRA: "https:"
PENPOT_CSP_FRAME_SRC_EXTRA: "https:"
```
#### Replacing the policy
`PENPOT_CSP_POLICY` defines the whole policy and takes precedence, in which case the
variables above are ignored and a warning is logged at startup.
Be aware that this also replaces the generated hashes, which change on every build. A
deployment that pins the whole policy has to recompute them at each release or the
application stops loading, so prefer the extension variables unless you really need to
remove a directive or add one the variables above do not cover.
`upgrade-insecure-requests` is an example of the latter. To add it, read the policy the
container is currently serving and use it as the starting point:
```bash
curl -sI https://penpot.example.com/ | grep -i content-security-policy
```
Then set the whole thing, with the hashes taken from that output:
```bash
PENPOT_CSP_POLICY: "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; manifest-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-...' 'sha256-...'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' blob: data:; frame-src 'self'; worker-src 'self' blob:; media-src 'self' blob:; upgrade-insecure-requests"
```
Remember to repeat that step on every upgrade, since the hashes will have changed.
<p class="advice">
Because of the above, <code class="language-bash">enforce</code> with the default policy
suits deployments that do not use plugins. Anywhere else it needs a policy of your own.