Kestrel
대시보드로 돌아가기
CVE-2026-22874CRITICAL· 9.6MITRENVDGHSA대응게시일: 2026. 07. 03.수정일: 2026. 07. 21.

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

SSRF

위협 신호 · CVSS · EPSS · KEV

시급 검토· 이론 심각도 Critical
CVSS
9.6critical

이론적 심각도 점수

EPSS
0.6%상위 57.1%

30일 내 악용 확률 예측

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용불필요
범위변경
영향
기밀성 영향높음
무결성 영향높음
가용성 영향없음
버전별 점수
CVSS 3.19.6CRITICAL
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

상세 설명

Summary

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.

Details

The vulnerability lives in HostMatchList.checkIP, specifically line 103:

text
1case MatchBuiltinExternal:
2 if ip.IsGlobalUnicast() && !ip.IsPrivate() {
3 return true
4 }

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:

RangeDescription
100.64.0.0/10RFC 6598 Carrier-Grade NAT
168.63.129.16/32Azure WireServer metadata endpoint
172.32.0.0/11Non-RFC1918 portion of 172.0.0.0/8 (real-world internal use)
64:ff9b::/96RFC 6052 IPv6 NAT64 (can embed 169.254.169.254)
2001::/32RFC 4380 Teredo tunneling
2002::/16RFC 3056 6to4
2001:db8::/32RFC 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.

Impact

An authenticated user who can create or modify a webhook can:

  • Reach cloud metadata endpoints (AWS IMDS via NAT64 64:ff9b::a9fe:a9fe, Azure WireServer 168.63.129.16)
  • Probe and exfiltrate from internal services on CGNAT (100.64.0.0/10) or non-RFC1918 172.x ranges
  • Read full HTTP response bodies through the webhook history UI

The same default is applied to repository migrations, broadening the attack surface to users who can trigger a migration.

Proof of Concept

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:

text
1go test -v ./modules/hostmatcher -run TestSSRFBypassRanges

Expected result on 4c37f4dacbac022f7beca75272439331f0368830:

  • 8 PASS — RFC 1918, IPv6 private ranges, and legitimate public IPs (control cases)
  • 10 FAIL — Each failing subtest is a documented SSRF bypass

Sample failure output:

text
1--- FAIL: TestSSRFBypassRanges/CGNAT_100.64.0.0/10_(RFC_6598)
2 Error: Not equal: expected: false, actual: true
3 Messages: ip=100.64.0.1
4--- FAIL: TestSSRFBypassRanges/Azure_WireServer_168.63.129.16
5 Error: Not equal: expected: false, actual: true
6 Messages: ip=168.63.129.16
7--- FAIL: TestSSRFBypassRanges/IPv6_NAT64_embedded_AWS_IMDS_169.254.169.254
8 Error: Not equal: expected: false, actual: true
9 Messages: ip=64:ff9b::a9fe:a9fe

Suggested Remediation

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.

References


Patch <a name="patch"></a>

Proposed gitea_ssrf_test.patch:

text
1diff --git a/modules/hostmatcher/hostmatcher_test.go b/modules/hostmatcher/hostmatcher_test.go
2index c781847..0b39e60 100644
3--- a/modules/hostmatcher/hostmatcher_test.go
4+++ b/modules/hostmatcher/hostmatcher_test.go
5@@ -159,3 +159,56 @@ func TestHostOrIPMatchesList(t *testing.T) {
6 }
7 test(cases)
8 }
9+
10+// TestSSRFBypassRanges verifies that the "external" filter (the default used by
11+// webhook delivery and repository migrations) blocks dangerous IP ranges.
12+//
13+// Each subtest's `allowed` field is the expected return value of MatchHostOrIP:
14+// - false: the IP should be blocked (rejected by the filter)
15+// - true: the IP should be allowed (a legitimate public destination)
16+//
17+// Subtests that FAIL are documented SSRF bypasses: IP ranges that should be
18+// blocked but are incorrectly allowed because the underlying check relies on
19+// net.IP.IsPrivate(), which only covers RFC 1918 and RFC 4193.
20+func TestSSRFBypassRanges(t *testing.T) {
21+ type tc struct {
22+ ip net.IP
23+ allowed bool
24+ }
25+
26+ hl := ParseHostMatchList("", "external")
27+
28+ cases := map[string]tc{
29+ // RFC 1918 / IPv6 private ranges - correctly blocked
30+ "RFC1918 10.0.0.0/8": {net.ParseIP("10.0.0.1"), false},
31+ "RFC1918 172.16.0.0/12": {net.ParseIP("172.16.0.1"), false},
32+ "RFC1918 192.168.0.0/16": {net.ParseIP("192.168.1.1"), false},
33+ "IPv6 loopback ::1": {net.ParseIP("::1"), false},
34+ "IPv6 link-local fe80::/10": {net.ParseIP("fe80::1"), false},
35+ "IPv6 ULA fd00::/8": {net.ParseIP("fd00::1"), false},
36+
37+ // Legitimate public IPs - correctly allowed
38+ "Public IPv4 (Google DNS)": {net.ParseIP("8.8.8.8"), true},
39+ "Public IPv6 (Google DNS)": {net.ParseIP("2001:4860:4860::8888"), true},
40+
41+ // SSRF bypasses - the assertions below intentionally describe the
42+ // expected secure behavior (allowed=false). Each failing subtest is
43+ // a documented bypass.
44+ "CGNAT 100.64.0.0/10 (RFC 6598)": {net.ParseIP("100.64.0.1"), false},
45+ "CGNAT 100.127.255.254 (RFC 6598)": {net.ParseIP("100.127.255.254"), false},
46+ "Azure WireServer 168.63.129.16": {net.ParseIP("168.63.129.16"), false},
47+ "Non-RFC1918 172.32.0.0/11": {net.ParseIP("172.32.0.1"), false},
48+ "Non-RFC1918 172.45.0.0/16": {net.ParseIP("172.45.0.1"), false},
49+ "IPv6 NAT64 64:ff9b::/96 (RFC 6052)": {net.ParseIP("64:ff9b::1"), false},
50+ "IPv6 NAT64 embedded AWS IMDS 169.254.169.254": {net.ParseIP("64:ff9b::a9fe:a9fe"), false},
51+ "IPv6 Teredo 2001::/32 (RFC 4380)": {net.ParseIP("2001::1"), false},
52+ "IPv6 6to4 2002::/16 (RFC 3056)": {net.ParseIP("2002::1"), false},
53+ "IPv6 documentation 2001:db8::/32 (RFC 3849)": {net.ParseIP("2001:db8::1"), false},
54+ }
55+
56+ for name, c := range cases {
57+ t.Run(name, func(t *testing.T) {
58+ assert.Equalf(t, c.allowed, hl.MatchHostOrIP("", c.ip), "ip=%v", c.ip)
59+ })
60+ }
61+}

Credit

This vulnerability was uncovered by @JLLeitschuh of the @braze-inc security team.

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.