Security
Security audit, October 2026
This review covers postkeep and its crates
postkeep-mail,postkeep-store,postkeep-authandpostkeep-webhook.
An internal review of postkeep before the v0.1.0 release. It repeated full passes over the code until one pass found no new medium or high issue. The threat model describes the system, assets and mitigations; this document records what was checked and found.
Scope
All crates on main as of 8 October 2026 (up to #26) (postkeep-mail,
postkeep-store, postkeep-auth, postkeep-webhook,
postkeep), the container image, the GitHub workflows and the
dependency set.
| Area | What was checked |
|---|---|
| OAuth 2.1 / MCP authorization / RFC 9700 | PKCE, code and refresh token lifecycle and replay, redirect URI validation and matching, error responses, iss, resource indicators and audience, client registration and authentication, revocation, consent and phishing, token passthrough, MCP session handling, Host/Origin checks |
| Cryptography | AES-256-GCM use, nonces, AAD/context binding, key parsing and rotation, hashing of secrets, constant-time comparisons, randomness, webhook HMAC signatures |
| Injection | IMAP commands (search, folders, flags, sequence sets), SMTP headers and replies, HTML/template output, SQL, logs |
| Denial of service | Body, header and time limits, concurrency caps, rate limits and their memory use, registration caps, watcher limits and backoff, message sizes |
| SSRF | Webhook URL parsing, name resolution, address filtering, DNS rebinding, redirects, timeouts, test sends |
| Multi-tenant isolation | Token → account binding, IMAP pool keying, account page and webhook scoping, deletion, allow-list enforcement |
| Web | Sessions and cookies, CSRF and same-origin checks, CSP and other headers, clickjacking, referrer leaks |
| Supply chain | cargo-deny, lockfiles, action pinning, workflow permissions, pull_request_target, base images, provenance |
| Container | Base image, user, contents, configuration |
Method
- Manual review of every module against the areas above, with the relevant RFCs (6749, 6750, 7009, 7591, 7636, 8252, 8414, 8707, 9207, 9700, 9728) and the MCP authorization and security best-practice documents.
- Regression tests for every fix, run on SQLite and PostgreSQL and against GreenMail.
- Fuzzing with cargo-fuzz: ten targets cover IMAP search rendering, folder names, message parsing, reply headers, HTML to text, redirect URIs, client IP and Basic credential headers, stored ciphertexts, and webhook URLs, headers and signatures. CI builds every target with overflow checks and runs each one on every pull request (see CONTRIBUTING.md).
Severity follows impact and exploitability for a typical deployment (a personal or small shared server on the public internet). High: account or data compromise by a remote attacker. Medium: a security control can be bypassed, or there’s a meaningful step towards compromise. Low: defence in depth or hard to exploit.
Findings
Pass 1
| ID | Severity | Finding | Fix |
|---|---|---|---|
| A-1 | Medium | Refresh token replay was not detected. Used refresh tokens were deleted, so a replay just failed and whoever rotated first kept a working grant (RFC 9700 §4.14.2). | #21: used tokens are kept until expiry; a replay revokes the grant and all its tokens |
| A-2 | Low | Authorization code replay didn’t revoke tokens issued from the code (OAuth 2.1 §4.1.3, SHOULD). PKCE already prevented redemption by a party without the verifier. | #21: codes are linked to their grant; a replay revokes it, including a grant still being created |
| A-3 | Medium | Open redirector through error redirects. With open client registration, /authorize?client_id=<attacker client>&response_type=x redirected at once to the attacker’s https URI from the server’s domain (RFC 9700 §4.11.1). |
#21: invalid requests for web redirect URIs show an error page; loopback and private-use schemes keep the standard redirect |
| A-4 | Medium | Mutable action tags in privileged workflows. The release job (contents, packages and id-token write) ran third-party actions by tag; the runtime image wasn’t pinned. | #22: actions pinned to commit SHAs, base image by digest, Dependabot for both |
| A-5 | Medium | The email allow-list didn’t apply to webhook watchers. After an address was removed, a restart still logged in to that mailbox and forwarded new-mail events. | #23: watchers check the allow-list before logging in |
| A-6 | Low | Integer overflow in sign::verify for extreme timestamps (found by fuzzing). It panicked with overflow checks; with wrapping arithmetic the freshness check could pass, but the HMAC still had to match. |
#20: abs_diff |
| A-7 | Low | postkeep-mail didn’t build on its own: it used tokio::select! without the macros feature, which workspace feature unification hid (found while setting up fuzzing). |
#20 |
Also fixed in this period, outside the security scope: new mail that
arrived between the folder scan and the start of IDLE was only reported
at the next change or IDLE renewal (up to 25 minutes later). Found by a
flaky CI run, reproduced deterministically, and fixed in #19.
Pass 2
A full second pass after the pass 1 fixes, including the new code.
| ID | Severity | Finding | Fix |
|---|---|---|---|
| B-1 | Medium | Redirect URI validation bypass with line breaks. The URL parser drops tabs and newlines, so https://a.example/cb\njavascript:… validated as one https URI. Stored one per line, it became two registered URIs, the second never checked; it also got around the 10-URI limit. |
#24: whitespace and control characters refused; the resolved URI is validated again at /authorize |
Pass 3
A full third pass on 8 October 2026 (12–12 SGT), covering every module again with emphasis on the code changed by #19–#26 (replay detection and its races, the open-redirect rule, allow-list enforcement in watchers, redirect URI validation, the IDLE change, the pinned workflows and the tower-http 0.7 upgrade of the body-limit and timeout layers). Fuzz targets ran again with overflow checks, and the full test suite (SQLite, PostgreSQL, GreenMail) passed on the result.
No new medium or high findings. One informational item was added to the accepted list (L-12).
Accepted low-severity and informational items
| ID | Item | Rationale |
|---|---|---|
| L-1 | The app doesn’t send Strict-Transport-Security. |
TLS terminates at the edge (Fly or a reverse proxy), which should set HSTS. Documented in the threat model. |
| L-2 | The CSP has no form-action. |
Chrome applies form-action to the redirect after a form POST, which would block returning to the client after sign-in. All output is escaped and the CSP forbids inline script, so there’s no injection to exploit it. |
| L-3 | Client names on the sign-in page are self-asserted. | Inherent to open registration; the redirect host is shown next to the name, and connecting requires typing an app-specific password. |
| L-4 | In-memory rate-limit maps prune only stale entries. | Bounded in practice by distinct client addresses (IPv6 grouped by /64); a large distributed attack could grow them. Process-level concurrency caps still apply. |
| L-5 | Reading a message fetches it whole into memory. | Bounded by iCloud’s message size limit, the per-account (4) and global (256) concurrency caps, and the request timeout. |
| L-6 | Private-use redirect schemes need not be reverse-DNS names. | RFC 8252 §7.1 recommends it; exact matching still applies, and such URIs only reach apps on the user’s device. |
| L-7 | refresh_token is accepted for every client, whatever grant_types it registered. |
All clients get both grants; restricting it would add nothing here. |
| L-8 | The build-stage image (rust:1-bookworm) is referenced by tag. |
Its output is built --locked from the committed lockfile; the shipped runtime image is pinned by digest. |
| L-9 | Tokens requested without resource are accepted. |
There’s one protected resource, and the bearer check rejects tokens bound to any other. |
| L-10 | The webhook test button makes the server send one request to a user-chosen public HTTPS URL. | Same SSRF filtering as deliveries, one per 10 s per account, and only for signed-in, allowed accounts. |
| L-11 | Whoever presents a stolen used refresh token can revoke the victim’s grant. | The intended trade-off of RFC 9700 replay detection; the user reconnects. |
| L-12 | Mail content returned to the model can contain prompt injection aimed at the send or delete tools. | Inherent to giving an assistant mail access. delete_message is annotated destructive, send_message open-world, and both descriptions ask the model to confirm with the user; MCP clients should require approval for them. |
Result
The loop ended after pass 3, the first pass with no new medium or high issue. Five medium findings (A-1, A-3, A-4, A-5, B-1) and three low ones (A-2, A-6, A-7) were fixed, each with regression tests, along with the IDLE defect. The items in the table above are accepted and documented in the threat model.
To report a vulnerability, see SECURITY.md.