Zammad: Stored XSS in the Desktop Autocomplete Label
A name registered anonymously is rendered as HTML in the autocomplete of the new desktop interface and executes JavaScript in the session of the searching agent.
Advisory ID: TP-2026-116
Product: Zammad (web-based helpdesk and ticketing system)
Vulnerability type: Stored cross-site scripting (CWE-79)
CVE: not requested
CVSS 4.0: 8.6 (High) · CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N
Affected versions: <= 7.2.0
Vendor advisory: GHSA-852x-r9wm-4v5j
Fixed in: 7.2.1
Reported: 23 August 2026
Summary
Zammad is a web-based helpdesk with a new Vue-based desktop interface at /desktop. Its autocomplete fields, such as the customer lookup when creating a ticket, render the label of a result as HTML and escape it only when the label contains the typed search term. The search also matches on email address or organization while the label is the full name, so the ordinary lookup by email address puts the raw label into the DOM. Anyone who creates an account through the public sign-up sets that name, even without completing email confirmation. When an agent looks up that record in the desktop interface, foreign code runs in the agent's session.
Root cause
CommonSelectItem renders matchedLabel with v-html and suppresses the matching lint rule for it (app/frontend/apps/desktop/components/CommonSelect/CommonSelectItem.vue:101, :110). CommonSelect applies escape() only inside if (option.match && option.match[0]) and otherwise passes the label through unchanged as matchedLabel (app/frontend/apps/desktop/components/CommonSelect/CommonSelect.vue:349-370). FieldAutoCompleteInput sets match from a regular expression over the label but does not drop results without a match (app/frontend/apps/desktop/components/Form/fields/FieldAutoComplete/FieldAutoCompleteInput.vue:336-341), unlike its twin FieldSelectInput.vue:79 with .filter((option) => option.match). The server returns the full name as the label (app/graphql/gql/queries/autocomplete_search/user.rb:53-54) while the search also matches email, login and organization, so match is null in normal use. The anonymous sign-up keeps firstname and lastname (app/controllers/users_controller.rb:894), and User strips HTML only from the note field (app/models/user.rb:141).
Proof of Concept
POST /api/v1/users (anonymous, with csrf-token from GET /)
{"signup": true, "email": "[email protected]", "password": "<password>",
"firstname": "<iframe srcdoc=\"<script src=/api/v1/ticket_attachment/T/A/X></script>\"></iframe>",
"lastname": "Q"}
-> agent opens /desktop/tickets/create and searches the customer field for "attacker.example"
The search term does not occur in the label, so match stays empty and the label is inserted unfiltered through v-html. The srcdoc document inherits the origin and CSP of the page and loads a same-origin script, for example a ticket attachment served as application/javascript, which drives the agent's session through window.top.
Impact
- Reading every ticket and internal note the searching agent can access.
- Writing articles and ticket changes as the agent.
- Full administrative rights if the agent is also an administrator.
- Only the Vue desktop interface at
/desktopis affected, which is not enabled as the main interface by default (ui_desktop_beta_switch) but is reachable by every agent; the classic and the mobile interface are not affected.
References
Is Something Like This in Your Software?
Our team found this vulnerability in the course of its work. Have your applications tested by the same specialists, with a penetration test from turingpoint.
