Go · code.gitea.io/gitea
Gitea: Incomplete SSRF Protection in Webhook and Migration Allow-list Default Filter
Gitea's default SSRF allow-list (MatchBuiltinExternal, used by both webhook delivery and repository migrations) relies on Go's standard library net.IP.IsPrivate(), which only covers RFC 1918 and RFC 4193. As a result, several IP ranges commonly used for cloud metadata services, internal networks, and IPv6 transition mechanisms are not blocked, allowing authenticated users to send HTTP requests to those destinations and read the responses via the webhook history UI.
The vulnerability lives in HostMatchList.checkIP, specifically line 103:
case MatchBuiltinExternal:
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
return true
}
net.IP.IsPrivate() recognises only:
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC 1918)fc00::/7 (RFC 4193 IPv6 ULA)It does not recognise:
| Range | Description |
|---|---|
100.64.0.0/10 |
RFC 6598 Carrier-Grade NAT |
168.63.129.16/32 |
Azure WireServer metadata endpoint |
172.32.0.0/11 |
Non-RFC1918 portion of 172.0.0.0/8 (real-world internal use) |
64:ff9b::/96 |
RFC 6052 IPv6 NAT64 (can embed 169.254.169.254) |
2001::/32 |
RFC 4380 Teredo tunneling |
2002::/16 |
RFC 3056 6to4 |
2001:db8::/32 |
RFC 3849 documentation |
The default is reached by webhook delivery at services/webhook/deliver.go#L312-L316 and by repository migrations at services/migrations/migrate.go#L522.
The SSRF is not blind. Webhook delivery captures the response status, headers, and up to 1 MiB of body (services/webhook/deliver.go#L259-L270) and renders them in the webhook history UI (templates/repo/settings/webhook/history.tmpl#L75-L85), so attackers can read everything the targeted internal service returns.
An authenticated user who can create or modify a webhook can:
64:ff9b::a9fe:a9fe, Azure WireServer 168.63.129.16)100.64.0.0/10) or non-RFC1918 172.x rangesThe same default is applied to repository migrations, broadening the attack surface to users who can trigger a migration.
The attached patch (gitea_ssrf_test.patch) adds TestSSRFBypassRanges to the existing test file. It uses Go subtests so each vulnerable range is its own named failing test case.
Run with:
go test -v ./modules/hostmatcher -run TestSSRFBypassRanges
Expected result on 4c37f4dacbac022f7beca75272439331f0368830:
Sample failure output:
--- FAIL: TestSSRFBypassRanges/CGNAT_100.64.0.0/10_(RFC_6598)
Error: Not equal: expected: false, actual: true
Messages: ip=100.64.0.1
--- FAIL: TestSSRFBypassRanges/Azure_WireServer_168.63.129.16
Error: Not equal: expected: false, actual: true
Messages: ip=168.63.129.16
--- FAIL: TestSSRFBypassRanges/IPv6_NAT64_embedded_AWS_IMDS_169.254.169.254
Error: Not equal: expected: false, actual: true
Messages: ip=64:ff9b::a9fe:a9fe
I'd suggest treating the design of MatchBuiltinExternal as the bug — IsPrivate() is too narrow a definition of "internal." A comprehensive deny-list approach is what's needed here. For reference, CC-Tweaked's AddressPredicate.PrivatePattern is a good reference list, blocking each of the ranges named in the table above plus a few others (multicast, broadcast, TEST-NET, etc.).
The exact remediation is at your discretion.
modules/hostmatcher/hostmatcher.go#L96-L114services/webhook/deliver.go#L312-L316services/migrations/migrate.go#L522services/webhook/deliver.go#L259-L270templates/repo/settings/webhook/history.tmpl#L75-L85net.IP.IsPrivate(): https://pkg.go.dev/net#IP.IsPrivateAddressPredicate.java#L116-L169Proposed gitea_ssrf_test.patch:
diff --git a/modules/hostmatcher/hostmatcher_test.go b/modules/hostmatcher/hostmatcher_test.go
index c781847..0b39e60 100644
--- a/modules/hostmatcher/hostmatcher_test.go
+++ b/modules/hostmatcher/hostmatcher_test.go
@@ -159,3 +159,56 @@ func TestHostOrIPMatchesList(t *testing.T) {
}
test(cases)
}
+
+// TestSSRFBypassRanges verifies that the "external" filter (the default used by
+// webhook delivery and repository migrations) blocks dangerous IP ranges.
+//
+// Each subtest's `allowed` field is the expected return value of MatchHostOrIP:
+// - false: the IP should be blocked (rejected by the filter)
+// - true: the IP should be allowed (a legitimate public destination)
+//
+// Subtests that FAIL are documented SSRF bypasses: IP ranges that should be
+// blocked but are incorrectly allowed because the underlying check relies on
+// net.IP.IsPrivate(), which only covers RFC 1918 and RFC 4193.
+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"),
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 repoSources: 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.