# Gitea: Incomplete SSRF Protection in Webhook and Migration Allow-list Default Filter

Gitea's default SSRF allow-list used net.IP.IsPrivate(), missing CGNAT, Azure WireServer and NAT64 ranges, letting authenticated users read internal HTTP responses via webhook history.

- Payout: Research
- Severity: critical
- Program: Gitea
- Found by (vendor credit): JLLeitschuh
- Found by: JLLeitschuh
- Disclosed: 2026-07-21
- CVE: CVE-2026-22874
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-2r5c-gw76-rh3w
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-2r5c-gw76-rh3w
- Tags: area:web domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:critical tech:go technique:incomplete-fix technique:source-review vuln:access-control vuln:ssrf

**Target:** Webhook delivery and repository migration HTTP fetcher (hostmatcher allow-list)
**Root cause:** MatchBuiltinExternal allowed any IP that is IsGlobalUnicast() && !IsPrivate(), but Go's net.IP.IsPrivate() only covers RFC 1918 and RFC 4193, so CGNAT, Azure WireServer, non-RFC1918 172.x, NAT64, Teredo, 6to4 and documentation ranges passed the filter.
**Steps:**
1. Create or modify a webhook (or trigger a repository migration) pointing at a blocked-range destination, e.g. `64:ff9b::a9fe:a9fe` (NAT64-embedded AWS IMDS) or `168.63.129.16` (Azure WireServer).
2. Gitea's hostmatcher evaluates the IP via `MatchBuiltinExternal`: `if ip.IsGlobalUnicast() && !ip.IsPrivate() { return true }` — the range is allowed.
3. Webhook delivery issues the HTTP request and captures status, headers and up to 1 MiB of body.
4. Read the captured response in the webhook history UI (`templates/repo/settings/webhook/history.tmpl`).
5. Reproduce in unit test: `go test -v ./modules/hostmatcher -run TestSSRFBypassRanges` — 10 subtests fail on 4c37f4d.
**How found:** Code review of the default allow-list: noticed the 'external' decision delegates to net.IP.IsPrivate(), whose documented scope is only RFC 1918/4193, then enumerated well-known internal and IPv6-transition ranges absent from that definition and encoded each as a failing subtest.
**Exploitation:** Authenticated user with webhook create/edit rights (or migration trigger rights) points the URL at an internal/metadata address in an unblocked range; Gitea fetches it server-side and the non-blind response is rendered back in the webhook history UI, yielding internal service and cloud metadata reads.
**Escalation:** Same default filter is shared with repository migrations, widening reach to users who can only trigger a migration.
**Insight:** When auditing SSRF filters, check what the underlying primitive actually covers rather than trusting its name — Go's IsPrivate() excludes CGNAT, Azure WireServer 168.63.129.16 and NAT64/6to4/Teredo prefixes that can embed 169.254.169.254; also check whether the response is reflected back (webhook history) turning blind SSRF into full-read.

## Proof of concept (copied exactly from the original report)

**Go test asserting SSRF bypass IP ranges**

```
func TestSSRFBypassRanges(t *testing.T) {
+	type tc struct {
+		ip      net.IP
+		allowed bool
+	}
+
+	hl := ParseHostMatchList("", "external")
+
+	cases := map[string]tc{
+		// RFC 1918 / IPv6 private ranges - correctly blocked
+		"RFC1918 10.0.0.0/8":        {net.ParseIP("10.0.0.1"), false},
+		"RFC1918 172.16.0.0/12":     {net.ParseIP("172.16.0.1"), false},
+		"RFC1918 192.168.0.0/16":    {net.ParseIP("192.168.1.1"), false},
+		"IPv6 loopback ::1":         {net.ParseIP("::1"), false},
+		"IPv6 link-local fe80::/10": {net.ParseIP("fe80::1"), false},
+		"IPv6 ULA fd00::/8":         {net.ParseIP("fd00::1"), false},
+
+		// Legitimate public IPs - correctly allowed
+		"Public IPv4 (Google DNS)": {net.ParseIP("8.8.8.8"), true},
+		"Public IPv6 (Google DNS)": {net.ParseIP("2001:4860:4860::8888"), true},
+
+		// SSRF bypasses - the assertions below intentionally describe the
+		// expected secure behavior (allowed=false). Each failing subtest is
+		// a documented bypass.
+		"CGNAT 100.64.0.0/10 (RFC 6598)":              {net.ParseIP("100.64.0.1"), false},
+		"CGNAT 100.127.255.254 (RFC 6598)":            {net.ParseIP("100.127.255.254"), false},
+		"Azure WireServer 168.63.129.16":              {net.ParseIP("168.63.129.16"), false},
+		"Non-RFC1918 172.32.0.0/11":                   {net.ParseIP("172.32.0.1"), false},
+		"Non-RFC1918 172.45.0.0/16":                   {net.ParseIP("172.45.0.1"), false},
+		"IPv6 NAT64 64:ff9b::/96 (RFC 6052)":          {net.ParseIP("64:ff9b::1"), false},
+		"IPv6 NAT64 embedded AWS IMDS 169.254.169.254": {net.ParseIP("64:ff9b::a9fe:a9fe"), false},
+		"IPv6 Teredo 2001::/32 (RFC 4380)":            {net.ParseIP("2001::1"), false},
+		"IPv6 6to4 2002::/16 (RFC 3056)":              {net.ParseIP("2002::1"), false},
+		"IPv6 documentation 2001:db8::/32 (RFC 3849)": {net.ParseIP("2001:db8::1"), false},
+	}
+
+	for name, c := range cases {
+		t.Run(name, func(t *testing.T) {
+			assert.Equalf(t, c.allowed, hl.MatchHostOrIP("", c.ip), "ip=%v", c.ip)
```

**Command to run the PoC test**

```
go test -v ./modules/hostmatcher -run TestSSRFBypassRanges
```

