mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 19:59:54 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2243f0c56 | ||
|
|
a8ccb5e0cd |
No files matched your search
@@ -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; \
|
||||
|
||||
@@ -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
@@ -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}
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user