The third finding from the same March 2026 audit. It’s the lowest-severity of the three, but it’s a good illustration of how a single missing entry in a blocklist becomes a stored attack primitive — and of how defense-in-depth changes the practical impact.

Fixed in 7.0.1 (backported to 6.5.4), assigned CVE-2026-34718 (CVSS v4 5.3, Moderate).

Background

Zammad sanitizes the HTML of ticket articles with Loofah and a custom scrubber, HtmlSanitizer::Scrubber::Wipe. Among other things, the scrubber walks link attributes and strips dangerous URI schemes from href.

Root cause

The scheme blocklist in remove_invalid_links (lib/html_sanitizer/scrubber/wipe.rb) covered only three schemes:

def remove_invalid_links(node)
  %w[href style].each do |attribute_name|
    next if !node[attribute_name]
    href = cleanup_target(node[attribute_name])
    next if !href.match?(%r{(javascript|livescript|vbscript):}i)   # data: missing
    node.delete(attribute_name)
  end
end

data: is absent. Because href is allowlisted on <a> tags, an anchor carrying a data:text/html URI passed through sanitization completely unchanged and was stored in the database as-is.

Attack path

The interesting part is the delivery: this needs no authentication.

  1. An attacker emails a Zammad-monitored mailbox with an <a href="data:text/html;base64,..."> link disguised as an invoice or document.
  2. Zammad ingests the email, runs the sanitizer, and the data: URI survives.
  3. A support agent opens the ticket and clicks the link.
  4. The browser navigates to the attacker’s inline HTML document — for example a pixel-perfect fake login page that posts credentials to an attacker-controlled server.

The same payload can also be injected by any authenticated user through the ticket-creation API.

Proof of concept

The payload is an ordinary-looking anchor whose href is a base64 data:text/html document:

<a href="data:text/html;base64,PHNjcmlwdD4uLi48L3NjcmlwdD4=">&#128196; View Invoice PDF</a>

After Zammad’s sanitizer it is stored unchanged. Control tests confirmed the known schemes were stripped while data: survived:

javascript:alert(1)  →  stripped
vbscript:msgbox(1)   →  stripped
data:text/html,...   →  survives  (stored unchanged)

Impact, and the severity story

In my original report I rated this Medium-High (CVSS 3.1, 7.1 — AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N), on the assumption that JavaScript inside the data: URI could execute in deployments without a restrictive Content Security Policy.

Zammad rated it Moderate (CVSS v4 5.3) because the GUI’s deployed CSP prevents the script from actually running when such a link is clicked. That’s a fair downgrade — and a clean illustration of defense-in-depth: the sanitizer gap is real, the malicious content is still stored and rendered, but a second layer (the CSP) blocks code execution. The stored-content and phishing/credential-harvesting risk remains, which is why it was still worth fixing.

Fix

A one-character change — add data to the scheme blocklist:

next if !href.match?(%r{(javascript|livescript|vbscript|data):}i)

References

Thanks again to the Zammad security team.