Skip to content

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.

  • 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’s PUT /mail/account/password, which hands its caller a new session, and the account’s own PUT /auth/password and /auth/sessions routes), and the two anonymous password-reset routes (/auth/password-reset, /auth/password-reset/confirm) answer 403 cross_origin when a browser’s Origin names another host than the one the request was sent to, ports included. SameSite alone 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 the Authorization header, which another origin cannot set: the API grants no CORS. The anonymous reads — /health, /setup while pending, GET /branding and its logo, GET /mailbox-exports/:token (the single-use link is the credential) — carry nothing a cross-origin page could use. The check compares Origin with the request’s Host, so a proxy you put in front of the stack must pass Host through unchanged (nginx: proxy_set_header Host $host;). One that rewrites it to its upstream’s address gets 403 cross_origin on every sign-in.
  • Under tls.mode=none the two applications share a host, so the mail client’s origin receives an administrator’s refresh cookie. Only the two-host deployment isolates them.

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.

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.

  • 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 a SECURITY_UPDATES build argument that each release run sets to its own id: every release fetches that day’s patches. rspamd is built from rspamd/rspamd:latest, which on 2026-10-03 carried five critical vulnerabilities that the upgrade removes.
  • The api and worker images ship no npm. They run node 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 its tar.
  • A release scans every image we build before pushing it (Trivy). The images the product pulls from upstream (postgres, redis, and mailctl backup’s alpine) 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 edge caddy service runs our own web image, 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 the web image’s Caddy binary carried 17 high findings in Go libraries that only a newer Caddy build fixed. caddy:2-alpine reached 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 on 0.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 in net/http and CVE-2026-97031 in crypto/tls), and the scan stopped the release with nothing pushed. The web image 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 edge caddy service ran the upstream image unchanged and carried the same two findings until it moved onto the web image, 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 the golang.org/x/net v0.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 from 0.12.1 onwards carry them (0.12.0 was never published).

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.