You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
sec(api/azure): listExchangeableAzureRIs returns the tenant-wide reservation listing unscoped #1656
listExchangeableAzureRIs (internal/api/handler_ri_exchange.go, GET /api/ri-exchange/azure-instances) returns the tenant-wide Azure reservation listing behind view:purchases alone. It calls neither requireAzureSubscriptionScope nor any per-row filter against the session's allowed_accounts.
Callers therefore receive reservation IDs, billing scope IDs, regions, SKUs, quantities, terms, expiry dates and display names for every reservation the deployment's Azure credential can read - including subscriptions the caller is not scoped to. ListExchangeableReservations is tenant-wide by design ("the Azure Capacity exchange API operates on reservation order IDs which span subscriptions"), so the breadth is inherent to the call, not incidental.
Why it matters
It undercuts the anti-enumeration posture the rest of this resource deliberately builds. Both POST siblings on the same resource:
enforce requireAzureSubscriptionScope, returning errNotFound (404, not 403) so a scoped caller cannot probe which subscriptions exist; and
The GET on the same resource then hands the whole listing over unfiltered, which is a shorter path to the same information those gates exist to withhold.
Mitigating factor
Azure RBAC bounds ListAll to what the deployment's service principal can read, so the exposure is the deployment's managed estate - not the customer's entire tenant. That caps the blast radius but does not make the listing correctly scoped: a user restricted to one CloudAccount still sees every other managed subscription's reservations.
Not a regression
Verified byte-identical at the merge base of PR #1515 - this is pre-existing, surfaced during adversarial review of that PR rather than introduced by it.
Fix direction
Apply requireAzureSubscriptionScope when ?subscription_id= is supplied, and filter the returned rows against the session's allowed_accounts (matching each row's BillingScopeID to a registered CloudAccount) when it is not. Unrestricted/admin sessions short-circuit as they do elsewhere. Preserve the graceful empty-state behaviour for unregistered subscriptions.
Related: #1644 (execute:ri-exchange missing from adminCarvedOuts).
Summary
listExchangeableAzureRIs(internal/api/handler_ri_exchange.go,GET /api/ri-exchange/azure-instances) returns the tenant-wide Azure reservation listing behindview:purchasesalone. It calls neitherrequireAzureSubscriptionScopenor any per-row filter against the session'sallowed_accounts.Callers therefore receive reservation IDs, billing scope IDs, regions, SKUs, quantities, terms, expiry dates and display names for every reservation the deployment's Azure credential can read - including subscriptions the caller is not scoped to.
ListExchangeableReservationsis tenant-wide by design ("the Azure Capacity exchange API operates on reservation order IDs which span subscriptions"), so the breadth is inherent to the call, not incidental.Why it matters
It undercuts the anti-enumeration posture the rest of this resource deliberately builds. Both POST siblings on the same resource:
requireAzureSubscriptionScope, returningerrNotFound(404, not 403) so a scoped caller cannot probe which subscriptions exist; andrequireAzureSourceOwnership's denials byte-identical, so "does not exist" and "belongs to someone else" cannot be told apart (PR feat(azure/ri-exchange): find compatible offerings and execute exchange (closes #596) #1515 additionally reordered the execute path's gates so the pipeline preserves that property, not just the gate).The GET on the same resource then hands the whole listing over unfiltered, which is a shorter path to the same information those gates exist to withhold.
Mitigating factor
Azure RBAC bounds
ListAllto what the deployment's service principal can read, so the exposure is the deployment's managed estate - not the customer's entire tenant. That caps the blast radius but does not make the listing correctly scoped: a user restricted to one CloudAccount still sees every other managed subscription's reservations.Not a regression
Verified byte-identical at the merge base of PR #1515 - this is pre-existing, surfaced during adversarial review of that PR rather than introduced by it.
Fix direction
Apply
requireAzureSubscriptionScopewhen?subscription_id=is supplied, and filter the returned rows against the session'sallowed_accounts(matching each row'sBillingScopeIDto a registered CloudAccount) when it is not. Unrestricted/admin sessions short-circuit as they do elsewhere. Preserve the graceful empty-state behaviour for unregistered subscriptions.Related: #1644 (
execute:ri-exchangemissing fromadminCarvedOuts).