# Patch Analysis — CVE-2026-54849 (Premmerce Wishlist for WooCommerce SQLi)

## Overview

The vulnerability is an **unauthenticated SQL injection** in the Premmerce Wishlist
for WooCommerce WordPress plugin (versions `<= 1.1.11`). The visitor's wishlist
"keys" are persisted in a browser cookie named `premmerce_wishlist` (a JSON
array). On every unauthenticated request to the wishlist functionality, the
plugin reads that cookie, decodes it, and interpolates the attacker-controlled
keys directly into a SQL `IN (...)` query built by **string concatenation**.

The fix shipped in **1.1.12** (changelog: *"Security: SQL injection fix in
wishlist cookie handling"*). **1.1.13** is byte-identical to 1.1.12 in all
security-relevant code (it only bumps WooCommerce compatibility and the
Freemius SDK); `cmp` of `src/Models/WishlistModel.php` and `src/WishlistStorage.php`
between 1.1.12 and 1.1.13 reports no differences.

## Files changed by the fix (1.1.11 → 1.1.12)

Only two PHP source files changed (all other diffs are cosmetic whitespace /
`switch(` → `switch (` reformatting):

### 1. `src/WishlistStorage.php` — `cookieGet()`

Vulnerable (1.1.11):
```php
public function cookieGet()
{
    if ($this->cookieIsSet()) {
        $data = json_decode(stripslashes($_COOKIE[self::COOKIE_NAME]));
        return $data ? $data : array();          // <-- NO validation of $data entries
    }
    return array();
}
```

Fixed (1.1.12):
```php
public function cookieGet()
{
    if ($this->cookieIsSet()) {
        $data = json_decode(stripslashes($_COOKIE[self::COOKIE_NAME]));
        if (is_array($data)) {
            return array_values(array_filter($data, function ($key) {
                return is_string($key) && preg_match('/^[a-zA-Z0-9]{1,13}$/', $key);
            }));
        }
        return array();
    }
    return array();
}
```

**What it changes:** every key decoded from the cookie is now validated with a
strict allow-list regex `^[a-zA-Z0-9]{1,13}$`. Only short alphanumeric strings
survive. This rejects every quote, space, parenthesis, comment, and SQL
metacharacter — exactly the characters required to break out of the `IN ("...")`
literal. Non-array JSON and non-string entries are also dropped.

**Assumption:** all cookie-derived data flows through `cookieGet()`. The fix
relies on this being the single choke point for cookie → SQL.

### 2. `src/Models/WishlistModel.php` — every dynamic SQL sink

The fix converts **every** dynamic SQL builder from string concatenation to
`$wpdb->prepare()` with `%s`/`%d` placeholders (defense in depth, so that even
a path that bypassed `cookieGet()` could not inject). The concatenation sinks
removed:

| Method (vuln 1.1.11) | Vulnerable construction | Fixed (1.1.12) |
|---|---|---|
| `getWishlistsByKeys($keys)` | `IN ("' . implode('", "', $keys) . '")` (double-quote) | `$wpdb->prepare(... IN (%s,%s,...), $keys)` |
| `getDefaultWishlistByKeys($keys)` | `IN ("' . implode('", "', $keys) . '")` (double-quote) | `$wpdb->prepare(... IN (%s,%s,...), $keys)` |
| `setUserToWishlistsByKeys($userId,$keys)` | `IN ('' . implode("','", $keys) . '')` (single-quote) | `$wpdb->prepare(... IN (%s,%s,...), array_merge([uid,date],$keys))` |
| `deleteWishlists($ids)` | `IN (' . implode(', ', $ids) . ')` (raw) | `array_map('intval',$ids)` + `$wpdb->prepare(... IN (%d,%d,...), $ids)` |
| `getWishlistsByProductId($id)` | `LIKE '%" . $id . "%'` (raw) | `$wpdb->prepare(... LIKE %s, '%' . esc_like($id) . '%')` |
| `getWishlists($params)` where_in | `IN (' . $value . ')` (raw, column unfiltered) | `allowed_columns = ['ID','user_id','wishlist_key']` allow-list + `$wpdb->prepare(... IN (%s,...), $where_values)` |
| `getWishlists($params)` orderby | raw `$params['orderby']` | unchanged `in_array($params['orderby'], ['ID','name','date_created','date_modified'])` allow-list (already present in vuln) |

A `grep` for raw concatenation patterns (`implode('", "')`, `implode("','")`,
`implode(', '`, `LIKE '%.`, `IN ("`, `IN ('`) in the fixed `WishlistModel.php`
returns **only** `implode(', ', array_fill(..., '%s'/'%d'))` calls — i.e. the
`implode` is building the **placeholder string** (`%s,%s,...`), never
interpolating data. **No data-interpolation sink remains.**

## Fix assumptions / invariants

1. **Single choke point for cookie data:** all cookie→SQL paths go through
   `WishlistStorage::cookieGet()`. The regex there is the outer guard.
2. **Parameterization is universal:** every dynamic SQL statement in
   `WishlistModel` uses `$wpdb->prepare()` with matched placeholders, so even
   untrusted data passed as an argument cannot break out.
3. **Allow-lists for structural SQL fragments:** `getWishlists()` where_in
   restricts columns to `ID/user_id/wishlist_key`; orderby restricts columns to
   `ID/name/date_created/date_modified`; `deleteWishlistsByDateInterval` selects
   the interval via a `switch` allow-list (unchanged from vuln, already safe).

## Code paths / inputs the fix DOES cover (verified by this variant run)

Every unauthenticated entry point that reaches a cookie-derived sink is covered.
The variant reproduction script tested four materially-distinct unauthenticated
entry points with the identical SLEEP(5) time oracle:

| Candidate | Entry point | Sink reached | Vuln 1.1.11 | Fixed 1.1.12 | Latest 1.1.13 |
|---|---|---|---|---|---|
| C0 (baseline) | `GET /?rest_route=/premmerce/wishlist/add/popup` | `getWishlistsByKeys(cookieGet())` via `getWishlistsAll()` | 5.20 s | 0.11 s | 0.12 s |
| C1 (wc-ajax) | `GET /?wc-ajax=premmerce_wishlist_popup` | `getWishlistsByKeys(cookieGet())` via `getWishlistsAll()` | 6.07 s | 0.04 s | 0.04 s |
| C2 (REST /add POST) | `POST /?rest_route=/premmerce/wishlist/add` | `getDefaultWishlistByKeys(cookieGet())` via `addProductToWishlist()` (+ `getWishlistsAll()`) | 5.14 s | 0.08 s | 0.08 s |
| C3 (frontend page) | `GET /?page_id=<wishlist_page>` | `getWishlistsByKeys(cookieGet())` via `Frontend::wishlistPage()` | 5.13 s | 0.10 s | 0.10 s |

All four fire SLEEP on the vulnerable build (confirming they are **real**
alternate triggers of the same root cause) and **none** fire SLEEP on the fixed
or latest build (the fix closes them all).

An additional **authenticated** alternate trigger was identified by source
inspection (not runtime-tested, see Notes): the `wp_login` action
`Frontend::assignUserWishlistFromCookie()` →
`WishlistModel::setUserToWishlistsByKeys($user->ID, cookieGet())`. This reaches a
**different** sink (an `UPDATE ... WHERE wishlist_key IN ('...')` with a
**single-quote** breakout) via a **different** entry point (a login event rather
than an HTTP request). It is covered by the same two-layer fix: `cookieGet()`
regex + `setUserToWishlistsByKeys` is now parameterized.

## Code paths / inputs the fix does NOT cover

**None found that cross the same trust boundary unauthenticated.** Specifically:

- **Bypassing `cookieGet()`'s regex:** the regex `^[a-zA-Z0-9]{1,13}$` admits no
  SQL metacharacters. Even if it did, the sinks are parameterized, so there is
  no injection primitive. No bypass.
- **A non-cookie unauthenticated path to a concatenation sink:** there are no
  concatenation sinks left in the fixed `WishlistModel`. Request-parameter
  inputs on the unauthenticated REST routes (`wishlist_key`, `wishlist_name`,
  `wishlist_move_to`, `product_ids`, `wishlist_id`, `productId`) are all passed
  as `$wpdb->prepare()` arguments, and the URL-param routes
  (`/page/rename/{key}`, `/delete/{key}`, `/delete/{key}/{productId}`) enforce
  regex constraints (`[a-zA-Z0-9]{13}`, `\d+`) at the router level as well.
- **Admin-only surfaces:** `WishlistTable.php` (admin list table) feeds
  `where_in['user_id'] = implode(',', $ids)` where `$ids` are DB-derived user
  IDs, through the now-allow-listed + parameterized `getWishlists()`. The admin
  search `s` parameter goes through WordPress core `WP_User_Query` (parameterized
  internally) and is never concatenated into the wishlist SQL. These are
  authenticated (`manage_options`) surfaces and are not injectable in the fixed
  build regardless; they are out of scope for this unauthenticated CVE.

## Behavior before vs after the fix

- **Before (1.1.11):** `cookieGet()` returns the raw `json_decode`'d array with
  no per-element validation; `getWishlistsByKeys()`/`getDefaultWishlistByKeys()`/
  `setUserToWishlistsByKeys()` concatenate the keys into `IN ("...")` / `IN ('...')`
  literals. A cookie value of `["x\") UNION SELECT SLEEP(5),2,3,4,5,6,7,8-- "]`
  breaks out of the double-quoted literal and executes arbitrary SQL
  (demonstrated: ~5–6 s SLEEP; admin `user_login` leaked via UNION in the parent
  repro).
- **After (1.1.12 / 1.1.13):** `cookieGet()` filters the key out (it contains
  `"`, `)`, space, `-` → fails the regex) → returns `[]`; even if a key
  survived, `getWishlistsByKeys()` builds `IN (%s)` via `$wpdb->prepare()`.
  Measured response times on all four entry points: **0.04–0.12 s** (no SLEEP).

## Conclusion

The fix is **complete and defense-in-depth** for the unauthenticated SQL
injection class. It (a) validates cookie keys at the single choke point and
(b) parameterizes every dynamic SQL sink. No materially-distinct unauthenticated
variant or bypass was found — all alternate entry points funnel through the
patched `cookieGet()` and the now-parameterized sinks. The fix does not need
extension for this CVE; the residual recommendation is generic hygiene (treat
all `$_COOKIE`/`$_REQUEST` as untrusted, add automated malicious-cookie tests).
