Security
Threat model
This describes what postkeep protects, from whom, and how. It
covers the server in crates/server, the OAuth authorization server in
crates/auth, storage in crates/store, the mail client in crates/mail and
webhook delivery in crates/webhook, as deployed from the
container image. The latest review is in audit-2026-10.md.
System overview
MCP client ──HTTPS──▶ reverse proxy / Fly edge ──▶ postkeep ──IMAP/SMTP · Microsoft Graph · Gmail API──▶ mailbox
▲ (OAuth 2.1, bearer tokens) │ │
│ │ └──HTTPS──▶ webhook receiver (user-chosen)
user's browser (sign-in page, account page) ◀───────┘
└──▶ SQLite / PostgreSQL- MCP clients discover the authorization server (RFC 9728, RFC 8414),
identify themselves with a Client ID Metadata Document at an
httpsURL or register dynamically (RFC 7591), and send the user to/authorize. - The user signs in with Microsoft or Google, or with an app-specific
password for iCloud Mail, Fastmail, Yahoo Mail, or an IMAP server the
operator configured. App-password sign-in is checked with an IMAP login.
The server stores the app password or the provider tokens encrypted, and
issues an authorization code (PKCE
S256required). - Tool calls on
/mcpcarry an opaque bearer token. The server loads the account’s credentials and uses IMAP/SMTP, Microsoft Graph, or the Gmail API on the user’s behalf. - Optionally, one watcher per account watches the Inbox: IMAP
IDLE(or a 60-second poll withoutIDLE), Graph notifications, or Gmail watch through Pub/Sub, and POSTs a small signed JSON event to the user’s webhook URL when mail arrives.
Assets
| Asset | Where | Why it matters |
|---|---|---|
| App-specific passwords | accounts table, AES-256-GCM |
Full IMAP/SMTP access to the mailbox (read, send, delete) until the app password is revoked at that provider (for iCloud Mail, account.apple.com) |
| Master key | Environment / secret file, process memory | Decrypts stored app passwords, Microsoft and Google tokens, webhook secrets and stored events |
| MCP access and refresh tokens, codes | Client, SHA-256 hashes in the database | Act as the user through the MCP tools |
| Mail content | Bodies are never stored. If the user turns on sender and subject, those two fields are stored inside the encrypted webhook event | Private correspondence; returned to the MCP client, never logged |
| Account page sessions | Cookie, hash in the database | Disconnect one app or all, replace the stored password (checked with the mail server first), delete data, configure webhooks |
| Webhook auth header and signing secret | Encrypted in the database | Authenticate deliveries to the user’s own endpoint |
| Availability | — | A personal server that silently stops is a nuisance; one that leaks is worse |
Actors
| Actor | Capabilities assumed |
|---|---|
| Anonymous internet user | Reaches every public endpoint, registers OAuth clients, sends crafted requests at volume, hosts web pages and webhook receivers |
| Malicious OAuth client | Registers any valid redirect URI and client name, crafts authorization links, keeps stolen codes or tokens |
| Malicious email sender | Controls the headers and bodies of mail delivered to a connected account |
| Other tenant | A different signed-in account on the same (shared) server |
| Network attacker | Observes or tampers with traffic between components (TLS is assumed on every hop) |
| Compromised dependency or CI action | Code running in the build or in the process |
| Operator | Trusted: holds the master key and the database. Not a threat the server can defend against |
Trust boundaries and entry points
- Public HTTP (
/register,/authorize,/token,/revoke,/.well-known/*,/account*, static assets,/,/healthz,/readyz). Unauthenticated. The per-IP request rate limit covers/register,/authorize,/token,/revoke,/accountand/account/*, and/oauth/{provider}/startand/oauth/{provider}/callback. These routes have no per-IP request rate limit:/.well-known/*, static assets (/assets/*, the favicons,/oauth/icon.svgand/oauth/logo.svg),/,/mcp,/healthz,/readyz, and the provider push endpoints (/push/microsoft,/push/microsoft/lifecycle,/push/google)./healthzand/readyzare also outside the global concurrency cap, the body limit and the request timeout./,/mcpand the push endpoints stay inside those three. - Protected MCP endpoint (
/mcp). Bearer token required, stateless (no MCP sessions),HostandOriginallow-lists against DNS rebinding. No per-IP request rate limit; a per-account concurrency cap applies. - Mail providers: IMAP/SMTP servers (iCloud, Fastmail, Yahoo, or a configured server), Microsoft Graph, the Gmail API, and Microsoft’s and Google’s OAuth endpoints. TLS with webpki roots. Responses (mail data, tokens, error bodies) are untrusted input.
- Provider push endpoints (
/push/microsoft,/push/microsoft/lifecycle,/push/google). Public and unauthenticated at the HTTP level, and outside the per-IP request rate limit. Each request is verified (clientState, Google-signed OIDC token). A verified Graph notification is handled only for the subscription id it names. Change notifications and amissedlifecycle event schedule a catch-up.reauthorizationRequiredreauthorizes that id when the local row still stores it, andsubscriptionRemoveddeletes the local row only when it still stores that id. A verified Gmail push only schedules a catch-up. See the provider review. - Upstream sign-in callbacks (
/oauth/microsoft/callback,/oauth/google/callback). Bound to the starting browser bystate, PKCE and a__Host-cookie. - Webhook receivers. User-supplied URLs reached from the server. Risk of SSRF.
- Client ID Metadata Documents. An unauthenticated
/authorizerequest names anhttpsURL that the server fetches. Risk of SSRF and of a self-named client. - Database. Trusted storage, but its contents are assumed readable by whoever gets a backup. Secrets are encrypted or hashed.
- Build and supply chain. Crates, CI jobs and their images, base images.
Threats and mitigations
Authentication and authorization (OAuth 2.1, MCP authorization)
| Threat | Mitigation |
|---|---|
| Stolen authorization code redeemed | PKCE S256 mandatory; codes last 5 min and work once; a replayed code revokes the grant made from it |
| Stolen refresh token | Rotation on every use. A replay of a used refresh token revokes the whole grant (RFC 9700 §4.14.2) |
| Token for another resource (confused deputy) | RFC 8707 resource checked at /authorize, /token and on every bearer request; no token passthrough (the server uses the stored app password or the stored provider tokens, never the client’s token). An embedding that serves several hosts turns on per-request resources: each allowed host is its own resource, and so is each name under a resource prefix (such as /w/<name>/mcp) when the embedding sets one. resource is required at /authorize and /token, and a token is accepted only at the exact resource it names (not the issuer, not another host or name, never without one). Requests for hosts the server does not serve are refused before authentication |
| Redirect URI manipulation | Exact match (any port only for loopback, RFC 8252); https or loopback http or private-use schemes; fragments, whitespace, control characters and dangerous schemes refused at registration and again at /authorize |
| Open redirector via error redirects | Invalid requests for web redirect URIs show an error page instead of redirecting (RFC 9700 §4.11.1) |
| Mix-up attacks | iss returned with every authorization response (RFC 9207) |
| Sign-in for an embedding’s own resources | An embedding’s sign-in hook gets only the /authorize requests for the resources it claims; a request without a resource always gets the mailbox sign-in. The mailbox sign-in page and Microsoft or Google sign-in can neither finish nor cancel those requests, and the embedding cannot approve or deny any other request. Codes and tokens for its principals follow the same PKCE, single-use, rotation, replay and audience rules, and never act for a mailbox account: the default principal resolver refuses them, so only a resolver the embedding installs can accept them. The hook’s response is sent with Cache-Control: no-store, and the pending request’s ID, which is all it takes to approve or deny it, is kept secret |
| Mailboxes added to an embedding’s tenants | Only an embedding’s own page can start a Microsoft or Google sign-in for a tenant (AuthState::start_upstream_sign_in); POST /oauth/{provider}/start cannot name one. The callback makes the same checks as for any provider sign-in (single-use state, the browser-binding cookie, PKCE, every scope, the allow-list) before it stores the account in that tenant and calls the embedding’s continuation hook, and without that hook it stops before the code exchange. The continuation is stored encrypted. The embedding starts these sign-ins only for same-origin posts from a person it signed in, with the tenant taken from that session, so nobody can add a mailbox to someone else’s tenant |
| Phishing with a lookalike client name | The sign-in page shows the redirect host next to the client-supplied name. App-password sign-in requires typing that password; Microsoft and Google sign-in do not. Registration strips bidi overrides, other format controls and Unicode line separators from the client name, and the pages strip them again before display and isolate the name and the redirect host (<bdi>) so they cannot reorder the surrounding text. Residual risk: users may still trust a convincing name |
| Brute-forcing app passwords through the sign-in page | Exponential backoff per client IP (to 15 min) and per address (to 1 min, so the owner can’t be locked out for long); malformed addresses and empty passwords are rejected before any connection; other input goes only to the selected preset or the operator’s IMAP server |
| Login CSRF / account page CSRF | Every account page POST needs a session-bound CSRF token and a same-origin Origin/Referer; cookie __Host-, HttpOnly, SameSite=Lax, Secure |
| Clickjacking | frame-ancestors 'none', X-Frame-Options: DENY |
Web pages calling /mcp from a browser (DNS rebinding, cross-site requests) |
The Host must be one the server serves, and an Origin, when sent, the public URL’s origin or one in POSTKEEP_ALLOWED_ORIGINS; anything else gets 403 before the token is checked. /mcp sends no CORS headers, so pages on other origins cannot read its replies |
| Unwanted accounts on a personal server | POSTKEEP_ALLOWED_EMAILS checked at sign-in, on every MCP request and refresh, on the account page, before an IMAP watcher logs in, before a Graph subscription or a Gmail watch is created or renewed, before Graph or Gmail catch-up reads mail, and before a queued webhook event is sent. Principals that an embedding’s sign-in hook approves have no address; the embedding decides who they are |
Multi-tenant isolation
- Every tool call resolves the account from the bearer token, never from
input. An embedding whose principal resolver grants several mailboxes
lets a call name one with a
mailboxargument; it is honoured only if it is one of the token’s mailboxes in the token’s tenant, and any other value fails with the same unknown-mailbox error. An embedding’s tool extension gets the same checked mailbox: a call naming another one fails before the extension’s check or tools run, and an extension tool cannot take the name of one of the server’s. The per-account concurrency limit counts the mailbox actually used. IMAP sessions are pooled per account and per credential fingerprint, so they can’t cross accounts or outlive a password change. - Account page and webhook queries are scoped by the session’s account ID. Deleting an account removes its tokens, sessions, codes, webhooks and delivery history.
- Encryption contexts bind each ciphertext to its account (password AAD is the address; webhook secrets use the account ID), so swapping rows between accounts fails to decrypt.
Injection
- IMAP: search values reject control characters and are quoted or sent as
literals; folder names are modified-UTF-7 encoded and quoted by
async-imap, which rejects CR/LF. Fuzzed (
imap_search,utf7). - SMTP: subjects and display names with line breaks are refused; headers
are built by lettre. Replies to hostile messages carry only the headers
the crate sets. Fuzzed (
reply_headers). - HTML: pages are askama templates with auto-escaping and a strict CSP (no inline script or style). Mail HTML is converted to plain text for tool results, never rendered.
- SQL: static, parameterised queries only.
- Logs: secrets, tokens and message content are never logged. Crates that
would log IMAP traffic, SMTP commands or MCP tool results stay capped
even when
POSTKEEP_LOGasks for trace.
SSRF (webhooks and client metadata documents)
https only; no userinfo; local names refused; every resolved address must
be public (no private, loopback, link-local, CGNAT, multicast,
documentation or metadata ranges; IPv4-mapped/NAT64 addresses use the IPv4
rules); the connection is pinned to the vetted address (no DNS rebinding);
redirects aren’t followed; short timeouts and a 16 KiB response cap. Test
sends are limited to one per 10 s per account. Fuzzed (webhook_target).
Client ID Metadata Documents use the same client and rules, with a 5 s limit
and a 64 KiB body cap. The URL must already be in normal form and the
document must name it as its client_id. Only public clients are accepted
(no client_secret, token_endpoint_auth_method none). Redirect URIs
pass the registration rules and must then match exactly, with no loopback
port leeway. A valid document is reused for its Cache-Control: max-age,
clamped to 5 minutes to 24 hours, which limits how often /authorize can
make the server fetch. Errors are never cached. Fetches count against the
per-IP request limit; a new client counts against the per-IP registration
limit and the client limit, like a registration. The sign-in page shows the
host that published the document next to the self-asserted name. Because
/authorize needs no sign-in, anyone can make the server fetch a public
HTTPS URL once per cache lifetime; the error page then shows only the HTTP
status, never the body.
Denial of service
Request body limits (16 KiB for auth endpoints, 1 MiB for MCP), header
read timeout, request timeout, a global concurrency cap (every route
except /healthz and /readyz; /readyz runs one round of database
checks at a time and reuses the answer for a second), a per-account
concurrency cap on /mcp, per-IP
request rate limits on the auth routes in item 1, a per-IP registration
limit on /register, a cap on registered clients with purging of
abandoned ones, a cap on watchers, and watcher reconnect backoff (longer
after an authentication failure).
Limits are per process and in memory.
Cryptography
AES-256-GCM with a random 96-bit nonce per value and context-bound AAD;
master key rotation re-encrypts stored secrets at startup when
POSTKEEP_PREVIOUS_MASTER_KEYS (or _FILE) is set; SHA-256 hashes
for high-entropy secrets (MCP tokens, codes, session IDs, client secrets);
constant-time comparisons; 256-bit random tokens from the OS RNG. Webhooks
are signed with HMAC-SHA256 over timestamp.body.
Supply chain and container
cargo-deny (advisories, licences, sources, bans) in CI; Cargo.lock
committed and builds --locked; the runtime base image pinned by digest
and bumped by hand. CI runs on the project’s own GitLab runner. No release
image is published yet. The image is distroless, runs as a non-root user, and holds
only the binary.
Assumptions and residual risks
- The operator is trusted. Anyone with the master key and the database can read every stored app password, Microsoft and Google token, webhook secret and stored webhook event. Use a secret store for the key.
- App-specific passwords are powerful. They grant full mailbox access until the app password is revoked at that provider (for iCloud Mail, account.apple.com). The account page says so.
- TLS terminates in front of the app (Fly edge or a reverse proxy). The app doesn’t send HSTS itself; set it at the proxy.
- Client display names aren’t verified (open registration). The redirect host shown on the sign-in page is the reliable signal. The name and that host are isolated so a name cannot reorder the sentence.
- Replay detection can be used to sign someone out: whoever presents a stolen used refresh token revokes the grant. That’s the intended trade-off of RFC 9700.
- Rate limits are per process. Several replicas multiply them; the limit maps prune old entries but can grow under a large distributed attack.
- Webhook payloads leave the server. Sender and subject leave the server only when the user turns on “Include sender and subject” (off by default).
- Provider grants are powerful. A Microsoft or Google refresh token grants mail read, modify and send access until it expires or the user revokes it at the provider. Microsoft doesn’t let apps remove their own access: remove postkeep at account.live.com/consent/Manage (personal accounts) or myapps.microsoft.com (work or school). Deleting a Gmail account on the account page tries to revoke postkeep’s access at Google; users can also remove it at myaccount.google.com/permissions. postkeep stores the token encrypted like an app password. Residual risks of provider support are listed in the provider review.
- A stopped machine doesn’t watch. Webhooks need an always-on instance (see deployment).