
CVE-2026-22874 writeup: incomplete SSRF allow-list in Gitea webhook/migration (IPv6 transition and cloud metadata). Fixed in Gitea 1.26.3.
Technical writeup and references for CVE-2026-22874 (GHSA-2r5c-gw76-rh3w): an incomplete SSRF allow-list in Gitea's webhook delivery and repository migration, fixed in Gitea 1.26.3.
I independently reported this SSRF class to the Gitea maintainers and am credited as a contributor (other) on the published advisory; the advisory's primary reporter is J. Leitschuh. This repository is a defensive, educational writeup of the public advisory — the issue is fixed.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N)<= 1.26.2 — Fixed: 1.26.3Gitea's default SSRF allow-list (MatchBuiltinExternal, used by both webhook delivery and repository migrations) relied on the Go standard library , which only recognises RFC 1918 (, , ) and RFC 4193 (). Several address ranges commonly used for cloud metadata services, internal networks, and IPv6 transition mechanisms are not "private" under that definition and therefore passed the "external" filter, letting an authenticated user make the server issue HTTP requests to those destinations.
net.IP.IsPrivate()10/8172.16/12192.168/16fc00::/7Because webhook delivery records the response status, headers, and up to ~1 MiB of body and renders it in the webhook history UI, the SSRF is not blind: the attacker can read what the targeted internal service returns.
net.IP.IsPrivate()| Range | Meaning |
|---|---|
100.64.0.0/10 | RFC 6598 Carrier-Grade NAT |
168.63.129.16/32 | Azure WireServer / instance metadata endpoint |
64:ff9b::/96 | RFC 6052 NAT64 (can embed 169.254.169.254, e.g. AWS IMDS) |
2001::/32 | RFC 4380 Teredo |
2002::/16 | RFC 3056 6to4 |
IPv4-mapped IPv6 forms of the above are affected in the same way.
An authenticated user who can create or modify a webhook (or start a migration) can therefore reach cloud metadata endpoints — for example AWS IMDS via a NAT64 mapping, or the Azure WireServer at 168.63.129.16 — and internal-only services, then read the response through the webhook history view.
The default policy is MatchBuiltinExternal in Gitea's HostMatchList check, which treated any global-unicast, non-IsPrivate() address as allowed:
case MatchBuiltinExternal:
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
return true
}
net.IP.IsPrivate() is not a security boundary for "external": it does not account for carrier-grade NAT, cloud metadata endpoints, or IPv6 transition ranges that can be routed to internal or metadata destinations.
Fixed in Gitea 1.26.3 by extending the built-in filter to block the additional ranges above. Operators should upgrade to 1.26.3 or later. As defence in depth: