/

CVE-2026-71519

CVE-2026-71519: The Gate That Checked the Request, Not the Record


CVE-2026-71519

CVSS:3.1/CVSS:4.0

Hemanshu Baviskar

CONTENTS

0% READ
Xen: The first copy failed. The second skipped the check.

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 GET and PUT on customers, 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. One PUT is 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.

// classes/webservice/WebserviceRequest.php  (read sink, getFilteredObjectDetails, ~:1322)
// A record-level shop check exists here (~:1339), but only runs when:
if (Shop::getAssoTable($this->resourceConfiguration['retrieveData']['table']) !== false) {
    // ... compare the loaded record's shop against the allowed set ...
}
// classes/webservice/WebserviceRequest.php  (read sink, getFilteredObjectDetails, ~:1322)
// A record-level shop check exists here (~:1339), but only runs when:
if (Shop::getAssoTable($this->resourceConfiguration['retrieveData']['table']) !== false) {
    // ... compare the loaded record's shop against the allowed set ...
}
// classes/webservice/WebserviceRequest.php  (read sink, getFilteredObjectDetails, ~:1322)
// A record-level shop check exists here (~:1339), but only runs when:
if (Shop::getAssoTable($this->resourceConfiguration['retrieveData']['table']) !== false) {
    // ... compare the loaded record's shop against the allowed set ...
}

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

GET /api/customers/{victim id}

id_shop = the victim's shop

403, refused

GET /api/customers/{victim id}

key's own shop id, or all, or omitted

200, full record incl. hash and secure_key

PUT /api/customers (body names the victim's id)

id_shop = the victim's shop

403, row unchanged

PUT /api/customers (body names the victim's id)

key's own shop id, or all, or omitted

200, email and password hash overwritten

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=0 on 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 (and POST/PATCH/DELETE) on customers and 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 PUT nulls secure_key and last_passwd_gen as a side effect)

Related

CVE-2026-71531, the sibling id_shop disjunction bug in the same shopHasRight() gate

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.

Frequently Asked
Questions
What is CVE-2026-71519?
Does this affect every PrestaShop installation?
Does an attacker need an account?
Why is naming the victim's shop refused but the attack still works?
What can an attacker actually do with CVE-2026-71519?
How is CVE-2026-71519 different from CVE-2026-71531?
How do I fix CVE-2026-71519?
Find out what's already
exploitable in your codebase.