Complexity: Medium - 150 Points · type:security
Milestone: M2 - Multi-tenant platform
Band lever: hold-B (a leaked narrow key reaches seller funds)
Context
apps/api/src/routes/api-keys.ts gates the whole router on the
api-keys:manage scope, then accepts the scopes array from the request body
and issues a key carrying them.
Problem
Nothing checks that the requested scopes are a subset of the calling key's
scopes. Any combination of ALL_SCOPES is accepted, including
offramp:initiate. So a key deliberately issued narrow — say, an integration
key scoped only to api-keys:manage so it can rotate credentials — is
effectively a root key.
The escalation reaches money: POST /links/:id/cash-out accepts a caller-supplied
payoutFields object that overrides the seller's saved payout destination for
that cash-out, with no re-confirmation. Leaked narrow key → self-issued
offramp:initiate key → proceeds redirected.
What needs to be done
- In the create-key handler, when the caller is authenticated by API key
(rather than a browser session), reject any requested scope the calling key
does not itself hold — 403, naming the offending scopes.
- Leave session-authenticated callers able to request any scope: the seller
logging in with their wallet is the authority the keys derive from.
- Consider the same subset rule for any future key-update path.
- Tests: narrow key requesting a broader scope is rejected; narrow key
requesting an equal-or-narrower scope succeeds; session caller is unaffected.
Key files
apps/api/src/routes/api-keys.ts:43 — create-key handler
apps/api/src/services/api-keys.ts:56 — scope validation
apps/api/src/middleware/ — where the auth variables distinguish key vs session
apps/api/src/services/link-service.ts:944 — the payoutFields override this protects
Done when
Tracked in ISSUES.md — issue 6.6.
Complexity: Medium - 150 Points ·
type:securityMilestone: M2 - Multi-tenant platform
Band lever: hold-B (a leaked narrow key reaches seller funds)
Context
apps/api/src/routes/api-keys.tsgates the whole router on theapi-keys:managescope, then accepts thescopesarray from the request bodyand issues a key carrying them.
Problem
Nothing checks that the requested scopes are a subset of the calling key's
scopes. Any combination of
ALL_SCOPESis accepted, includingofframp:initiate. So a key deliberately issued narrow — say, an integrationkey scoped only to
api-keys:manageso it can rotate credentials — iseffectively a root key.
The escalation reaches money:
POST /links/:id/cash-outaccepts a caller-suppliedpayoutFieldsobject that overrides the seller's saved payout destination forthat cash-out, with no re-confirmation. Leaked narrow key → self-issued
offramp:initiatekey → proceeds redirected.What needs to be done
(rather than a browser session), reject any requested scope the calling key
does not itself hold —
403, naming the offending scopes.logging in with their wallet is the authority the keys derive from.
requesting an equal-or-narrower scope succeeds; session caller is unaffected.
Key files
apps/api/src/routes/api-keys.ts:43— create-key handlerapps/api/src/services/api-keys.ts:56— scope validationapps/api/src/middleware/— where the auth variables distinguish key vs sessionapps/api/src/services/link-service.ts:944— thepayoutFieldsoverride this protectsDone when
offramp:initiatecannot create a key that has it.Tracked in
ISSUES.md— issue 6.6.