mirror of
https://github.com/penpot/penpot.git
synced 2026-09-18 18:06:14 +00:00
🐳 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, render.html is loaded by the exporter in a headless browser, and rasterizer.html is initialised by the frontend itself, so leaving any of them out would have broken those paths 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. The three external locations were also passing through the security headers of their upstreams. raw.githubusercontent.com returns its own Content-Security-Policy and both it and fonts.googleapis.com return Strict-Transport-Security. Browsers enforce the intersection of every policy they receive, so the upstream one takes precedence on those responses, and the HSTS one lands on our own host, meaning a deployment that deliberately disables HSTS would get it set anyway by a third party. Hide all three at the proxy. Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
This commit is contained in:
parent
2e94361f62
commit
dc3f0ff59d
@ -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; \
|
||||
|
||||
@ -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,15 +119,15 @@ 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: set PENPOT_CSP_POLICY to your own policy, or keep the default report-only mode." >&2
|
||||
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
|
||||
;;
|
||||
report-only)
|
||||
|
||||
@ -7,6 +7,13 @@ location ~ ^/github/penpot-files/(.+)$ {
|
||||
proxy_set_header User-Agent "curl/8.5.0";
|
||||
proxy_set_header Host "raw.githubusercontent.com";
|
||||
proxy_set_header Accept "*/*";
|
||||
# A third-party upstream must not dictate the security policy of our own
|
||||
# origin: browsers enforce the intersection of every Content-Security-Policy
|
||||
# they receive, and any Strict-Transport-Security lands on this host.
|
||||
proxy_hide_header Strict-Transport-Security;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Content-Security-Policy-Report-Only;
|
||||
|
||||
include /etc/nginx/nginx-security-headers.conf;
|
||||
add_header Access-Control-Allow-Origin $http_origin;
|
||||
proxy_buffering off;
|
||||
@ -32,6 +39,13 @@ location ~ ^/internal/gfonts/font/(?<font_file>.+) {
|
||||
|
||||
proxy_cache penpot;
|
||||
|
||||
# A third-party upstream must not dictate the security policy of our own
|
||||
# origin: browsers enforce the intersection of every Content-Security-Policy
|
||||
# they receive, and any Strict-Transport-Security lands on this host.
|
||||
proxy_hide_header Strict-Transport-Security;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Content-Security-Policy-Report-Only;
|
||||
|
||||
include /etc/nginx/nginx-security-headers.conf;
|
||||
add_header Access-Control-Allow-Origin $http_origin;
|
||||
add_header Cache-Control max-age=86400;
|
||||
@ -55,6 +69,13 @@ location ~ ^/internal/gfonts/css {
|
||||
|
||||
proxy_cache penpot;
|
||||
|
||||
# A third-party upstream must not dictate the security policy of our own
|
||||
# origin: browsers enforce the intersection of every Content-Security-Policy
|
||||
# they receive, and any Strict-Transport-Security lands on this host.
|
||||
proxy_hide_header Strict-Transport-Security;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Content-Security-Policy-Report-Only;
|
||||
|
||||
include /etc/nginx/nginx-security-headers.conf;
|
||||
add_header Access-Control-Allow-Origin $http_origin;
|
||||
add_header Cache-Control max-age=86400;
|
||||
|
||||
@ -454,24 +454,34 @@ 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. The plugin runtime initialises on every page load, whether or not a plugin is
|
||||
opened, and its sandbox needs `eval` to evaluate plugin code. Under the default policy
|
||||
those calls are blocked: the application still loads, but the plugin system is degraded,
|
||||
and opening a plugin additionally needs its remote host reachable from `connect-src` and
|
||||
`frame-src`.
|
||||
|
||||
OIDC single sign-on needs no exception: the provider is reached by navigating away from
|
||||
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:
|
||||
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> with the default policy
|
||||
suits deployments that do not use plugins. Anywhere else it needs a policy of your own.
|
||||
</p>
|
||||
|
||||
### HTTP Strict Transport Security
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user