Skip to content

Hardening

codingncaffeine edited this page Sep 28, 2026 · 8 revisions

Mail is untrusted input from strangers, and this application treats it that way. This page walks the defensive layers from the message inward to the kernel, and every claim points at the code that makes it true — pinned to the v0.0.4 tag so the lines stay where this page says they are — or at a check you can run on your own machine.

Layer 1 — a sanitizer the message cannot go around

Every HTML body passes through the sanitizer before the rendering engine ever sees a byte of it. There is no second path: the reading pane and the message window both render only what MessageRenderer returns, and the sanitizer sits inside it — not beside it where a caller could forget it.

  • HtmlSanitizer.cs rebuilds the document, keeping an allow-listed subset; scripts, event handlers, forms and frames do not survive it.
  • CssScrubber.cs does the same for styles, which are their own exfiltration channel (remote url() fetches).
  • Remote images and stylesheets are stripped by default and counted; the pane's bar reports the hosts that wanted to be fetched, and allowing a sender is a per-sender decision that never carries to the next message.
  • The document is loaded with a base of about:blank (ReadingPaneBody.cs#L764-L766), so a relative reference that survived sanitizing resolves to nowhere.

Check it yourself: send yourself an HTML message with a remote <img>. The image does not load, and the message's bar names the host that was refused. Nothing was fetched: watch with ss -t while opening it.

Layer 2 — the engine, and the engine's own sandbox

Rendering is done by WPE WebKit out of process. The web process runs inside WebKit's own bubblewrap sandbox — namespaces, seccomp, a private filesystem view — and the launcher below deliberately leaves that sandbox intact rather than trading it for the outer one (the reasoning is written where the decision is: mailbox-launcher.sh#L75-L110). The engine keeps no profile: the web view's data and cache directories point at a scratch path (the GTK fallback uses an ephemeral data manager outright — ReadingPaneBody.cs, OnEnvironmentRequested), and message content needs no JavaScript of its own to display.

Check it yourself: with a message open, pgrep -a WPEWebProcess, then compare ls /proc/<pid>/ns against your shell's — the render process lives in its own mount, PID and user namespaces.

Layer 3 — the whole application inside a hardened unit

/usr/bin/mailbox is not the binary. It is a readable shell script — packaging/mailbox-launcher.sh — that starts the application in a transient systemd user unit (the exec block, #L113-L137):

Property What it holds
ProtectSystem=strict, ProtectHome=read-only The filesystem is read-only outside the application's own directories.
ReadWritePaths= (each named) Writes land only in the XDG mailbox directories, the download directory, and the runtime subdirectories the engine's sandbox needs.
PrivateTmp=yes No shared /tmp with the rest of the session.
NoNewPrivileges=yes Nothing in the unit gains privileges by executing a set-id file.
CapabilityBoundingSet= (empty) No capabilities, held or acquirable.
SystemCallFilter=@system-service @mount seccomp mincore openat2 A syscall allow-list; everything else is refused.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK Sockets only in the families a mail client uses.
LockPersonality, ProtectKernelModules, ProtectControlGroups, ProtectClock, RestrictRealtime The usual kernel-surface reductions, all on.

Just as important is what is deliberately off, because a wall that breaks the engine's own sandbox makes the application less defended, not more. Each exception is documented beside the property it relaxes (#L75-L110):

  • MemoryDenyWriteExecute — the .NET JIT needs W^X mappings; the runtime aborts without them.
  • RestrictNamespaces — the engine builds its sandbox out of namespaces; taking them away would trade its sandbox for this one.
  • @mount and seccomp in the filter — bubblewrap assembles the engine's sandbox out of mount, pivot_root and seccomp calls.
  • mincore — the EGL loader probes its own mappings while the render process brings up its display; without it the filter killed the web process silently and every message body rendered blank.
  • RestrictSUIDSGID off, openat2 admitted — bubblewrap 0.12 opens every bind source with openat2 (the symlink-safe open, with no fallback to the unprotected one), and systemd can honour RestrictSUIDSGID only by refusing that whole call, since the mode bits it polices travel in a struct seccomp cannot read. What the option held is nearly all held anyway by NoNewPrivileges, the empty capability set and the read-only tree.
  • ProtectKernelTunables/ProtectKernelLogs/ProtectHostname — each overmounts a piece of /proc, and the kernel then refuses the engine's sandbox its fresh procfs. What they mask is root's to write anyway.

Check it yourself: cat $(command -v mailbox) — the whole confinement is those ~120 lines. While the app runs, find the unit and read the properties systemd actually applied:

unit=$(systemctl --user list-units 'run-*' --no-legend | grep -i mailbox -m1 | awk '{print $1}')
systemctl --user show "$unit" -p ProtectSystem -p ProtectHome -p NoNewPrivileges \
    -p CapabilityBoundingSet -p SystemCallFilter -p RestrictAddressFamilies

(If the unit list shows no description match, journalctl --user -t mailbox -o verbose | grep -m1 _SYSTEMD_USER_UNIT names it.) MAILBOX_NO_SANDBOX=1 is the documented escape for debugging, and sessions without a systemd user manager run unconfined rather than not at all — the launcher says so in its header.

Layer 4 — credentials in the keyring, never in a file

Passwords go to the desktop keyring over Secret Service, through secret-tool, and there is no file fallback by design: if no keyring answers, the store keeps credentials only in memory for the session rather than inventing a file format (CredentialStore.cs#L226 is the decision; SecretServiceStore above it is the keyring half). OAuth refresh tokens go to the keyring; access tokens are never written anywhere.

Check it yourself: grep -ri password ~/.config/mailbox ~/.local/share/mailbox finds nothing; your keyring tool (e.g. KWalletManager, Seahorse) shows the entries.

Layer 5 — what leaves the machine

  • No telemetry, no crash reporting, no phoning home. The update check (UpdateCheck.cs) is off by default and asks GitHub for a version number when you turn it on.
  • The Weather module asks for nothing until you add a place. Then it asks the public services directly from your machine, with no account and no key: Open-Meteo for the place search (what you type into Add Location), the forecasts and air quality, the US National Weather Service for warnings and the forecasters' discussion, and NOAA nowCOAST and Environment and Climate Change Canada's GeoMet for the map. They are told the place's coordinates — for the map, the area in view; for the discussion, the office that covers it — and a User-Agent naming Mailbox, its version and its repository, as the Weather Service asks; nothing about you. A US zip code is also looked up in a list inside the application, which sends nothing. Work Offline stops the scheduled updates and every forecast refresh; the map's pictures and a search you type into Add Location are still asked for while it is on (WeatherReceiver.cs, MapLayers.cs).
  • Remote images, when you allow a sender, are fetched through one controlled HTTP client — the same one whose refusals the bar reports — never by the engine on its own initiative.
  • DKIM verification resolves selector records through the resolver; signatures are checked as mail arrives, never as it is drawn, so drawing a message causes no lookups (the receiver owns the verifier: App.axaml.cs#L731-L734).

Check it yourself: run the application with no accounts and no weather places configured and watch ss -tp — nothing connects. Add an account: the connections are your mail servers. Add a weather place: the connections are the weather services above.

Layer 6 — the store, the logs, and what they never contain

One SQLite file per account under $XDG_DATA_HOME/mailbox/accounts/, so a corrupt file costs one account. Settings are JSON under $XDG_CONFIG_HOME/mailbox/; logs under $XDG_STATE_HOME/mailbox/logs, five runs kept. Protocol wire logs — which are your mail — are off unless MAILBOX_PROTOCOL_LOG asks, and when they are on they are written under the state directory rather than /tmp, because /tmp is world-readable on a shared machine (App.axaml.cs#L711-L715).

Layer 7 — the supply chain and the artifacts

  • Dependency auditing runs at every build: NuGetAudit in all mode at low severity (Directory.Build.props#L16-L18), so a known advisory in any package, direct or transitive, fails the restore — locally and in CI.
  • The .deb is verified in a clean debian:trixie container before release (packaging/test-deb.sh): the install must resolve, a full ldd sweep of every shipped ELF must come back with nothing missing, and the binary must start to the point of asking for a display.
  • The AUR package pins the release tarball's SHA-256 (packaging/aur/PKGBUILD).
  • S/MIME and OpenPGP ship disabled. Cryptography that runs before anyone asked for it is attack surface; nothing signs, encrypts, decrypts or verifies until it is turned on.

What this does not claim

Honesty is part of a security posture. The local store is not encrypted at rest (planned; today the answer is full-disk encryption). The engine, not this application, parses what the sanitizer lets through — that is why it is sandboxed twice rather than trusted once. And the tarball run directly (./mailbox) is unconfined unless your session has a systemd user manager for the launcher to use — the packaged installs use the launcher path.

Clone this wiki locally