Security baseline
Purpose. One place that says what each layer does about the browser-facing and supply-chain threats. A later change is checked against this page, and a reviewer can see what was chosen and what was not. It was written by the security pass; a layer not listed here has no stated defence.
Sessions and cross-site requests
Section titled “Sessions and cross-site requests”- Requests carry a Bearer access token held in memory, never in
localStorage. The only cookie is the refresh token:HttpOnly,SameSite=Strict, host-only, scoped to/api/v1/auth. The access token names its session (sid), and a request whose session has ended is refused, however recently the token was issued. - The routes that read or set that cookie (
login,handoff,refresh,logout, the webmail’sPUT /mail/account/password, which hands its caller a new session, and the account’s ownPUT /auth/passwordand/auth/sessionsroutes), and the two anonymous password-reset routes (/auth/password-reset,/auth/password-reset/confirm) answer403 cross_originwhen a browser’sOriginnames another host than the one the request was sent to, ports included.SameSitealone does not separate the admin panel from the mail client, which are one site, and does not separate two ports on one host. Every other route that changes anything needs theAuthorizationheader, which another origin cannot set: the API grants no CORS. The anonymous reads —/health,/setupwhile pending,GET /brandingand its logo,GET /mailbox-exports/:token(the single-use link is the credential) — carry nothing a cross-origin page could use. The check comparesOriginwith the request’sHost, so a proxy you put in front of the stack must passHostthrough unchanged (nginx:proxy_set_header Host $host;). One that rewrites it to its upstream’s address gets403 cross_originon every sign-in. - Under
tls.mode=nonethe two applications share a host, so the mail client’s origin receives an administrator’s refresh cookie. Only the two-host deployment isolates them.
Response headers
Section titled “Response headers”Set by the web container in front of both applications, so they hold whichever proxy is in front of it. The table is abridged: docker/web/Caddyfile has the full policies, which also set default-src 'self', font-src, worker-src and manifest-src to 'self' and, in the mail client, frame-src 'self'.
| Header | Value | Why |
|---|---|---|
Content-Security-Policy |
script-src 'self', connect-src 'self', object-src 'none', frame-ancestors 'none', base-uri 'self', form-action 'self' |
Neither application has an inline script or a third-party origin, so an injected script has nowhere to load from. |
— style-src |
'self' 'unsafe-inline' |
Rich-text compose, signatures and the email frame’s wrapper carry style attributes. Styles cannot run code. |
— img-src (mail client) |
'self' data: blob: https: http: |
The email body frame inherits this policy. The client itself holds remote images back until the reader allows them; data: is for embedded images, blob: for attachments. |
X-Frame-Options |
DENY |
Clickjacking, for browsers that predate frame-ancestors. |
X-Content-Type-Options |
nosniff |
No response is sniffed into a script or a page. |
Referrer-Policy |
no-referrer |
A link or a remote image in an email does not learn the mail client’s URL. |
Permissions-Policy |
camera, microphone, geolocation, payment, usb, interest-cohort off | Neither application uses them. |
Cross-Origin-Opener-Policy |
same-origin |
A page this one opens cannot reach back into it. |
The API adds nosniff and no-referrer to every response except the four event streams (/mail/events and the three AI streams), which write their own headers. An attachment, a message’s original source (GET /mail/messages/:uid/raw, as text/plain) and a profile picture (GET /mail/avatars/:address, to a signed-in mailbox only, private, max-age=86400, its bytes checked to be the image type it claims when it was uploaded) are served with
Content-Security-Policy: sandbox; default-src 'none' and nosniff, and only a short list of attachment content types displays inline
(webmail).
Strict-Transport-Security: max-age=15552000 (six months) is sent on the three front-end names — the mail hostname, postmaster.
and webmail. — whenever TLS is on, by the edge Caddy rather than by web. Not on a customer’s own names
(autoconfig., autodiscover., mta-sts.), with no includeSubDomains and no preload. It started at one day and was raised once a release had run with it, because the header locks a browser out of a console whose certificate lapses: mailctl doctor checks each name’s certificate, and
Locked out of the console by a certificate is the way back. scripts/ci/caddy-routes.sh
holds where it is sent against a real Caddy.
The end-to-end suites fail on any CSP violation, so a feature that a later policy change breaks shows up as a red test, not as a silently empty pane.
Containers
Section titled “Containers”| Service | Runs as | Capabilities | Can gain privileges |
|---|---|---|---|
web (the two applications’ static files) |
an unprivileged user, read-only filesystem | none | no |
api |
uid and gid 2525 | none | no |
cert-sync |
root, with group 2525 added to read a BYO key the API wrote | none | no |
worker |
uid and gid 2525 | none | no |
postgres, redis, edge caddy |
their images’ defaults; each entrypoint drops to its own user, and Caddy binds 80 and 443. The edge runs the web image as root |
Docker’s default set | no |
postfix |
a root master that binds 25, 465 and 587 and drops to postfix |
CHOWN, DAC_OVERRIDE, KILL, NET_BIND_SERVICE, SETGID, SETUID |
no |
dovecot |
a root master that binds the IMAP, POP3 and Sieve ports, chroots its logins, drops to its users | CHOWN, DAC_OVERRIDE, KILL, NET_BIND_SERVICE, SETGID, SETUID, SYS_CHROOT |
no |
rspamd |
a root master that drops to _rspamd |
CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID |
no |
Why cert-sync is still root (measured): it copies the certificate and key Caddy stores 0600
as root. api and worker run as 2525. The worker only reads Postfix’s and Dovecot’s logs. Postfix 3.7 writes its log 0600
with no setting to change it (maillog_file_permissions arrived in 3.9), so the postfix and dovecot containers
hand their logs over: docker/common/maillog.sh, run by the postfix and dovecot entrypoints and every tenth pass of
their config watchers, makes the maillog directory 0:2525 0750 and every log in it 0:2525 0640, which covers the
file postlogd recreates 0600 after a rotation, and prunes rotations past a week. The worker can read that directory
and not write it, on purpose: the handover is a root chown by name, and a directory the worker could write would let
it plant a symlink for root to follow. Root-owned, because those containers have no FOWNER and root can chmod only
what it owns. Every
image that mounts mailconfig or certs creates that mountpoint as 2525:2525 2775, because Docker gives a new
volume the ownership of the first image to mount it; the daemons’ watchers make their handshake directories
group-writable for 2525 at every start and hand the API’s live render files to its group, rather than relying on
setgid, which their capability sets (no FSETID) would silently drop. An install created before this keeps a
root-owned certs volume until a one-time chown (upgrade.md). Root with no capabilities can still write files it
owns, but it cannot read or change anyone else’s.
How the mail daemons’ sets were found. Every capability was dropped and Docker’s default set
added back minus six (FSETID, MKNOD, NET_RAW, SETFCAP, SETPCAP, AUDIT_WRITE), then each of the remaining
eight was removed in turn while the Compose smoke ran and every log was read for permission errors. FOWNER was
needed by none, SYS_CHROOT only by Dovecot (its login chroot), and CHOWN by all three. KILL is needed by all
three, and only Dovecot’s need shows in a log. A root master signalling a child that has changed user needs it; refused,
rspamd’s reload leaves the old workers running (seven more per apply) and srs.sh cannot restart postsrsd, both without a
word. The review of this change found those two, so the sets are checked by what runs after a reload, not by logs alone. NET_BIND_SERVICE was not needed at all, because Docker lets a container bind any port, but it
is kept: that is a runtime default, not a guarantee. The final sets also passed each daemon’s reload, a restart, and
POP3 and ManageSieve logins. no-new-privileges holds for all three: the only setgid binaries, postdrop and
postqueue, are run by root, and Dovecot submits over postfix:25.
Images
Section titled “Images”- Every runtime stage installs the base image’s security updates (
apk upgrade,apt-get upgrade), because a base pulled once and cached keeps its old packages indefinitely. A cached upgrade layer would go stale the same way, so it is keyed on aSECURITY_UPDATESbuild argument that each release run sets to its own id: every release fetches that day’s patches.rspamdis built fromrspamd/rspamd:latest, which on 2026-10-03 carried five critical vulnerabilities that the upgrade removes. - The
apiandworkerimages ship no npm. They runnode dist/…. The npm CLI bundled with the Node base image was the source of every Node.js finding an image scan made, including a critical one in itstar. - A release scans every image we build before pushing it (Trivy). The images the product pulls from upstream (
postgres,redis, andmailctl backup’salpine) are pinned by digest and scanned at that digest by the same release run, and the pins are bumped deliberately before each release. A fixable high or critical finding in one of them stops the release too, unless it has been reviewed: unreachable here, or accepted, with its reason, for at most 90 days, after which it counts as new. The edgecaddyservice runs our ownwebimage, so the scan of that image covers the Caddy terminating TLS for the internet. A fixable high or critical vulnerability in any image stops the release: no image is pushed until every scan passes. Until 2026-10-05 only a critical did, because on 2026-10-03 thewebimage’s Caddy binary carried 17 high findings in Go libraries that only a newer Caddy build fixed.caddy:2-alpinereached 2.11.6 and all seven images scanned clean, so the gate was raised. The official Caddy image can lag Caddy’s own release again; a red scan is fixed by a bump and a new tag, never by lowering the gate in a hurry. It lagged Go on0.12.0:caddy:2-alpine’s 2.11.7 binary was built with Go 1.26.8, which carries two fixable high CVEs (CVE-2026-78667 innet/httpand CVE-2026-97031 incrypto/tls), and the scan stopped the release with nothing pushed. Thewebimage now rebuilds the same Caddy release from source with Go 1.26.9, matching the official build’s dependencies and build tags (docker/web/Dockerfile), until the official image catches up. The edgecaddyservice ran the upstream image unchanged and carried the same two findings until it moved onto thewebimage, which mounts the edge’s Caddyfile over its own and replaces its healthcheck with one on Caddy’s admin endpoint. Then a Go library lagged: on 2026-10-10 Trivy reported CVE-2026-78669 (HTTP/2, high, fixable) in thegolang.org/x/netv0.59.0 that Caddy 2.11.7 requires, so the same build raises that one module to v0.60.0 and fails if the binary does not link it. It is the only module that differs from the official binary’s. - Every release publishes a software bill of materials for each image it builds: CycloneDX JSON at
https://get.maildock.app/releases/<version>/sbom/<service>.cdx.json, signed with the release key like the release manifest (<service>.cdx.json.sig), naming the image digest it describes. It lists the operating-system packages and libraries in the image, without findings, so a scanner of your own can judge it on your own schedule. Releases from0.12.1onwards carry them (0.12.0was never published).
Dependencies
Section titled “Dependencies”CI fails when pnpm audit --prod reports a high or critical advisory in a dependency the product ships
(pnpm audit:check). Development-only packages are not covered: they never reach an install.