Files
pl_mail/compose.override.yaml.dist
T
Paul LütznerandClaude Opus 5 8c3080d00a fix(security): an SVG attachment is not an image, and a shipped install gets headers
CODE-REVIEW S-02 (high), S-04, S-08, S-05, S-06.

S-02 — three controllers decided what may render inline with
`str_starts_with($contentType, 'image/')`, and `image/svg+xml` satisfies it
while executing script as a top-level document. nosniff does not help: nothing
is mislabelled, the type genuinely is what it says. The type comes from the MIME
headers of an incoming mail.

Reachable from the mail itself. MailBodySanitizer::resolveCids() rewrites EVERY
`cid:` in the document rather than only `img src`, and the sanitiser allows
relative links — so `<a href="cid:logo">Rechnung ansehen</a>` has the part id
filled in by the application, and the click opens the attachment route without
the `?download=1` that every template adds.

Now an allow-list (InlineDisposition), which is the difference between
"everything not yet known to be dangerous" and "the formats actually wanted",
plus `default-src 'none'; sandbox` on the response — so a later widening of the
list is a bug rather than a vulnerability. Parameters are stripped before
comparing, because `image/svg+xml; charset=utf-8` would otherwise walk past the
one entry meant to stop it.

S-08 pulled along: the JMAP download had the same prefix test. Guarded today
only by the jmap firewall being stateless and wanting a header a browser
navigation does not send, which is a thin thing to depend on.

S-04 pulled along: the file-picker preview carried no Content-Disposition at
all — implicitly inline, in our origin, with a type chosen by Nextcloud, Immich,
Dropbox, OneDrive or Drive. A file an attacker drops in a shared folder is a
realistic shape for the setups these integrations exist for. A non-image now
falls back to the placeholder rather than to a download: a download prompt fired
by a thumbnail is worse than a grey box.

S-05 — the Caddyfile now sends HSTS, X-Frame-Options, nosniff, Referrer-Policy
and Permissions-Policy, and drops the Server banner. None of these existed
anywhere in the repository, so the ones a live instance showed were its
operator's proxy — while the README's own install instructions are `docker
compose up -d` and "open https://localhost". `defer`, so the per-response
policies on the attachment and image-proxy routes still win. Referrer-Policy is
the one that matters most for a mail client: it sends its users to strangers'
links all day.

S-06 — mercure subscribe defaults to [] rather than '*'. Never reached today,
since both cookie-minting paths pass explicit topics, but a future
createCookie($request) without a topic argument would have issued a subscription
to every user's mail events and looked entirely ordinary in review.

Also: mailpit removed from the dev override. MailerInterface is injected in no
service in src/ — plMail's own outgoing mail leaves through the user's account
via MailSenderRegistry — and the override never set MAILER_DSN either, so the
container has been catching nothing. Both configuration pages said the dev stack
pointed at it; they now say what is actually true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:16:02 +02:00

152 lines
5.0 KiB
Plaintext

# Every app service builds the same dev stage, so they share one image — one
# build instead of six.
#
# The `image` line is the load-bearing part. compose.yaml names these services
# `ghcr.io/karatektus/pl_mail:latest`, and compose tags what it builds with the
# service's image name — so without this, every `docker compose up --build`
# overwrites the published tag locally with a dev image. That image carries no
# application code (the source arrives via the bind mount below), which is
# correct for dev and fatal anywhere else: if this override ever goes missing,
# compose finds that local tag, does not pull, and boots an app-less container
# that falls into the entrypoint's symfony/skeleton branch and crash-loops.
# Building under our own name leaves the published tag alone.
x-dev-build: &dev-build
image: pl_mail-dev:latest
build:
context: .
target: frankenphp_dev
services:
# Builds locally like every other app service. Without this it is the one
# service still pulling ghcr.io/karatektus/pl_mail, which would run emulated
# on an arm64 host — and since everything else waits on it completing
# successfully, that one container gates the whole stack.
secrets-init:
<<: *dev-build
php:
<<: *dev-build
volumes:
- ./:/app
- app_var:/app/var
# Durable mail data must be visible to BOTH the web container (which
# serves downloads) and the workers (which write during sync). php
# overlays /app/var with the private app_var volume to keep cache/logs
# off the bind mount, which otherwise hides these two directories from
# everything else.
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
- ./frankenphp/Caddyfile:/etc/frankenphp/Caddyfile:ro
- ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/conf.d/20-app.dev.ini:ro
environment:
APP_ENV: ${APP_ENV:-dev}
XDEBUG_MODE: "${XDEBUG_MODE:-off}"
extra_hosts:
- host.docker.internal:host-gateway
tty: true
imap-supervisor:
<<: *dev-build
volumes:
- ./:/app
# Durable mail data must be visible to BOTH the web container (which
# serves downloads) and the workers (which write during sync). php
# overlays /app/var with the private app_var volume to keep cache/logs
# off the bind mount, which otherwise hides these two directories from
# everything else.
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
environment:
APP_ENV: ${APP_ENV:-dev}
# One block per worker in compose.yaml. A service the base file defines but
# this one does not still starts — from the published image, without the
# source bind-mounted — so a missing block here is not a missing container
# but a container running yesterday's code.
worker-export:
<<: *dev-build
volumes:
- ./:/app
# Durable mail data must be visible to BOTH the web container (which
# serves downloads) and the workers (which write during sync). php
# overlays /app/var with the private app_var volume to keep cache/logs
# off the bind mount, which otherwise hides these two directories from
# everything else.
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
environment:
APP_ENV: ${APP_ENV:-dev}
worker-ingest:
<<: *dev-build
volumes:
- ./:/app
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
environment:
APP_ENV: ${APP_ENV:-dev}
worker-maintenance:
<<: *dev-build
volumes:
- ./:/app
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
environment:
APP_ENV: ${APP_ENV:-dev}
scheduler:
<<: *dev-build
volumes:
- ./:/app
environment:
APP_ENV: ${APP_ENV:-dev}
database:
ports:
- "5432:5432"
adminer:
image: adminer
restart: unless-stopped
ports:
- "8080:8080"
environment:
ADMINER_DEFAULT_SERVER: database
tailwind:
<<: *dev-build
volumes:
- .:/app
# Runs bin/console, so it goes through the app entrypoint and needs the
# same secrets every other service uses. Without this it would generate
# its own and refuse to start.
- app_secrets:/app/var/secrets
- app_var:/app/var
command: php bin/console tailwind:build --watch
# The image healthcheck probes the FrankenPHP admin endpoint on :2019, but
# this service replaces the server with the Tailwind watcher, so nothing
# ever listens there and the container is reported unhealthy while working
# fine. Same reason imap-supervisor, messenger-worker and scheduler disable
# it in compose.yaml.
healthcheck:
disable: true
depends_on:
- php
###> symfony/mercure-bundle ###
mercure:
ports:
- "80"
###< symfony/mercure-bundle ###
volumes:
app_var:
app_attachments:
app_raw:
app_uploads: