Skip to content

sec(api): listExchangeableAzureRIs returns tenant-wide reservations without allowed-accounts scoping #1532

Description

@cristim

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:

  1. 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.
  2. 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

  1. Register two Azure subscriptions in the same tenant, SUB-A and SUB-B, as cloud accounts.
  2. Create a non-admin user whose allowed_accounts contains only the account for SUB-A, and grant view:purchases.
  3. As that user: GET /api/ri-exchange/azure-instances?subscription_id=<SUB-B-guid>.
    • Expected: empty list, 403, or 404.
    • Actual: reservations are returned.
  4. 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

  1. Capture the session: session, err := h.requirePermission(ctx, req, "view", "purchases") (the sibling handlers already do this; the return value is currently discarded with _).
  2. Resolve the caller's scope via h.getAllowedAccounts(ctx, session) and short-circuit for auth.IsUnrestrictedAccess.
  3. 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.
  4. 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.
  5. 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.

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