fix(compose): scope declared From addresses to the account that owns them #27
No reviewers
Labels
No labels
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jlxq0/jmap-mcp!27
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/scope-from-addresses-to-owner"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The hold before any send tool is granted again. Refs #24.
JMAP_MCP_EXTRA_FROM_ADDRESSESwas a flat list appended to every authenticated caller, while the other two sources of a sendableFrom(Identity/get,principal_aliases) were caller-scoped. The live value isjulian@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.earthstops 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_addressesreceivessession_usernamefromidentity_from_ctx, which is the Logto JWT'semailclaim. My first commit matched the owner against it.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
a_declared_address_reaches_only_its_owner,an_unrelated_account_is_granted_nothinga_session_without_a_username_is_granted_nothingan_entry_without_an_owner_is_granted_to_nobodya_logto_claim_cannot_borrow_another_accounts_from_addressThe last is tested against a mock JMAP server with the attacker-favourable input fed deliberately: the session says
lucy, the claim argument saysjulian, 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_ADDRESSESmust becomejulian@kampong.social=julian@lindner.earthinclusters/fondue/jmap-mcp, or that address stops being sendable and the pod warns at startup. That is Clark's queue.Gated on the pinned toolchain and mutated on the security property. Merging.
My mutation, aimed at the defect Codex found rather than at the feature:
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 reachesowned_addressesfrom the Logto JWT'semailclaim.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.earthstops 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:And the old test earning its keep
Moving the owner into the entry stopped a bare role address being refused, and
role_addresses_are_refusedwent 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.