GHSA-4825-p4xm-pcf2

Suggest an improvement
Source
https://github.com/advisories/GHSA-4825-p4xm-pcf2
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-4825-p4xm-pcf2/GHSA-4825-p4xm-pcf2.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-4825-p4xm-pcf2
Aliases
Published
2026-09-22T20:40:30Z
Modified
2026-09-22T21:00:05Z
Severity
  • 7.1 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N CVSS Calculator
Summary
Spree: Broken Access Control in `PATCH /api/v3/store/carts/:id/associate` (IDOR)
Details

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.

# 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.

# 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.

# 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.

Database specific
{
    "cwe_ids":  [
        "CWE-639"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-09-22T20:40:30Z",
    "nvd_published_at":  null,
    "severity":  "HIGH"
}
References

Affected packages

RubyGems / spree_api

Package

Name
spree_api
Purl
pkg:gem/spree_api

Affected ranges

Type
ECOSYSTEM
Events
Introduced
5.4.0
Fixed
5.4.4

Affected versions

5.*
5.4.0
5.4.1
5.4.2
5.4.3

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-4825-p4xm-pcf2/GHSA-4825-p4xm-pcf2.json"

RubyGems / spree_api

Package

Name
spree_api
Purl
pkg:gem/spree_api

Affected ranges

Type
ECOSYSTEM
Events
Introduced
5.5.0
Fixed
5.5.4

Affected versions

5.*
5.5.0
5.5.1
5.5.2
5.5.3

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-4825-p4xm-pcf2/GHSA-4825-p4xm-pcf2.json"