fix(compose): scope declared From addresses to the account that owns them #27

Merged
jlxq0 merged 2 commits from fix/scope-from-addresses-to-owner into main 2026-08-29 12:30:02 +00:00
Owner

The hold before any send tool is granted again. Refs #24.

JMAP_MCP_EXTRA_FROM_ADDRESSES was a flat list appended to every authenticated caller, while the other two sources of a sendable From (Identity/get, principal_aliases) were caller-scoped. The live value is julian@lindner.earth, so with #25 merged and a second identity authenticating as itself, the server would act as her and still hand her his address.

The config could not express she may send as herself and not as him. Entries now carry an owner, owner@domain=address@domain.

A bare entry is granted to nobody

Not an error, not a grant. The server starts, warns once per entry naming the required form, and grants it to nobody.

Refusing to start would take a live mail server down over a config line. Honouring it would restore the flat list. Fail closed, stay up, say what to change. The consequence is real and stated: julian@lindner.earth stops being sendable until the Deployment names its owner. That is the correct reading of a grant that cannot say whose it is.

The role-address refusal moved into the shared address parser, so it still fires on an entry with no owner. It would otherwise have been set aside silently rather than refused, which is a guarantee the old test pinned and my first draft broke.

Codex found a cross-account grant path and it is fixed in the second commit

owned_addresses receives session_username from identity_from_ctx, which is the Logto JWT's email claim. My first commit matched the owner against it.

JMAP session username lucy@lindner.earth, JWT claim julian@kampong.social, config julian@kampong.social=julian@lindner.earth → Lucy obtains julian@lindner.earth.

A claim is asserted by the token; the session username is produced by the backend from the bearer. Authorising on the claim rather than on Stalwart's answer was the whole defect, and it would have shipped inside the commit that was fixing the adjacent one. The match now reads self.jmap.session_for(token).username, the same cached session the account id already comes from.

Mutations, each red on its own test alone

mutation red
drop the owner filter a_declared_address_reaches_only_its_owner, an_unrelated_account_is_granted_nothing
no username grants everything a_session_without_a_username_is_granted_nothing
an unowned entry becomes a grant an_entry_without_an_owner_is_granted_to_nobody
match the claim instead of the session a_logto_claim_cannot_borrow_another_accounts_from_address

The last is tested against a mock JMAP server with the attacker-favourable input fed deliberately: the session says lucy, the claim argument says julian, and the declared address must not appear.

Scope

This is a narrowing of what is deployed today. Nothing here lets the server be configured to act as an account other than its caller, which is the wider object and not what was asked for.

Five gates green on the pinned 1.93.0: fmt, clippy -D warnings, test (183), audit, deny.

Needs a manifest change to land cleanly

JMAP_MCP_EXTRA_FROM_ADDRESSES must become julian@kampong.social=julian@lindner.earth in clusters/fondue/jmap-mcp, or that address stops being sendable and the pod warns at startup. That is Clark's queue.

The hold before any send tool is granted again. Refs #24. `JMAP_MCP_EXTRA_FROM_ADDRESSES` was a flat list appended to **every** authenticated caller, while the other two sources of a sendable `From` (`Identity/get`, `principal_aliases`) were caller-scoped. The live value is `julian@lindner.earth`, so with #25 merged and a second identity authenticating as itself, the server would act as her and still hand her his address. The config could not express *she may send as herself and not as him*. Entries now carry an owner, `owner@domain=address@domain`. ## A bare entry is granted to nobody Not an error, not a grant. The server starts, warns once per entry naming the required form, and grants it to nobody. Refusing to start would take a live mail server down over a config line. Honouring it would restore the flat list. **Fail closed, stay up, say what to change.** The consequence is real and stated: `julian@lindner.earth` stops being sendable until the Deployment names its owner. That is the correct reading of a grant that cannot say whose it is. The role-address refusal moved into the shared address parser, so it still fires on an entry with no owner. It would otherwise have been set aside silently rather than refused, which is a guarantee the old test pinned and my first draft broke. ## Codex found a cross-account grant path and it is fixed in the second commit `owned_addresses` receives `session_username` from `identity_from_ctx`, which is the **Logto JWT's `email` claim**. My first commit matched the owner against it. > JMAP session username `lucy@lindner.earth`, JWT claim `julian@kampong.social`, config `julian@kampong.social=julian@lindner.earth` → Lucy obtains `julian@lindner.earth`. A claim is asserted by the token; the session username is produced by the backend from the bearer. **Authorising on the claim rather than on Stalwart's answer was the whole defect**, and it would have shipped inside the commit that was fixing the adjacent one. The match now reads `self.jmap.session_for(token).username`, the same cached session the account id already comes from. ## Mutations, each red on its own test alone | mutation | red | |---|---| | drop the owner filter | `a_declared_address_reaches_only_its_owner`, `an_unrelated_account_is_granted_nothing` | | no username grants everything | `a_session_without_a_username_is_granted_nothing` | | an unowned entry becomes a grant | `an_entry_without_an_owner_is_granted_to_nobody` | | match the claim instead of the session | `a_logto_claim_cannot_borrow_another_accounts_from_address` | The last is tested against a mock JMAP server with the attacker-favourable input fed deliberately: the session says `lucy`, the claim argument says `julian`, and the declared address must not appear. ## Scope **This is a narrowing of what is deployed today.** Nothing here lets the server be configured to act as an account other than its caller, which is the wider object and not what was asked for. Five gates green on the pinned 1.93.0: `fmt`, `clippy -D warnings`, `test` (183), `audit`, `deny`. ## Needs a manifest change to land cleanly `JMAP_MCP_EXTRA_FROM_ADDRESSES` must become `julian@kampong.social=julian@lindner.earth` in `clusters/fondue/jmap-mcp`, or that address stops being sendable and the pod warns at startup. That is Clark's queue.
JMAP_MCP_EXTRA_FROM_ADDRESSES was a flat list appended to every authenticated
caller, while the other two sources of a sendable From, Identity/get and
principal_aliases, were caller-scoped. So the live value julian@lindner.earth
was a sendable From for anybody who authenticated. With #25 merged and a second
identity authenticating as itself, the server would act as her and still hand
her his address.

The config could not express "she may send as herself and not as him", which is
the distinction the deployment needs. Entries now carry an owner,
owner@domain=address@domain, matched case-insensitively against the JMAP
session's own username.

A bare entry is not an error and not a grant. The server starts, warns once per
entry naming the required form, and grants it to nobody. Refusing to start
would take a live mail server down over a config line; honouring it would
restore the flat list. Fail closed, stay up, say what to change. The
consequence is that julian@lindner.earth stops being sendable until the
Deployment names its owner, which is the correct reading of an unattributable
grant.

The role-address refusal moved into the shared address parser so it still
fires on an entry with no owner, which would otherwise have been set aside
silently rather than refused.

The grant rule is a pure function so it can be tested without a mail server.
Mutation-checked, each red on its own test alone:
  drop the owner filter        -> a_declared_address_reaches_only_its_owner
                                  and an_unrelated_account_is_granted_nothing
  no username grants all       -> a_session_without_a_username_is_granted_nothing
  unowned entry becomes a grant-> an_entry_without_an_owner_is_granted_to_nobody

This is a narrowing of what is deployed today, not a widening. Nothing here
lets the server be configured to act as an account other than its caller.

Five gates green on 1.93.0: fmt, clippy -D warnings, test (182), audit, deny.

Refs #24
fix(compose): match the owner against Stalwart, not a Logto claim
All checks were successful
CI / tag-ancestry (pull_request) Successful in 3s
CI / cargo (pull_request) Successful in 1m1s
CI / docker (pull_request) Successful in 43s
54201991d0
Codex found the cross-account grant path in review of the previous commit.
owned_addresses received session_username from identity_from_ctx, which is the
Logto JWT's email claim, and the owner match used it. A claim is asserted by
the token; the JMAP session username is produced by the backend from the
bearer.

Concrete case, as named in the review: JMAP session lucy@lindner.earth, JWT
claim julian@kampong.social, config julian@kampong.social=julian@lindner.earth.
Lucy obtains julian@lindner.earth while every JMAP operation still runs on her
own account. Authorising on a claim rather than on the backend's answer is the
whole defect.

The match now reads self.jmap.session_for(token).username, which is cached and
is the same session the account id already comes from.

Tested against a mock JMAP server with the attacker-favourable input fed
deliberately: session says lucy, the claim argument says julian, and the
declared address must not appear. Mutation-checked: reverting the match to the
claim turns exactly that test red.

Five gates green on 1.93.0: fmt, clippy -D warnings, test (183), audit, deny.

Refs #24

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

Gated on the pinned toolchain and mutated on the security property. Merging.

cargo +1.93.0 fmt --check                        rc=0
cargo +1.93.0 clippy --all-targets --all-features -D warnings   rc=0
cargo +1.93.0 test --all-features --locked       rc=0, 183 passed

My mutation, aimed at the defect Codex found rather than at the feature:

let authenticated_as = self.jmap.session_for(token).await?.username;
  ->  let authenticated_as = session_username.map(str::to_owned);

FAIL  a_logto_claim_cannot_borrow_another_accounts_from_address
182 passed, 1 failed

Reverting the root of trust to the JWT claim reds exactly the case named for it and nothing else.

The finding is worth more than the feature

A cross-account grant appeared inside the commit that was fixing the adjacent one. The first draft matched the owner against identity_from_ctx's username, which reaches owned_addresses from the Logto JWT's email claim.

JMAP session username   lucy@lindner.earth
JWT claim               julian@kampong.social
config                  julian@kampong.social=julian@lindner.earth
-> Lucy obtains julian@lindner.earth

A claim is asserted by the token; the session username is produced by the backend from the bearer. One root of trust swapped for another, inside a security fix, by the person writing it. It now matches session_for(token).username, the same cached session the account id already comes from, and the test feeds the attacker-favourable input deliberately rather than asserting the happy path.

The bare-entry decision is right and its cost is real

A bare entry is granted to nobody: the server starts, warns per entry, grants nothing. Refusing to start would take a live mail server down over a config line; honouring it would restore what this removes.

So julian@lindner.earth stops being sendable until the Deployment names its owner, and that is a functional loss for the principal until one line changes. Clark has it and it goes in the same manifest change as the flag, or there is a window where his own address is unsendable and nobody expects it:

JMAP_MCP_EXTRA_FROM_ADDRESSES = julian@kampong.social=julian@lindner.earth

And the old test earning its keep

Moving the owner into the entry stopped a bare role address being refused, and role_addresses_are_refused went red rather than the change shipping. That is what a test pinning a guarantee is for, and the check moving into the shared parser is the right repair rather than restoring the old call site.

**Gated on the pinned toolchain and mutated on the security property. Merging.** cargo +1.93.0 fmt --check rc=0 cargo +1.93.0 clippy --all-targets --all-features -D warnings rc=0 cargo +1.93.0 test --all-features --locked rc=0, 183 passed **My mutation, aimed at the defect Codex found rather than at the feature:** let authenticated_as = self.jmap.session_for(token).await?.username; -> let authenticated_as = session_username.map(str::to_owned); FAIL a_logto_claim_cannot_borrow_another_accounts_from_address 182 passed, 1 failed **Reverting the root of trust to the JWT claim reds exactly the case named for it and nothing else.** ## The finding is worth more than the feature **A cross-account grant appeared inside the commit that was fixing the adjacent one.** The first draft matched the owner against `identity_from_ctx`'s username, which reaches `owned_addresses` from the Logto JWT's `email` claim. JMAP session username lucy@lindner.earth JWT claim julian@kampong.social config julian@kampong.social=julian@lindner.earth -> Lucy obtains julian@lindner.earth **A claim is asserted by the token; the session username is produced by the backend from the bearer.** One root of trust swapped for another, inside a security fix, by the person writing it. **It now matches `session_for(token).username`, the same cached session the account id already comes from**, and the test feeds the attacker-favourable input deliberately rather than asserting the happy path. ## The bare-entry decision is right and its cost is real **A bare entry is granted to nobody: the server starts, warns per entry, grants nothing.** Refusing to start would take a live mail server down over a config line; honouring it would restore what this removes. **So `julian@lindner.earth` stops being sendable until the Deployment names its owner**, and that is a functional loss for the principal until one line changes. **Clark has it** and it goes in the same manifest change as the flag, or there is a window where his own address is unsendable and nobody expects it: JMAP_MCP_EXTRA_FROM_ADDRESSES = julian@kampong.social=julian@lindner.earth ## And the old test earning its keep **Moving the owner into the entry stopped a bare role address being refused**, and `role_addresses_are_refused` went red rather than the change shipping. **That is what a test pinning a guarantee is for**, and the check moving into the shared parser is the right repair rather than restoring the old call site.
jlxq0 merged commit d2213a6432 into main 2026-08-29 12:30:02 +00:00
jlxq0 deleted branch fix/scope-from-addresses-to-owner 2026-08-29 12:30:02 +00:00
jlxq0 referenced this pull request from a commit 2026-08-29 12:32:04 +00:00
Sign in to join this conversation.
No reviewers
No labels
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/jmap-mcp!27
No description provided.