Status:🤷 UNKNOWN
Date:📅 unknown
Decision Makers:unknown
ADR-0010: Security & privacy posture
- Status: Accepted
- Date: 2026-06-27
- Relates to: 📝 ADR-0006 (CSP), 📝 ADR-0007 (no CDN), 📝 ADR-0009 (env-only secrets)
Context
msgbrowse holds the most sensitive data a person owns — their entire message history — and adds an LLM feature set (embeddings, RAG, journal digests) that could exfiltrate it. The adversaries are other software on the same machine, a malicious archive (crafted message content), accidental data leakage to a hosted LLM, and network attackers if the UI is ever exposed beyond loopback. This ADR records the posture; SECURITY.md is the living document.
Decision
Local-first, least-privilege, read-only with respect to the archive, with exactly one configurable network egress and no telemetry.
- Loopback-only by default, warn otherwise.
listen_addrdefaults to127.0.0.1:8787.Run(internal/web/server.go) logs a warning on a non-loopback bind ("the UI has no authentication") viaisLoopback. The UI has no auth, so any wider exposure is the operator's deliberate, flagged choice. - Strict CSP, self-only assets.
securityHeaderssetsContent-Security-Policy: default-src 'none'withscript-src 'self',style-src 'self',img-src 'self' data:,connect-src 'self',font-src 'self',base-uri 'none',form-action 'self',frame-ancestors 'none', plusX-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,X-Frame-Options: DENY. Every asset is same-origin and self-hosted — vendored htmx (📝 ADR-0006), the built stylesheet and inline Hero Icons (📝 ADR-0007),theme.js— so the CSP holds with no CDN. - Untrusted content is always escaped. Message bodies (a crafted-archive
attack surface) flow through
html/templateauto-escaping;renderBodyre-escapes text runs and linkifies URLs withrel="noopener noreferrer nofollow";highlightSnippetescapes before applying<mark>and strips stray control-character sentinels. - User-supplied archives are read-only and never written. User-pointed
archive roots mount
:roin Docker and msgbrowse only opens their files for reading; all writes go todata_dir, which must be outside any user-supplied archive. Since 📝 ADR-0020 the desktop app additionally owns managed archive roots insidedata_dir(<data_dir>/archives/<source>,internal/setup/layout.go); those are written only by the app-spawned bundled exporters and — on replica nodes — by the bundled Syncthing daemon (📝 ADR-0021), never by msgbrowse's own importer, which still only reads them. Media serving is path-traversal-contained:containWithin(internal/web/media.go) anchors and cleans the relative path and verifies it stays inside the per-source base. SVG attachments are forced to download, never inlined (they can carry script). - Encrypted
.snapshotsare listed, never opened. The SQLCipher.snapshots/*.tarbackups are inventoried by filename and size only; msgbrowse never opens, decrypts, or reads them, and never touches the macOS Keychain. - One egress, local by default. The single configurable
llm.base_url(internal/llm) is the only outbound connection off the local network; it defaults to a local LiteLLM proxy (http://127.0.0.1:4000/v1) routing to a local model, so out of the box no message content leaves the device. There is no telemetry or analytics. The LLM API key is env-only (MSGBROWSE_LLM_API_KEY, 📝 ADR-0009), never baked into the image or a committed file. Device sync (📝 ADR-0018, superseded by 📝 ADR-0021) later added a second, opt-in network surface: a bundled Syncthing daemon with a non-loopback sync listener (device_sync.listen_addr, default:8788;device_sync.enableddefaults tofalse) that does LAN-local discovery and archive transfers only — global discovery, relays, and NAT traversal are all disabled (internal/syncthing/configgen.go), so enabling it adds no cloud egress. - Per-thread privacy denylist.
journal.exclude_conversationsis a denylist of conversations whose content is never sent to any LLM, for any feature — the right control regardless of source (📝 ADR-0003). Note that vision/audio journal features send raw media bytes, a heavier egress that only applies if LiteLLM is pointed at a hosted provider. - Hardened container. The app container runs non-root, with a
read-only root filesystem (
/tmptmpfs,/datathe only writable volume), all Linux capabilities dropped, andno-new-privileges. The web port is published to host loopback only; LiteLLM is not published to the host. The in-container0.0.0.0bind is safe because the host mapping is loopback-only. - MCP is read-only on stderr-only logs. The MCP server exposes read-only tools and keeps logs on stderr so they never corrupt the stdio JSON-RPC stream (📝 ADR-0008).
Why these choices
- Loopback default over auth: a single-user local tool shouldn't ship a login system; binding to loopback removes the network attacker entirely, and a loud warning makes any wider bind a conscious decision.
default-src 'none'+ self-only assets: the archive is hostile input, so the UI is built (📝 ADR-0006/📝 ADR-0007) to need nothing off-origin — letting the CSP be maximally strict instead of carving CDN exceptions.- Read-only archive + contained media paths: the archive is the irreplaceable asset; never writing it and lexically containing every served path closes both corruption and traversal.
- Snapshots listed, not opened: their value is knowing a backup exists; decrypting them would pull plaintext and the Keychain into scope for no feature gain.
- One egress, local default, no telemetry: makes the data-leaves-the-box boundary a single, auditable line of config, off by default.
Consequences
Positive
- Out of the box, nothing leaves the machine; the one egress is explicit, single, and local-by-default. The only other network surface — the Syncthing sync listener (📝 ADR-0021) — is off by default, LAN-only, and never touches a cloud relay.
- A malicious archive cannot execute script in the UI (escaping + strict CSP) or escape its directory (path containment), nor force a script-capable SVG inline.
- Container blast radius is minimized (non-root, read-only rootfs, cap-drop, loopback-only publish).
Negative
- No built-in authentication — exposing the UI beyond loopback is entirely the operator's responsibility (the warning is the only guardrail).
- The strict no-CDN CSP forces vendoring/building every asset (📝 ADR-0007's dev-time toolchain).
- Routing LiteLLM to a hosted provider (and enabling vision/audio) silently widens egress to raw media bytes; the denylist and the local default are the mitigations, but the operator can override them.
Operational
- Keep the default local LiteLLM route; routing to a hosted model must be a
deliberate
litellm.config.yamledit and is documented as off-device. - Supply the LLM key via
MSGBROWSE_LLM_API_KEY; never commit it. data_dirmust live outside any user-supplied (read-only) archive root (app-managed roots live insidedata_dirby design, 📝 ADR-0020).
Alternatives considered
- Add UI authentication / TLS. Deferred: out of scope for a loopback, single-user tool; loopback-by-default plus operator-owned access control is the pragmatic posture.
- Index/search inside the encrypted snapshots. Rejected: would require decryption and Keychain access, dragging plaintext backups into the threat model for no browsing benefit.
- Send full archives to a hosted LLM by default for richer features. Rejected: inverts the privacy default; the local route + per-thread denylist keep the user in control of every byte that leaves.
References
internal/web/server.go(securityHeadersCSP, loopback default + warning)internal/web/media.go(containWithin, SVG-not-inlined),render.go(escaping)internal/config/config.go(llm.base_url, env-only key,journal.exclude_conversations)internal/llm/(single egress client),internal/ingest/snapshots.go(list-only)SECURITY.md(threat model, container hardening, egress table)