GHSA-4825-p4xm-pcf2
Spree: Broken Access Control in `PATCH /api/v3/store/carts/:id/associate` (IDOR)
Quick fix
GHSA-4825-p4xm-pcf2 — spree_api: upgrade to the fixed version with the command below.
bundle update spree_apiDetails
## Summary
The Store API v3 endpoint `PATCH /api/v3/store/carts/:id/associate` binds a guest cart to the authenticated caller without verifying possession of that cart. It locates the cart by prefixed ID only — `current_store.carts.where(user: [nil, current_user]).find_by_prefix_id!(params[:id])` — and omits the `authorize!(:update, @cart, cart_token)` check that every other action in the controller performs via `CartResolvable`. Because prefixed IDs are a reversible Sqids encoding of the auto-increment primary key (obfuscation, not a token), an authenticated customer can name arbitrary guest cart IDs, take them over, and read the checkout addresses stored on them. This is broken access control / IDOR, reachable by any low-privilege registered user.
## Severity
Requires an authenticated store account and depends on target guest carts already carrying an address and not yet being associated, on a store not running in `login_required` mode. Confidentiality impact is the driver (guest checkout PII); integrity impact is limited and recoverable (cart reassignment + email overwrite on an in-progress cart). Not Critical: the action is gated behind authentication (`PR:L`, not `PR:N`) and constrained by cart state, so it is not anonymously exploitable.
## Details
**Root cause:** `associate` skips the cart-possession check its sibling actions enforce and trusts a guessable identifier as the sole locator.
**Entry point.** `Spree::Api::V3::Store::CartsController#associate` (`carts_controller.rb:88-96`), guarded only by `prepend_before_action :require_authentication!, only: [:index, :associate]`. That requires the *caller* be authenticated; it does not tie the request to a specific guest cart.
```ruby # spree/api/app/controllers/spree/api/v3/store/carts_controller.rb:88-96 # PATCH /api/v3/store/carts/:id/associate def associate @cart = find_cart_for_association
result = Spree.cart_associate_service.call(guest_order: @cart, user: current_user, guest_only: true)
if result.success? render_cart else render_service_error(result.error.to_s) end end ```
**Missing check.** `find_cart_for_association` (`carts_controller.rb:177-178`) resolves any guest cart (`user IS NULL`) in the store by ID with no `authorize!(..., cart_token)`. Contrast `CartResolvable#find_cart!`, which binds the token.
```ruby # spree/api/app/controllers/spree/api/v3/store/carts_controller.rb:177-178 def find_cart_for_association current_store.carts.where(user: [nil, current_user]).find_by_prefix_id!(params[:id]) end ```
**Identifier.** `prefixed_id` is `"cart_" + SQIDS.encode([id])` with `SQIDS = Sqids.new(min_length: 10)` (`prefixed_id.rb:17,56`) — default alphabet, no salt, no blocklist. Sqids is non-cryptographic and reversible, so candidate IDs are derivable offline from sequential primary keys.
```ruby # spree/core/app/models/concerns/spree/prefixed_id.rb:17-56 SQIDS = Sqids.new(min_length: 10)
def prefixed_id return nil unless id.present?
"#{self.class._prefix_id_prefix}_#{Spree::PrefixedId::SQIDS.encode([id])}" end ```
**Data flow.** `Spree.cart_associate_service.call(guest_order: @cart, user: current_user, guest_only: true)` reassigns the owner and overwrites email, preserving existing addresses via `bill_address ||= / ship_address ||=`. `render_cart` then serializes `billing_address`/`shipping_address` (first_name, last_name, address1, address2, city, postal_code, phone, company) back to the caller.
## PoC
**Preconditions:** attacker holds an ordinary store account (self-service registration) and the store's publishable key (a front-end credential, present in any headless storefront bundle); one or more guest carts carry checkout addresses; store is not in `login_required` mode.
1. Authenticate: `POST /api/v3/store/auth/login` → attacker JWT. 2. Derive candidate IDs offline: `"cart_" + Sqids.encode([n])` for a range of `n`. 3. For each candidate: `PATCH /api/v3/store/carts/<id>/associate` with the attacker JWT. A hit returns `200` with the victim's `billing_address`/`shipping_address`; non-guest or missing carts return `404`/`422`.
## Impact
**Confidentiality:** an authenticated attacker can enumerate guest cart IDs and read checkout PII (name, street, postal code, phone) on carts they don't own. **Integrity:** limited and recoverable — each call reassigns the guest cart and overwrites its email, disrupting the original guest's in-progress cart. Requires a registered account, so not anonymously exploitable.
## Remediation
Update to Spree 5.4.4 or 5.5.4. Your storefront, based on https://github.com/spree/storefront, doesn't need any updates because it has always sent a cart token when associating carts; this is a backend issue.
Are you affected?
Enter the version of the package you're using.
Affected packages
References
- https://github.com/spree/spree/security/advisories/GHSA-4825-p4xm-pcf2[WEB]
- https://github.com/spree/spree/pull/14314[WEB]
- https://github.com/spree/spree/commit/8834230a1f47bb5988f23f45dbd162776cf592bd[WEB]
- https://github.com/spree/spree/commit/af0d1a2d582a60d179de65b7d3ea024cb26426a8[WEB]
- https://github.com/spree/spree[PACKAGE]
- https://github.com/spree/spree/releases/tag/v5.4.4[WEB]
- https://github.com/spree/spree/releases/tag/v5.5.4[WEB]