Summary
GET /api/ri-exchange/azure-instances returns Azure reservations that are tenant-wide and are never intersected with the caller's allowed_accounts scope. Any authenticated session holding view:purchases can read every exchangeable Azure VM reservation the deployment's Azure credentials can see, including reservations belonging to subscriptions outside that user's allowed-accounts set.
Verified on origin/main @ 101f099fbb25f36e16a493bebe45e40e64c4f795.
Where
- Route:
internal/api/router.go:303 — {ExactPath: "/api/ri-exchange/azure-instances", Method: "GET", Handler: r.listExchangeableAzureRIsHandler, Auth: AuthUser}
- Handler:
internal/api/handler_ri_exchange.go:232-256 — listExchangeableAzureRIs
- Client builder:
internal/api/handler_ri_exchange.go:193-222 — buildAzureExchangeClient
- Underlying provider call:
providers/azure/services/compute/exchange.go:92-112 — ListExchangeableReservations -> armreservations.ReservationClient.NewListAllPager(nil)
The handler body, in full:
func (h *Handler) listExchangeableAzureRIs(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) {
if _, err := h.requirePermission(ctx, req, "view", "purchases"); err != nil {
return nil, err
}
subscriptionID := req.QueryStringParameters["subscription_id"]
client, err := h.buildAzureExchangeClient(ctx, subscriptionID)
...
reservations, err := client.ListExchangeableReservations(ctx)
...
return &ExchangeableAzureRIsResponse{Reservations: reservations}, nil
}
It gates on the verb (view:purchases) and nothing else. It never calls getAllowedAccounts, never calls reshapeCloudAccountInScope, and never validates the caller-supplied ?subscription_id= against the session's scope. buildAzureExchangeClient performs no scoping either: it is a plain GetCloudAccountByExternalID(ctx, "azure", subscriptionID) lookup, so registration of the subscription in CUDly is the only gate, not per-caller authorization.
Two independent leaks stack here:
- Caller-chosen subscription.
subscription_id is taken verbatim from the query string. Any registered Azure subscription GUID is accepted, whether or not it is in the caller's allowed_accounts.
- Tenant-wide result set even for an in-scope subscription.
ListExchangeableReservations builds the pager with NewListAllPager(nil), which enumerates reservations across the whole tenant rather than the subscription the client was constructed for. The code comment at handler_ri_exchange.go:219-220 acknowledges this ("the tenant-wide armreservations API"). So a scoped user who legitimately owns one Azure subscription still receives every other subscription's reservations in the same tenant.
Why the sibling handlers prove the intended contract
Every other scoped read in the same file applies allowed-accounts resolution. listExchangeableAzureRIs is the outlier:
| Handler |
Line |
Scoping |
listConvertibleRIs |
handler_ri_exchange.go:343 |
reshapeCloudAccountInScope at :351 |
getRIUtilization |
:379 |
reshapeCloudAccountInScope at :386 |
getReshapeRecommendations |
:532 |
reshapeCloudAccountInScope at :539 |
getRIExchangeHistory |
:1001 |
getAllowedAccounts at :1016 |
listExchangeableAzureRIs |
:232 |
none |
reshapeCloudAccountInScope (handler_ri_exchange.go:285) is itself a thin wrapper over h.getAllowedAccounts + auth.IsUnrestrictedAccess + auth.MatchesAccount, the same primitives used by internal/api/scoping.go (requireAccountAccess, requirePlanAccess, validatePurchaseRecommendationScope, requireExecutionAccess) and by handler_accounts.go, handler_analytics.go, handler_dashboard.go, handler_history.go, handler_ladder.go, handler_marketplace.go and handler_recommendations.go. The pattern is repo-wide; this endpoint simply misses it.
There is also existing regression coverage asserting exactly this contract for the AWS siblings (internal/api/handler_per_account_perms_test.go:1116, :1202, :1274, headed "getAllowedAccounts scope, issue #1030"), which lists /ri-exchange/instances, /ri-exchange/utilization and /ri-exchange/reshape but not /ri-exchange/azure-instances.
Impact
Cross-account (and, within a tenant, cross-subscription) information disclosure behind a read permission that is widely granted. Leaked fields include reservation IDs, SKUs, quantities, terms and provisioning state, which is commercially sensitive capacity/spend data and also a reconnaissance aid for the exchange execute path.
view:purchases is a read verb held by ordinary non-admin users, so the blast radius is "any logged-in user of a multi-tenant/multi-team deployment", not "an admin".
Relationship to #1527 (separable, not a duplicate)
#1527 covers the execute half: the exchange source reservations are not scoped to the authorized subscription. That code path does not exist on main yet; it arrives with open PR #1515.
This issue is the read/list half. It is already reachable on main today, on a route that is live in the current deployment, and it does not depend on #1515 merging or on #1527 being fixed. Fixing #1527 in the execute path leaves this listing endpoint leaking, and vice versa. Filing them separately keeps each fix independently verifiable and independently landable; they should probably share a helper once both land.
Reproduction sketch
- Register two Azure subscriptions in the same tenant,
SUB-A and SUB-B, as cloud accounts.
- Create a non-admin user whose
allowed_accounts contains only the account for SUB-A, and grant view:purchases.
- As that user:
GET /api/ri-exchange/azure-instances?subscription_id=<SUB-B-guid>.
- Expected: empty list, 403, or 404.
- Actual: reservations are returned.
- As the same user, repeat with the in-scope subscription:
GET /api/ri-exchange/azure-instances?subscription_id=<SUB-A-guid>.
- Expected: only
SUB-A reservations.
- Actual: reservations from
SUB-B are included too, because NewListAllPager is tenant-wide.
Step 4 is the important one: it fails even without a hostile query parameter, so a fix that only validates subscription_id is incomplete.
Suggested fix direction
- Capture the session:
session, err := h.requirePermission(ctx, req, "view", "purchases") (the sibling handlers already do this; the return value is currently discarded with _).
- Resolve the caller's scope via
h.getAllowedAccounts(ctx, session) and short-circuit for auth.IsUnrestrictedAccess.
- Reject or intersect the caller-supplied
subscription_id: when the session is scoped and the requested subscription's CloudAccount does not satisfy auth.MatchesAccount, return the same not-found/empty shape the siblings use rather than the reservations. When subscription_id is absent, derive the set of in-scope Azure subscriptions instead of leaving it caller-controlled.
- Filter the returned
[]azurecompute.ExchangeableReservation down to the in-scope subscriptions, since the provider call is tenant-wide by construction. The subscription is recoverable from each reservation's ARM resource ID; alternatively push a subscription filter into providers/azure/services/compute/exchange.go.
- Add regression coverage alongside
internal/api/handler_per_account_perms_test.go:1116/1202/1274, covering both the out-of-scope subscription_id case and the tenant-wide-bleed case from step 4 of the reproduction. Both tests must fail against current main.
Steps 3 and 4 are separate defects; a fix that stops at 3 still leaks.
Note for triage (not covered by this issue)
listTargetOfferings (internal/api/handler_ri_exchange.go:115) also gates only on view:purchases with no allowed-accounts resolution. It reads through the deployment's ambient AWS credentials rather than a caller-supplied account, so the exposure shape is different and it needs its own assessment. Flagging it here so it is not lost; it is deliberately out of scope for this issue.
Summary
GET /api/ri-exchange/azure-instancesreturns Azure reservations that are tenant-wide and are never intersected with the caller'sallowed_accountsscope. Any authenticated session holdingview:purchasescan read every exchangeable Azure VM reservation the deployment's Azure credentials can see, including reservations belonging to subscriptions outside that user's allowed-accounts set.Verified on
origin/main@101f099fbb25f36e16a493bebe45e40e64c4f795.Where
internal/api/router.go:303—{ExactPath: "/api/ri-exchange/azure-instances", Method: "GET", Handler: r.listExchangeableAzureRIsHandler, Auth: AuthUser}internal/api/handler_ri_exchange.go:232-256—listExchangeableAzureRIsinternal/api/handler_ri_exchange.go:193-222—buildAzureExchangeClientproviders/azure/services/compute/exchange.go:92-112—ListExchangeableReservations->armreservations.ReservationClient.NewListAllPager(nil)The handler body, in full:
It gates on the verb (
view:purchases) and nothing else. It never callsgetAllowedAccounts, never callsreshapeCloudAccountInScope, and never validates the caller-supplied?subscription_id=against the session's scope.buildAzureExchangeClientperforms no scoping either: it is a plainGetCloudAccountByExternalID(ctx, "azure", subscriptionID)lookup, so registration of the subscription in CUDly is the only gate, not per-caller authorization.Two independent leaks stack here:
subscription_idis taken verbatim from the query string. Any registered Azure subscription GUID is accepted, whether or not it is in the caller'sallowed_accounts.ListExchangeableReservationsbuilds the pager withNewListAllPager(nil), which enumerates reservations across the whole tenant rather than the subscription the client was constructed for. The code comment athandler_ri_exchange.go:219-220acknowledges this ("the tenant-wide armreservations API"). So a scoped user who legitimately owns one Azure subscription still receives every other subscription's reservations in the same tenant.Why the sibling handlers prove the intended contract
Every other scoped read in the same file applies allowed-accounts resolution.
listExchangeableAzureRIsis the outlier:listConvertibleRIshandler_ri_exchange.go:343reshapeCloudAccountInScopeat:351getRIUtilization:379reshapeCloudAccountInScopeat:386getReshapeRecommendations:532reshapeCloudAccountInScopeat:539getRIExchangeHistory:1001getAllowedAccountsat:1016listExchangeableAzureRIs:232reshapeCloudAccountInScope(handler_ri_exchange.go:285) is itself a thin wrapper overh.getAllowedAccounts+auth.IsUnrestrictedAccess+auth.MatchesAccount, the same primitives used byinternal/api/scoping.go(requireAccountAccess,requirePlanAccess,validatePurchaseRecommendationScope,requireExecutionAccess) and byhandler_accounts.go,handler_analytics.go,handler_dashboard.go,handler_history.go,handler_ladder.go,handler_marketplace.goandhandler_recommendations.go. The pattern is repo-wide; this endpoint simply misses it.There is also existing regression coverage asserting exactly this contract for the AWS siblings (
internal/api/handler_per_account_perms_test.go:1116,:1202,:1274, headed "getAllowedAccounts scope, issue #1030"), which lists/ri-exchange/instances,/ri-exchange/utilizationand/ri-exchange/reshapebut not/ri-exchange/azure-instances.Impact
Cross-account (and, within a tenant, cross-subscription) information disclosure behind a read permission that is widely granted. Leaked fields include reservation IDs, SKUs, quantities, terms and provisioning state, which is commercially sensitive capacity/spend data and also a reconnaissance aid for the exchange execute path.
view:purchasesis a read verb held by ordinary non-admin users, so the blast radius is "any logged-in user of a multi-tenant/multi-team deployment", not "an admin".Relationship to #1527 (separable, not a duplicate)
#1527 covers the execute half: the exchange source reservations are not scoped to the authorized subscription. That code path does not exist on
mainyet; it arrives with open PR #1515.This issue is the read/list half. It is already reachable on
maintoday, on a route that is live in the current deployment, and it does not depend on #1515 merging or on #1527 being fixed. Fixing #1527 in the execute path leaves this listing endpoint leaking, and vice versa. Filing them separately keeps each fix independently verifiable and independently landable; they should probably share a helper once both land.Reproduction sketch
SUB-AandSUB-B, as cloud accounts.allowed_accountscontains only the account forSUB-A, and grantview:purchases.GET /api/ri-exchange/azure-instances?subscription_id=<SUB-B-guid>.GET /api/ri-exchange/azure-instances?subscription_id=<SUB-A-guid>.SUB-Areservations.SUB-Bare included too, becauseNewListAllPageris tenant-wide.Step 4 is the important one: it fails even without a hostile query parameter, so a fix that only validates
subscription_idis incomplete.Suggested fix direction
session, err := h.requirePermission(ctx, req, "view", "purchases")(the sibling handlers already do this; the return value is currently discarded with_).h.getAllowedAccounts(ctx, session)and short-circuit forauth.IsUnrestrictedAccess.subscription_id: when the session is scoped and the requested subscription'sCloudAccountdoes not satisfyauth.MatchesAccount, return the same not-found/empty shape the siblings use rather than the reservations. Whensubscription_idis absent, derive the set of in-scope Azure subscriptions instead of leaving it caller-controlled.[]azurecompute.ExchangeableReservationdown to the in-scope subscriptions, since the provider call is tenant-wide by construction. The subscription is recoverable from each reservation's ARM resource ID; alternatively push a subscription filter intoproviders/azure/services/compute/exchange.go.internal/api/handler_per_account_perms_test.go:1116/1202/1274, covering both the out-of-scopesubscription_idcase and the tenant-wide-bleed case from step 4 of the reproduction. Both tests must fail against currentmain.Steps 3 and 4 are separate defects; a fix that stops at 3 still leaks.
Note for triage (not covered by this issue)
listTargetOfferings(internal/api/handler_ri_exchange.go:115) also gates only onview:purchaseswith no allowed-accounts resolution. It reads through the deployment's ambient AWS credentials rather than a caller-supplied account, so the exposure shape is different and it needs its own assessment. Flagging it here so it is not lost; it is deliberately out of scope for this issue.