Skip to content

sec(api/azure): listExchangeableAzureRIs returns the tenant-wide reservation listing unscoped #1656

Description

@cristim

Summary

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
  • keep requireAzureSourceOwnership'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 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).

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions