mirror of
https://github.com/penpot/penpot.git
synced 2026-09-11 14:39:35 +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 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:
parent
a8ccb5e0cd
commit
a2243f0c56
@ -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,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
|
||||
;;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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