Severity: High (CVSS 8.1 / 8.6) · PrestaShop Webservice API · CodeAnt AI Security
PrestaShop lets one installation run many storefronts from a single database, and it ships a legacy REST webservice at /api/. On that webservice the only thing separating one merchant's data from another's is a per-key shop association. CVE-2026-71519 is the discovery that the customer endpoint authorizes the shop named in the request and then never checks the shop that actually owns the record it loads. A key bound to one shop reads, rewrites, and takes over customer accounts in every other shop, and the only request it is refused is the honest one.
TL;DR
The defect. The webservice shop gate,
WebserviceRequest::shopHasRight(), answers one question: is this key associated with the shop in the URL? It never asks whether the key is associated with the shop that owns the customer record it goes on to load. On the customer resource that second check does not exist.What it grants. A key holding only
GETandPUToncustomers, bound to a single shop, reads any other shop's full customer record (email,secure_key, bcrypt password hash) and overwrites that record's email and password. OnePUTis a cross-tenant account takeover.The irony. Naming the victim's shop id is the one value the platform rejects with
403. The working exploit carries the attacker key's own, legitimately held shop id. There is no tampered parameter to find.Reach. Stock core code, not a plugin. Requires the legacy webservice and multistore, neither on by default. Where they are on, the tenant boundary the product sells is the thing that fails.
Status. Reproduced on 9.1.4. Fixed in PrestaShop 8.2.9 and 9.2.1.
At a Glance
CVE | CVE-2026-71519 |
Product | PrestaShop (open-source e-commerce), multistore + legacy webservice |
Class | CWE-639, Authorization Bypass Through User-Controlled Key |
Root cause | Shop gate authorizes the requested shop, not the loaded record; customer resource has no record-level shop check |
Impact | Cross-tenant customer disclosure and account takeover (read + modify) |
Affected | PrestaShop before 8.2.9, and 9.0.0 before 9.2.1 |
Fixed | 8.2.9, 9.2.1 |
Severity | 8.1 CVSS 3.1 / 8.6 CVSS 4.0 (High) |
Disclosure | Coordinated; fixed upstream |
Credit | CodeAnt AI Security |

A Boundary Made of One Row
PrestaShop multistore puts several storefronts in one database and one back office. The legacy webservice authenticates with a 32-character API key sent as the HTTP Basic username with an empty password. Each key carries two kinds of grant: per-resource, per-method permissions (customers GET, customers PUT, and so on) in webservice_permission, and, under multistore, an association to a set of shops in webservice_account_shop.
That shop association is the entire tenant boundary. A brand group that gives its fulfilment partner a key scoped to one storefront is trusting that one row in webservice_account_shop to keep the partner out of the other storefronts' customer lists. The whole finding is about what happens to that trust when the record being touched belongs to a shop the key never named.
The Gate Asks the Wrong Question
Every /api/ request runs through WebserviceRequest::shopHasRight(). It is the shop gate, and in isolation it works. It compares the shop context built from the request against the key's webservice_account_shop rows, and when they do not intersect it stops the request cold with 403 No permission for this key on this shop, error 132 at WebserviceRequest.php:855.
The problem is what the gate is comparing. Its input is self::$shopIDs, the shop set resolved from the URL. It is a statement about the request. Once the gate passes, the read path loads the target customer by primary key and the write path updates it by primary key, and neither consults the shop that owns that row.
The check at :1339 is real. It is also dead for this resource. Shop::getAssoTable('customer') returns false, because customer is not in the $asso_tables list that Shop.php builds (classes/shop/Shop.php, around :143 and :588). PrestaShop stores a customer's shop in the customer row's own id_shop column rather than in a separate association table, so the resource is invisible to the one function that guards ownership. The guard that would have caught this is standing at the door, holding a list that does not have the customer on it.
The write path does not even have a dead guard. WebserviceRequest::saveEntityFromXml() builds its target object from the <id> in the submitted XML and calls ObjectModel::update(), whose WHERE clause is the primary key alone (ObjectModel.php:671, :716). No shop appears anywhere between the gate and the UPDATE.
Why One PUT Is a Takeover
Reading another shop's customer is already a data-protection breach: GET /api/customers/{id} under display=full returns the full field set, including secure_key and the stored bcrypt password hash. But the customer object has a writable passwd field whose webservice setter hashes submitted plaintext. So a single PUT naming a foreign customer id does three things at once: it changes the account's email to one the attacker controls, it replaces the stored hash with the hash of a password the attacker chose, and it does so on a record whose id_shop still points at the victim's shop the whole time.
In the lab, after the write, password_verify() returns true for the attacker-chosen plaintext against the newly stored hash. The original owner is locked out and the attacker can sign in. That is account takeover, not disclosure, and it is why the integrity impact is scored High.
The One Request That Gets Refused
The sharpest way to see the defect is the negative control, because it inverts the usual shape of an access-control bug. The exploit does not smuggle a forbidden value past a filter. Every value in the working request is one the key legitimately holds: the resource is customers, the methods are GET and PUT, and the shop id is the attacker's own.
Request | Shop selector | Result |
|---|---|---|
|
|
|
| key's own shop id, or |
|
|
|
|
| key's own shop id, or |
|
The only request the platform refuses is the one that tells the truth about which shop is being touched. Say nothing about the shop, or name your own, and the gate waves you through to a record it never rechecks.
Reach and Real-World Exposure
This is stock core code, reachable with no back-office session and no employee account, only a webservice key. It is not a default configuration. Both the legacy webservice (PS_WEBSERVICE) and multistore (PS_MULTISHOP_FEATURE_ACTIVE) are off on a fresh install, and the fixture needs two shops in separate groups with customer sharing off. We did not measure how many live installations run that combination, and we do not claim internet-wide exposure.
What we do claim is that where this configuration exists, it exists for exactly the operators who are most exposed by it: brand groups, franchises, and agencies that run many storefronts from one PrestaShop and hand scoped webservice keys to third-party integrators. For them the per-shop key association is the separation mechanism, and a single long-lived, non-interactive credential in any one integrator's hands reads and takes over customers across every shop on the installation.
Severity
Metric | Value |
|---|---|
CVSS 3.1 | 8.1, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
CVSS 4.0 | 8.6, CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
Network reachable, low complexity, and the only privilege required is a minimally scoped webservice key (PR:L). Confidentiality is High (full customer record including password hash) and integrity is High (email and password overwrite, account takeover). It is not a 10.0: scope is unchanged, the attacker already holds a credential, and there is no availability impact. This is a high-severity authorization bug, not a pre-auth catastrophe, and scoring it honestly matters more than making the number larger.
Detection and Fix
Upgrade. PrestaShop fixed this in 8.2.9 and 9.2.1. That is the durable remedy.
Check the record, not the request. The code fix is to compare the loaded record's id_shop against the key's webservice_account_shop rows after the object is constructed, on both the read path and the write path, and to refuse a foreign row with the existing 403/404. The record-level check must stop depending on Shop::getAssoTable(), which answers a storage question (is there an association table?) rather than an ownership one (does this key own this row?). One resolver should cover both shapes: an association table where one exists, and the record's own id_shop column where it does not.
If you cannot upgrade immediately, these reduce exposure but each has a cost, and they are reasoning from the code path rather than a tested control:
Set
PS_WEBSERVICE=0on any installation that does not actively need the legacy webservice. This disables the whole attack surface and every integration that depends on it.Where the webservice is needed but customer writes are not, revoke
PUT(andPOST/PATCH/DELETE) oncustomersand rotate keys. This stops the takeover but not the disclosure.A proxy or WAF rule cannot help here the way it can for the sibling id_shop bug, because there is no tampered value to strip. The request carries the attacker's own valid shop id.
Credit and Scope
Finding | CodeAnt AI Security |
What we claim | Record-level shop ownership is not checked on the customer read and write paths; demonstrated cross-tenant disclosure and account takeover, reproduced to a benign marker on 9.1.4 |
What we do not claim | A measurement of real-world prevalence; runtime verification on the 8.2.x line (source-matched only); that the write is surgical (the demonstrated |
Related | CVE-2026-71531, the sibling id_shop disjunction bug in the same |
Coordination | Reported to PrestaShop; fixed in 8.2.9 and 9.2.1 |
How do I fix it?
Upgrade to PrestaShop 8.2.9 or 9.2.1. If you cannot upgrade immediately, disable the legacy webservice where it is not needed, or revoke write methods on the customers resource and rotate keys, and plan the upgrade. A WAF rule is not a reliable mitigation here because the exploit carries the attacker's own valid shop id rather than a tampered value.

More from CodeAnt
CVE-2026-77226: The Empty Admin Group That Reopened Camunda's Setup
CVE-2026-109432: The Validator That Had Nothing to Validate, a Camunda RCE
CVE-2026-71531: The Selector That Outranked the Key, a PrestaShop Webservice Bypass
Find out what is already exploitable in your codebase. Book a demo or start a free trial at codeant.ai.






