/

CVE-2026-71531

CVE-2026-71531: The Selector That Outranked the Key


CVE-2026-71531

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's legacy webservice refuses a single-shop API key the moment it names a shop it does not own. CVE-2026-71531 is the discovery that the same key gets everything it was just refused by naming no shop at all. The word all resolves to every storefront on the installation, the shop gate then checks that set with an OR instead of an AND, and a key associated with one shop passes a request that spans all of them.

TL;DR

  • The defect. On id_shop=all, PrestaShop expands the request's shop set to every shop in the database without reference to the calling key. The gate WebserviceRequest::shopHasRight() then evaluates that set as a disjunction: it passes if the key is associated with any one requested shop, not all of them. A one-shop key therefore satisfies a request covering every shop.

  • The tell. GET /api/customers?id_shop={another shop} is refused 403. GET /api/customers?id_shop=all returns that same shop's customers. The boundary is enforced wherever a shop is named and bypassed only when none is.

  • A second door. id_shop_group=N reaches the same state by a different code path, so a fix confined to the all branch leaves the group route open.

  • Impact. Cross-tenant disclosure of full customer records (email, secure_key, bcrypt password hash) across the whole installation. Because the same gate governs every method, the CVE scores the write and delete reach as well.

  • Status. Reproduced on 8.2.7 and 9.1.4. Fixed in PrestaShop 8.2.9 and 9.2.1.

At a Glance

CVE

CVE-2026-71531

Product

PrestaShop (open-source e-commerce), multistore + legacy webservice

Class

CWE-863, Incorrect Authorization

Root cause

id_shop=all / id_shop_group=N expand the shop context without checking the key's associations; shopHasRight() evaluates the expanded set as a disjunction

Impact

Cross-tenant access to data the key is not authorized for, including full customer records

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

One Row Against Every Tenant

A PrestaShop multistore installation runs many storefronts from one database and exposes a legacy webservice at /api/, authenticated by a 32-character API key sent as the HTTP Basic username with an empty password. Under multistore, each key is associated with a set of shops in webservice_account_shop. That association is the separator between a per-shop integration and every other business on the installation. An agency given a key for one brand's storefront is relying on it to keep that key out of the others' data.

A Gate That Resolves Before It Authorizes

WebserviceRequest::webserviceChecks() does two things in order: it resolves which shops the request covers, then it authorizes them. The order is the problem.

Resolution runs first. When the request carries id_shop=all, shopExists() sets the context to CONTEXT_ALL and assigns self::$shopIDs every shop on the installation, with no reference to the calling key (classes/webservice/WebserviceRequest.php, the sink around :916 in the 8.2.7 tree). The key's own associations are not consulted at this step. The set is built from the URL, for the URL.

Authorization runs second, over that already-expanded set:

// classes/webservice/WebserviceRequest.php  shopHasRight()  (~:879, 8.2.7)
// Passes when the key is associated with ANY ONE shop in self::$shopIDs,
// rather than requiring association with every shop in the resolved set.
foreach (self::$shopIDs as $id_shop) {
    // if the key is associated with THIS shop -> allow the whole request
}
// classes/webservice/WebserviceRequest.php  shopHasRight()  (~:879, 8.2.7)
// Passes when the key is associated with ANY ONE shop in self::$shopIDs,
// rather than requiring association with every shop in the resolved set.
foreach (self::$shopIDs as $id_shop) {
    // if the key is associated with THIS shop -> allow the whole request
}
// classes/webservice/WebserviceRequest.php  shopHasRight()  (~:879, 8.2.7)
// Passes when the key is associated with ANY ONE shop in self::$shopIDs,
// rather than requiring association with every shop in the resolved set.
foreach (self::$shopIDs as $id_shop) {
    // if the key is associated with THIS shop -> allow the whole request
}

That loop is a disjunction. It was written to answer "is this key allowed on the shop it asked for," which is correct when the set is a single shop. Applied to a set that now contains every shop, it answers "is this key allowed on at least one of all the shops," which every valid key trivially satisfies. The key's own shop is in the set, so the OR short-circuits to allow, and the request proceeds against all of them.

shopExists() widens two things at once: self::$shopIDs, which the gate reads, and the shop context the customers listing filters on. So once the gate is past, the listing itself returns rows from every shop. The expansion that fooled the gate also opened the data.

The Two Doors Are Not the Same Door

There is a second route to the same state, and it matters for anyone writing the patch. id_shop_group=N is resolved by groupShopExists(), which runs before shopExists(). When a group is named, shopExists() then early-returns (:903), leaving the group's shop expansion in place. The disjunction in shopHasRight() does the rest.

This means the vulnerability has two entry points that converge on one flawed gate. A fix that special-cases the string all in the id_shop branch, and stops there, still leaves id_shop_group=N expanding the set through a different function. The durable fix has to live where the set is authorized, not where each selector is parsed.

What the Lab Showed

The finding was reproduced on both supported release lines, 8.2.7 and 9.1.4, with one webservice key associated with a single shop, holding GET and PUT on customers and nothing else. No employee session, no back-office access.

GET /api/customers?display=full&id_shop={other shop}        -> 403, error 132 (refused)
GET /api/customers?display=full&id_shop={own shop}          -> 200, foreign customer absent
GET /api/customers?display=full&id_shop=all                 -> 200, foreign customer present
GET /api/customers/{victim id}?id_shop=all                  -> 200, full foreign record
GET /api/customers?display=full&id_shop_group={other group} -> 200, foreign customer present
GET /api/customers?display=full&id_shop={other shop}        -> 403, error 132 (refused)
GET /api/customers?display=full&id_shop={own shop}          -> 200, foreign customer absent
GET /api/customers?display=full&id_shop=all                 -> 200, foreign customer present
GET /api/customers/{victim id}?id_shop=all                  -> 200, full foreign record
GET /api/customers?display=full&id_shop_group={other group} -> 200, foreign customer present
GET /api/customers?display=full&id_shop={other shop}        -> 403, error 132 (refused)
GET /api/customers?display=full&id_shop={own shop}          -> 200, foreign customer absent
GET /api/customers?display=full&id_shop=all                 -> 200, foreign customer present
GET /api/customers/{victim id}?id_shop=all                  -> 200, full foreign record
GET /api/customers?display=full&id_shop_group={other group} -> 200, foreign customer present

The own-shop listing returned three records and correctly did not include the second shop's customer. Naming the second shop directly was refused 403. The same key, saying id_shop=all or naming the other shop's group, returned those same three records plus the foreign customer, and the single-record read under id_shop=all returned that customer's whole row, carrying an id_shop pointing at the shop the key had just been refused. The gate's 403 and the 200 that bypasses it differ by nothing except whether a shop was named.

What Comes Back

Under display=full, a disclosed customer record is the complete object: first and last name, email, birthday, siret, note, secure_key, and the stored password hash. The webservice keys that reach this are non-interactive and effectively permanent; merchants issue them to ERP connectors, marketplaces, and agencies and rarely rotate them. A single over-scoped key, or one leaked from any integrator holding it, reads every storefront's customer base rather than the one it was granted.

Our lab work demonstrated the disclosure (the read path) to a benign marker. The published CVE scores integrity as well, because the defect is in shopHasRight(), the single gate that governs every method the key holds, so the same disjunction that lets a read span all shops lets a write or delete span them too. The customer write path is demonstrated end to end in the sibling finding, CVE-2026-71519.

Reach and Real-World Exposure

Stock core code, no plugin, no employee account. It is not a default configuration: PS_MULTISHOP_FEATURE_ACTIVE and PS_WEBSERVICE are both off on a fresh install, and the control depends on customer sharing being off between the two shops. We did not measure how many production installations run that combination and make no claim of broad internet exposure.

The exposure that does exist is concentrated exactly where it hurts: multi-brand groups, franchises, and agencies that run many storefronts from one PrestaShop and hand out per-shop webservice keys. For them the shop association is the tenant wall, and this bug turns a one-shop grant into installation-wide read access with a single query-string value.

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 is a minimally scoped webservice key (PR:L). Confidentiality is High because the disclosed record includes the password hash and secure_key. Integrity is High because the gate at fault governs the write and delete methods as well as read. Scope is unchanged and there is no availability impact, so it is not a 10.0. Note that this is the gate-level score: the read primitive is what we proved to a marker, and the write reach follows from the same gate and is demonstrated on the customer resource in CVE-2026-71519.

Detection and Fix

Upgrade. PrestaShop fixed this in 8.2.9 and 9.2.1.

Resolve the key's shops before expanding any selector. id_shop=all should mean "all shops this key is associated with," not "every shop in the database," and id_shop_group=N should mean that group intersected with the key's associations. Because groupShopExists() runs first and shopExists() returns early, the fix cannot live only in the all branch; it has to constrain the resolved set for every selector.

Make the gate a conjunction. In shopHasRight(), require association with every shop in the resolved set rather than OR-ing over self::$shopIDs, and refuse a smaller intersection with the existing 403/error 132. Equivalently, intersect the request with the key's associations up front and reject when the intersection is narrower than what was asked for.

Narrow the context, not only the static. The listing filters on Shop::setContext() while the gate reads self::$shopIDs, so correcting one and not the other leaves a multi-shop key listing wider than its grant. Route every reader of self::$shopIDs through one authorization point.

If you cannot upgrade immediately, set PS_WEBSERVICE=0 where multistore does not need it, or strip id_shop=all and id_shop_group= from /api/ requests at the proxy or WAF and rotate over-scoped keys. Unlike the sibling customer-endpoint bug, this one does have a tampered value a proxy can key on. This is reasoned from the code path and should be validated against your own traffic before you rely on it.

Credit and Scope

Finding

CodeAnt AI Security

What we claim

id_shop=all and id_shop_group=N expand the shop set without consulting the key, and shopHasRight() evaluates it as a disjunction; demonstrated cross-tenant customer disclosure reproduced to a benign marker on 8.2.7 and 9.1.4

What we do not claim

A measurement of real-world prevalence; a write or delete demonstrated through this specific selector path (we proved the read; the write reach is the same gate and is shown on the customer resource in CVE-2026-71519)

Related

CVE-2026-71519, the record-level check missing from 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. As interim mitigation you can disable the legacy webservice where it is not needed, or block id_shop=all and id_shop_group= on /api/ at a proxy and rotate over-scoped keys. Validate any proxy rule against your real integration traffic first.

More from CodeAnt

  • CVE-2026-71519: The Gate That Checked the Request, Not the Record, a PrestaShop Account Takeover

  • 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

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-71531?
Does this affect every PrestaShop installation?
Why does naming a shop get refused but id_shop=all succeed?
What is the difference between the id_shop=all and id_shop_group=N routes?
What data is exposed?
How is this different from CVE-2026-71519?
What is the CVSS score and severity of CVE-2026-71531?
Find out what's already
exploitable in your codebase.