Security
Security review: Outlook and Gmail support, October 2026Outlook and Gmail review, October 2026
This review covers what Phase 2 added: sign-in with Microsoft and Google, the Microsoft Graph and Gmail mailboxes, Graph change notifications, Gmail push through Cloud Pub/Sub, and the durable webhook outbox. It follows the October 2026 audit and extends the threat model.
Scope
| Area | Code |
|---|---|
| Upstream OAuth client, token refresh and revocation | crates/upstream/src/{oauth,tokens,http,pkce}.rs |
| Sign-in pages and callbacks | crates/auth/src/upstream.rs |
| Graph and Gmail API access | crates/upstream/src/api.rs, crates/server/src/mailbox/{graph,gmail,id}.rs |
| Push endpoints | crates/server/src/push.rs, crates/upstream/src/pubsub.rs |
| Subscriptions, watches and catch-up | crates/server/src/watch/{graph,gmail,outbox}.rs |
Questions asked: SSRF, token leakage (logs, errors, URLs), verification of
push requests (JWT, clientState), replay, CSRF and login CSRF on the
upstream leg, scope minimization, and isolation between accounts.
Method
Manual review of every new entry point and every outbound request, with
tests for each finding: wiremock tests for the provider APIs, the token
endpoints, the Gmail API and the Pub/Sub JWT (signed with a throwaway RSA
key), property tests for id parsing, and cargo-fuzz targets
graph_notification, pubsub_push and provider_id.
Affected versions
v0.2.0 was released before these fixes. Findings 1, 2, 4 and 8 are fixed by the change that adds this review, and findings 3 and 5 by the fuzzing change (#48); all of them ship in the release after 0.2.0. Findings 1, 4 and 8 only apply when a Microsoft or Google client is configured, and finding 2 only when push is in use. A server that offers only IMAP sign-in, like a 0.1.0 installation upgraded with its existing settings, is not affected.
Findings
| # | Severity | Finding | Status |
|---|---|---|---|
| 1 | Medium | A Microsoft directory user without a mailbox can carry any mail value its tenant admin sets, and that address was checked against POSTKEEP_ALLOWED_EMAILS and for conflicts (the “nOAuth” pattern) |
Fixed |
| 2 | Low | Every accepted push notification scheduled its own catch-up, so a burst (or a replayed Pub/Sub token within its validity) caused one provider API call per notification | Fixed |
| 3 | Low | Extreme exp/iat claims overflowed i64 arithmetic in the ID token and push token checks (a panic in debug builds; rejected in release) |
Fixed |
| 4 | Low | The error parameter of an upstream callback was logged verbatim, allowing log-line injection by the signed-in user |
Fixed |
| 5 | Info | Provider error codes were copied into errors without a length bound | Fixed |
| 6 | Info | Deleting an account doesn’t cancel its Graph subscription or Gmail watch at the provider | Accepted |
| 7 | Info | gmail.send is redundant with gmail.modify |
Open question |
| 8 | Low | A sign-in refused for missing scopes or a missing mailbox left the fresh grant in place at the provider | Fixed |
1. Microsoft addresses without a mailbox (fixed)
Accounts are keyed by the provider’s immutable subject (Graph user id,
Google sub), never by address, so no one could take over an existing
account. But the address from GET /me (mail, else
userPrincipalName) decided whether the allow-list admitted the user and
whether the sign-in conflicted with an existing Outlook account. For a
directory user without an Exchange Online mailbox, mail is whatever the
tenant administrator typed in, so an attacker controlling any tenant could
present an allowed address, get an account on a server restricted to
other people, and block the real owner’s Outlook sign-in with a conflict.
Fix: Microsoft sign-in now also requires GET /me/mailFolders/inbox to
succeed. For mailbox users Exchange Online keeps mail equal to the
mailbox’s primary SMTP address, which must be in an accepted (verified)
domain of the tenant. Accounts without a mailbox are refused with a clear
message (OAuthError::NoMailbox). Test:
microsoft_accounts_without_a_mailbox_are_refused.
Gmail addresses come from the Gmail profile of the authorized mailbox and are Google-verified.
2. Catch-up amplification (fixed)
Graph notifications with a valid clientState and Pub/Sub pushes with a
valid token each spawned a catch-up. They were serialized per account, so
no duplicate events resulted, but each still called the provider. A
captured Pub/Sub token stays valid for up to an hour, so a replay loop
could burn the account’s Gmail API quota. Now a catch-up that is scheduled
but not yet started absorbs further requests for the same account;
requests arriving once it has started schedule exactly one more, so no
change is missed. Test: the burst step of
graph_notifications_become_ordered_webhook_events.
3. Timestamp overflow in claim checks (fixed)
exp + leeway and iat - leeway now saturate. Found while writing the
pubsub_push fuzz target; unit test extreme_timestamps_do_not_overflow.
4. Log injection through the callback error (fixed)
Only reachable after the state and browser-binding checks, so only by the
user who started the sign-in. The value is now logged only if it looks like
an OAuth error code ([a-z_]{1,64}), else as unrecognized.
5. Unbounded provider error codes (fixed)
Error messages were already capped at 300 characters; codes are now capped at 100.
8. Grants left behind by refused sign-ins (fixed)
The allow-list refusal already revoked the grant postkeep had just
received; refusals for missing scopes or a missing mailbox didn’t. Both now
revoke it (Google; Microsoft has no revocation endpoint). Test: the
revocation expectation in
sign_in_fails_without_required_scopes_or_refresh_token.
6. Subscriptions outlive deleted accounts (accepted)
Deleting an account deletes its tokens, so postkeep can no longer call the provider to cancel the Graph subscription or stop the Gmail watch. Graph subscriptions lapse within 7 days and Gmail watches within 7 days; their notifications are ignored because the subscription records are deleted with the account. Google grants are revoked on deletion, which also stops Gmail notifications. Documented in the provider guides.
7. gmail.send (open question)
gmail.modify already allows sending, so gmail.send adds no capability.
It is requested because the brief asked for it and because it makes the
consent screen say “send email on your behalf” explicitly. Dropping it
would shorten the consent screen by one line; it doesn’t change the
verification category (gmail.modify is restricted either way).
Checked and found sound
SSRF. All provider endpoints are fixed constants (overridable only in
tests). The Graph @odata.nextLink is followed only when scheme, host,
port and path prefix match the Graph endpoint, so the bearer token never
leaves Graph. Message and folder ids go into URL paths through
join_segments, which percent-encodes each segment, after
parse_provider_id limits them to [A-Za-z0-9-_=+/] and 1024 bytes.
Search words are reduced to safe characters before they reach OData
$filter, KQL $search or a Gmail query. A leading - is removed, and
unprefixed search text drops boolean and proximity operators (Graph:
AND, OR, NOT and NEAR, including NEAR(n); Gmail: OR, AND and AROUND,
including AROUND(n)), so users can’t inject operators. The push endpoints fetch nothing based on request content;
the only outbound request they can trigger is the JWKS fetch from the
fixed Google URL, at most once a minute for unknown key ids. HTTP clients
don’t follow redirects and verify TLS against webpki roots.
Token handling. Refresh and access tokens are encrypted at rest with
the existing envelope (same KDF and contexts as IMAP passwords) and
wrapped in SecretString; Debug output redacts them. Logs carry the
provider, account id and error codes, never tokens, codes, state,
clientState or push JWTs. Tokens are sent only in Authorization
headers, never in URLs. A refresh rejected with invalid_grant marks the
account as needing a new sign-in and stops retries; transient failures
don’t. A 401 from Graph or Gmail triggers exactly one refresh and retry.
Push verification. Graph: each notification must name a stored
subscription whose provider is Graph, and its clientState (a 256-bit
random secret, stored only as a SHA-256 hash) is compared in constant
time; at most 100 notifications per request are considered; validation
tokens are echoed only if short and free of control characters, as
text/plain with nosniff. Gmail: the push must carry a Google-signed
RS256 OIDC token (no other algorithm, key id required) whose issuer is
accounts.google.com, whose audience equals the configured audience,
which hasn’t expired (60 s leeway) and whose email is the configured
service account with email_verified true. Keys come from Google’s JWKS
and are cached per Cache-Control. Tests cover wrong audience, issuer,
email, unverified email, expiry, future iat, a tampered payload and an
unsigned token.
Replay. Notifications are hints, never data: each triggers a catch-up
that reads the provider from the stored position (Graph:
receivedDateTime plus the ids already reported at that instant; Gmail:
history id plus recently reported ids). Replayed, duplicated or
reordered notifications therefore can’t create duplicate events, and
missed ones are caught up on the next notification, renewal or Graph
missed lifecycle event. The outbox numbers events per account, keeps
them until the receiver accepts them, and delivers them in order with the
same X-Webhook-Id on retries.
CSRF on the upstream leg. Starting a sign-in is a same-origin POST.
It creates a single-use state (256-bit, stored server-side with a short
TTL), a PKCE S256 verifier, and a browser binding: a random value in a
__Host- cookie (Secure, HttpOnly, SameSite=Lax, path /) whose
hash is stored with the state. The callback consumes the state
atomically and requires the binding cookie to match in constant time and
the provider to match the callback path, so a callback URL lured into
another browser connects nothing (login CSRF). The upstream authorization
code is redeemed with the PKCE verifier and the client secret.
Scope minimization. Microsoft: Mail.ReadWrite, Mail.Send,
offline_access, User.Read, delegated only. Google: openid,
gmail.modify, gmail.send; not https://mail.google.com/, so postkeep
can’t permanently delete Gmail (the delete tool refuses permanent for
Gmail instead), and not email or profile. Google’s granular consent
is checked after sign-in: without gmail.modify the sign-in is refused
and the grant revoked.
Cross-account isolation. MCP tools build their API client from the account bound to the access token; ids carry no account information and are resolved only within that mailbox. Graph notifications map to an account only through the stored subscription id; the body is never trusted for the account. Gmail pushes map the notified address to the Gmail account with that address and only act if that account has an active watch. Token refresh is serialized per account. Accounts are unique per provider and subject; the same address can exist once per provider.
Residual risks
- Finding 1’s fix relies on Exchange Online keeping
mailequal to a mailbox’s primary address in a verified domain. A hybrid tenant whose on-premises administrator controls proxy addresses could still present an address in one of its own verified domains, which is the expected trust in that tenant. - Pub/Sub tokens are bearer tokens valid for up to an hour. Someone who captures one (for example from a misconfigured proxy log) can trigger catch-ups for watched Gmail accounts during that hour, coalesced as in finding 2, but can’t read or inject mail.
- Graph and Gmail calls are subject to provider quotas; heavy MCP use of one account can exhaust them for that account.
- Microsoft offers apps no endpoint to revoke a grant; deleting an Outlook account removes postkeep’s tokens but the grant stays listed at Microsoft until the user removes it.