Reviewed commit: be11bdcb5 (origin/main), from the 2026-07-28 full-repo review.
Severity: HIGH. Privilege escalation into another tenant's accounts via a plan re-point.
Where
internal/api/handler_accounts.go:1304-1348 (setPlanAccounts)
What
PUT /api/plans/{id}/accounts calls requirePermission(ctx, httpReq, "update", "plans") at :1309 and then goes straight to UUID-shape validation and validatePlanAccountProviders.
It never calls h.requirePlanAccess(ctx, session, id), which every other plan mutation does (handler_plans.go:169/209/267/293/491), and it never checks the supplied account_ids against the caller's allowed_accounts. It also discards the session: requirePermission's return value is assigned to _.
Failure scenario
User U has allowed_accounts = [acct-A] and update:plans. Plan P belongs to acct-B, and GET /api/plans/P correctly 404s for U.
U sends PUT /api/plans/P/accounts {"account_ids":["<acct-A>","<acct-B>"]}, which returns 200. P now intersects acct-A, so requirePlanAccess passes for U from that point on, and POST /api/plans/P/purchases (handler_plans.go:278) creates scheduled executions against a plan that still targets acct-B.
The reverse direction is equally available: U re-points their own plan onto acct-B and schedules purchases there.
Fix direction
Add requirePlanAccess(session, id) plus a per-account_id requireAccountAccess loop before SetPlanAccounts.
Related
Same family as LeanerCloud/cloud-commitments-platform#29, which covers only listPlans read scoping. Also related: #959 (dashboard calculateCommitmentMetrics allowed-accounts intersection).
Reviewed commit:
be11bdcb5(origin/main), from the 2026-07-28 full-repo review.Severity: HIGH. Privilege escalation into another tenant's accounts via a plan re-point.
Where
internal/api/handler_accounts.go:1304-1348(setPlanAccounts)What
PUT /api/plans/{id}/accountscallsrequirePermission(ctx, httpReq, "update", "plans")at:1309and then goes straight to UUID-shape validation andvalidatePlanAccountProviders.It never calls
h.requirePlanAccess(ctx, session, id), which every other plan mutation does (handler_plans.go:169/209/267/293/491), and it never checks the suppliedaccount_idsagainst the caller'sallowed_accounts. It also discards the session:requirePermission's return value is assigned to_.Failure scenario
User U has
allowed_accounts = [acct-A]andupdate:plans. Plan P belongs to acct-B, andGET /api/plans/Pcorrectly 404s for U.U sends
PUT /api/plans/P/accounts {"account_ids":["<acct-A>","<acct-B>"]}, which returns 200. P now intersects acct-A, sorequirePlanAccesspasses for U from that point on, andPOST /api/plans/P/purchases(handler_plans.go:278) creates scheduled executions against a plan that still targets acct-B.The reverse direction is equally available: U re-points their own plan onto acct-B and schedules purchases there.
Fix direction
Add
requirePlanAccess(session, id)plus a per-account_idrequireAccountAccessloop beforeSetPlanAccounts.Related
Same family as LeanerCloud/cloud-commitments-platform#29, which covers only
listPlansread scoping. Also related: #959 (dashboardcalculateCommitmentMetricsallowed-accounts intersection).