GHSA-4825-P4XM-PCF2
Vulnerability from github – Published: 2026-09-22 20:40 – Updated: 2026-09-22 20:40Summary
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.
- Authenticate:
POST /api/v3/store/auth/login→ attacker JWT. - Derive candidate IDs offline:
"cart_" + Sqids.encode([n])for a range ofn. - For each candidate:
PATCH /api/v3/store/carts/<id>/associatewith the attacker JWT. A hit returns200with the victim'sbilling_address/shipping_address; non-guest or missing carts return404/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.
{
"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)"
}
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
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.