GHSA-4825-P4XM-PCF2

Vulnerability from github – Published: 2026-09-22 20:40 – Updated: 2026-09-22 20:40
VLAI
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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "RubyGems",
        "name": "spree_api"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "5.4.0"
            },
            {
              "fixed": "5.4.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "RubyGems",
        "name": "spree_api"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "5.5.0"
            },
            {
              "fixed": "5.5.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-94462"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-639"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-22T20:40:30Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\nThe 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 \u2014 `current_store.carts.where(user: [nil, current_user]).find_by_prefix_id!(params[:id])` \u2014 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.\n\n## Severity\n\nRequires 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.\n\n## Details\n\n**Root cause:** `associate` skips the cart-possession check its sibling actions enforce and trusts a guessable identifier as the sole locator.\n\n**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.\n\n```ruby\n# spree/api/app/controllers/spree/api/v3/store/carts_controller.rb:88-96\n# PATCH /api/v3/store/carts/:id/associate\ndef associate\n  @cart = find_cart_for_association\n\n  result = Spree.cart_associate_service.call(guest_order: @cart, user: current_user, guest_only: true)\n\n  if result.success?\n    render_cart\n  else\n    render_service_error(result.error.to_s)\n  end\nend\n```\n\n**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.\n\n```ruby\n# spree/api/app/controllers/spree/api/v3/store/carts_controller.rb:177-178\ndef find_cart_for_association\n  current_store.carts.where(user: [nil, current_user]).find_by_prefix_id!(params[:id])\nend\n```\n\n**Identifier.** `prefixed_id` is `\"cart_\" + SQIDS.encode([id])` with `SQIDS = Sqids.new(min_length: 10)` (`prefixed_id.rb:17,56`) \u2014 default alphabet, no salt, no blocklist. Sqids is non-cryptographic and reversible, so candidate IDs are derivable offline from sequential primary keys.\n\n```ruby\n# spree/core/app/models/concerns/spree/prefixed_id.rb:17-56\nSQIDS = Sqids.new(min_length: 10)\n\ndef prefixed_id\n  return nil unless id.present?\n\n  \"#{self.class._prefix_id_prefix}_#{Spree::PrefixedId::SQIDS.encode([id])}\"\nend\n```\n\n**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.\n\n## PoC\n\n**Preconditions:** attacker holds an ordinary store account (self-service registration) and the store\u0027s 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.\n\n1. Authenticate: `POST /api/v3/store/auth/login` \u2192 attacker JWT.\n2. Derive candidate IDs offline: `\"cart_\" + Sqids.encode([n])` for a range of `n`.\n3. For each candidate: `PATCH /api/v3/store/carts/\u003cid\u003e/associate` with the attacker JWT. A hit returns `200` with the victim\u0027s `billing_address`/`shipping_address`; non-guest or missing carts return `404`/`422`.\n\n## Impact\n\n**Confidentiality:** an authenticated attacker can enumerate guest cart IDs and read checkout PII (name, street, postal code, phone) on carts they don\u0027t own. **Integrity:** limited and recoverable \u2014 each call reassigns the guest cart and overwrites its email, disrupting the original guest\u0027s in-progress cart. Requires a registered account, so not anonymously exploitable.\n\n## Remediation\n\nUpdate to Spree 5.4.4 or 5.5.4. Your storefront, based on https://github.com/spree/storefront, doesn\u0027t need any updates because it has always sent a cart token when associating carts; this is a backend issue.",
  "id": "GHSA-4825-p4xm-pcf2",
  "modified": "2026-09-22T20:40:30Z",
  "published": "2026-09-22T20:40:30Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/spree/spree/security/advisories/GHSA-4825-p4xm-pcf2"
    },
    {
      "type": "WEB",
      "url": "https://github.com/spree/spree/pull/14314"
    },
    {
      "type": "WEB",
      "url": "https://github.com/spree/spree/commit/8834230a1f47bb5988f23f45dbd162776cf592bd"
    },
    {
      "type": "WEB",
      "url": "https://github.com/spree/spree/commit/af0d1a2d582a60d179de65b7d3ea024cb26426a8"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/spree/spree"
    },
    {
      "type": "WEB",
      "url": "https://github.com/spree/spree/releases/tag/v5.4.4"
    },
    {
      "type": "WEB",
      "url": "https://github.com/spree/spree/releases/tag/v5.5.4"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Spree: Broken Access Control in `PATCH /api/v3/store/carts/:id/associate` (IDOR)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…