-
Notifications
You must be signed in to change notification settings - Fork 0
Hardening
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.
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.csrebuilds the document, keeping an allow-listed subset; scripts, event handlers, forms and frames do not survive it. -
CssScrubber.csdoes the same for styles, which are their own exfiltration channel (remoteurl()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.
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.
/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. -
@mountandseccompin 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. -
RestrictSUIDSGIDoff,openat2admitted — bubblewrap 0.12 opens every bind source withopenat2(the symlink-safe open, with no fallback to the unprotected one), and systemd can honourRestrictSUIDSGIDonly 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 byNoNewPrivileges, 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.
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.
- 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.
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).
- Dependency auditing runs at every build:
NuGetAuditinallmode atlowseverity (Directory.Build.props#L16-L18), so a known advisory in any package, direct or transitive, fails the restore — locally and in CI. - The
.debis verified in a cleandebian:trixiecontainer before release (packaging/test-deb.sh): the install must resolve, a fulllddsweep 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.
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.