Skip to content

sec(api): upsertLadderConfig writes commitment-laddering config for ANY cloud account #1539

Description

@cristim

Reviewed commit: be11bdcb5 (origin/main), from the 2026-07-28 full-repo review.

Severity: CRITICAL. Class: the read side of a feature is scoped by allowed_accounts, the write side of the same feature is not.

Where

  • internal/api/handler_ladder.go:77-122 (write path, upsertLadderConfig)
  • internal/api/handler_ladder.go:27-66 (read path, which does call filterLadderConfigsByAllowedAccounts)
  • internal/api/handler_ladder.go:129 (validateLadderAccountProvider)

What

PUT /api/ladder/configs gates only on requirePermission(ctx, req, "update", "config"). cfg.CloudAccountID comes straight from the request body; validateLadderAccountProvider only checks that the account exists and that its provider matches. Neither getAllowedAccounts / MatchesAccount nor requirePermissionConstraints is called before h.config.UpsertLadderConfig at :116.

The GET handler's own doc comment (:20-26) records that migration 000088 granted view:config to the Standard User and Read-Only User groups precisely so that scoped users reach this endpoint. That reasoning was applied to the read and not to the write.

Failure scenario

Standard User U has allowed_accounts = [acct-A] and holds update:config. U sends:

PUT /api/ladder/configs
{"cloud_account_id":"<acct-B-uuid>","provider":"aws","enabled":true,
 "mode":"auto_approve","cadence":"daily","max_hourly_commit_per_run":null, ...}

which returns 200. LadderConfigDB.MaxHourlyCommitPerRun is *float64 where nil means no cap (internal/config/types.go:1075-1077), and Mode accepts auto_approve, so the ladder engine now auto-purchases uncapped commitments on account B, which U has no entitlement to and cannot even see. The GET filters row B back out, so the change is invisible to U afterwards and to any other scoped operator.

Fix direction

Call h.requireAccountAccess(ctx, session, cfg.CloudAccountID) before the upsert, and add a requirePermissionConstraints("update", "config", {AccountIDs, Providers}) set, mirroring executeExchange's pattern.

Related

Same family as the other write-side scoping gaps filed from this review. Not covered by LeanerCloud/cloud-commitments-platform#70 / LeanerCloud/cloud-commitments-go#27 / LeanerCloud/cloud-commitments-go#36, which are ladder feature/algorithm issues.

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