mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-23 16:44:41 +02:00
Introduce second list of addresses which are classified as dialable if explicitly allowed when in Lax mode. Restricted pool now includes: link-local, site local, private (including ULA), CGNAT, discard, dummy, documentation and test addreses. Reserved pool shrinks to: this network, wireserver embedding/translation ranges and multicasts Rationale for the choice is that while items in restricted pool can be dangerous to allow they could be a legitimate target in some deployments. Ranges left in reserved list are ranges which make no sense to dial, are public (wireserver) or are 6to4 embedding which cannot be reasonably verified to be safe. To unlock those a proxy should be used instead fixes: https://github.com/go-gitea/gitea/issues/39557 --------- Signed-off-by: TheFox0x7 <thefox0x7@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
171 lines
9.0 KiB
Go
171 lines
9.0 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package policy
|
|
|
|
import (
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestCheckAddr(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, allow, block, host, ip string
|
|
localNeedsIPAllow, strict, want bool
|
|
}{
|
|
// lax mode exempts public and unresolved targets, restricted ones need an allow entry
|
|
{name: "empty lists allow public", host: "github.com", ip: "8.8.8.8", want: true},
|
|
{name: "empty lists deny private", ip: "10.0.0.5"},
|
|
{name: "lax exempts public despite allow list", allow: "example.com", host: "other.com", ip: "8.8.8.8", want: true},
|
|
{name: "lax exempts unresolved despite allow list", allow: "example.com", host: "other.com", want: true},
|
|
{name: "block unresolved", block: "evil.example.com", host: "evil.example.com"},
|
|
{name: "block host", block: "evil.example.com", host: "evil.example.com", ip: "8.8.8.8"},
|
|
{name: "block cidr", block: "127.0.0.0/8", ip: "127.0.0.1"},
|
|
{name: "block ipv4-mapped", block: "loopback", ip: "::ffff:127.0.0.1"},
|
|
{name: "block nat64 by cidr", block: "10.0.0.0/8", ip: "64:ff9b::a00:1"},
|
|
{name: "allow loopback", allow: "loopback", ip: "127.0.0.1", want: true},
|
|
{name: "allow host", allow: "example.com", host: "example.com", ip: "8.8.8.8", want: true},
|
|
{name: "allow cidr", allow: "10.0.0.0/8", ip: "10.0.0.5", want: true},
|
|
{name: "block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5"},
|
|
{name: "non cloud link-local is default denied", ip: "::ffff:169.254.1.2"},
|
|
{name: "link-local allowed by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", want: true},
|
|
{name: "link-local allowed ipv4-mapped", allow: "169.254.0.0/16", ip: "::ffff:169.254.169.254", want: true},
|
|
{name: "restricted range allowed by cidr", allow: "192.0.2.0/24", ip: "192.0.2.1", want: true},
|
|
{name: "ula metadata allowed by private", allow: "private", ip: "fd00:ec2::254", want: true},
|
|
{name: "reserved denied despite allow", allow: "168.63.129.16/32", ip: "168.63.129.16"},
|
|
{name: "reserved denied ipv4-mapped", allow: "168.63.129.16/32", ip: "::ffff:168.63.129.16"},
|
|
{name: "nat64 reserved denied despite allow", allow: "64:ff9b::/96", ip: "64:ff9b::a9fe:a9fe"},
|
|
{name: "teredo reserved denied despite allow", allow: "2001::/23", ip: "2001::1"},
|
|
{name: "protocol assignment allowed by cidr", allow: "2001::/23", ip: "2001:3::1", want: true},
|
|
{name: "local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true},
|
|
{name: "local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, want: true},
|
|
{name: "local gate accepts cidr", allow: "10.0.0.0/24", ip: "10.0.0.5", localNeedsIPAllow: true, want: true},
|
|
// strict mode requires every target to match the allow list, an empty list denies all
|
|
{name: "strict denies public with empty list", ip: "8.8.8.8", strict: true},
|
|
{name: "strict denies unresolved with empty list", host: "example.com", strict: true},
|
|
{name: "strict allows unresolved host", allow: "example.com", host: "example.com", strict: true, want: true},
|
|
{name: "strict rejects unlisted public", allow: "loopback", ip: "8.8.8.8", strict: true},
|
|
{name: "strict rejects unmatched host", allow: "example.com", host: "other.com", ip: "8.8.8.8", strict: true},
|
|
{name: "strict allows matched host", allow: "example.com", host: "example.com", ip: "8.8.8.8", strict: true, want: true},
|
|
{name: "strict block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5", strict: true},
|
|
{name: "strict reserved denied despite allow", allow: "168.63.129.16/32", ip: "168.63.129.16", strict: true},
|
|
{name: "strict link-local allowed by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", strict: true, want: true},
|
|
{name: "strict local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true, strict: true},
|
|
{name: "strict local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, strict: true, want: true},
|
|
} {
|
|
opts := []Option{WithAllow(tc.allow, "test.ALLOWED"), WithBlock(tc.block, "test.BLOCKED")}
|
|
if tc.localNeedsIPAllow {
|
|
opts = append(opts, WithLocalNeedsIPAllow())
|
|
}
|
|
var addr netip.Addr
|
|
if tc.ip != "" {
|
|
addr = netip.MustParseAddr(tc.ip)
|
|
}
|
|
mode := Lax
|
|
if tc.strict {
|
|
mode = Strict
|
|
}
|
|
err := NewPolicy("test", mode, opts...).checkAddr(tc.host, netip.AddrPortFrom(addr, 80))
|
|
assert.Equal(t, tc.want, err == nil, "%s (%s): %v", tc.name, tc.ip, err)
|
|
}
|
|
}
|
|
|
|
// a policy without list options behaves like one with empty lists
|
|
func TestPolicyWithoutLists(t *testing.T) {
|
|
lax := NewPolicy("test", Lax)
|
|
assert.NoError(t, lax.checkAddr("", netip.AddrPortFrom(netip.MustParseAddr("8.8.8.8"), 80))) // public targets pass
|
|
assert.Error(t, lax.checkAddr("", netip.AddrPortFrom(netip.MustParseAddr("10.0.0.5"), 80))) // restricted targets need an entry
|
|
strict := NewPolicy("test", Strict)
|
|
err := strict.checkAddr("", netip.AddrPortFrom(netip.MustParseAddr("8.8.8.8"), 80)) // strict denies without an entry
|
|
assert.ErrorContains(t, err, "can only call allowed HTTP servers")
|
|
}
|
|
|
|
func TestDenialNamesSetting(t *testing.T) {
|
|
err := NewPolicy("webhook", Strict, WithAllow("example.com", "security.ALLOWED_HOST_LIST")).checkAddr("other.com", netip.AddrPortFrom(netip.MustParseAddr("8.8.8.8"), 80))
|
|
assert.EqualError(t, err, "webhook can only call allowed HTTP servers (check your security.ALLOWED_HOST_LIST setting), deny 'other.com(8.8.8.8)'")
|
|
|
|
err = NewPolicy("webhook", Lax, WithBlock("evil.com", "migrations.BLOCKED_HOST_LIST")).CheckHost(hostURL(t, "http://evil.com"))
|
|
assert.EqualError(t, err, "webhook can not call blocked HTTP servers (check your migrations.BLOCKED_HOST_LIST setting), deny 'evil.com'")
|
|
}
|
|
|
|
func hostURL(t *testing.T, raw string) *url.URL {
|
|
t.Helper()
|
|
u, err := url.Parse(raw)
|
|
require.NoError(t, err)
|
|
return u
|
|
}
|
|
|
|
func TestCheckHostIPs(t *testing.T) {
|
|
ips := func(addrs ...string) (ret []net.IP) {
|
|
for _, addr := range addrs {
|
|
ret = append(ret, net.ParseIP(addr))
|
|
}
|
|
return ret
|
|
}
|
|
|
|
blocked := NewPolicy("test", Lax, WithAllow("private", ""), WithBlock("blocked.example.com", ""))
|
|
assert.NoError(t, blocked.checkHostIPs(hostURL(t, "http://example.com"), ips("8.8.8.8", "10.0.0.5")))
|
|
assert.NoError(t, blocked.checkHostIPs(hostURL(t, "http://example.com"), nil)) // unresolved name, the dialer re-checks the resolved address
|
|
assert.Error(t, blocked.checkHostIPs(hostURL(t, "http://blocked.example.com"), ips("8.8.8.8")))
|
|
assert.Error(t, blocked.checkHostIPs(hostURL(t, "http://blocked.example.com"), nil))
|
|
|
|
allowed := NewPolicy("test", Strict, WithAllow("10.0.0.0/8, *.example.com", ""))
|
|
assert.NoError(t, allowed.checkHostIPs(hostURL(t, "http://"), ips("10.0.0.5")))
|
|
assert.NoError(t, allowed.checkHostIPs(hostURL(t, "http://git.example.com"), ips("192.168.0.1")))
|
|
assert.NoError(t, allowed.checkHostIPs(hostURL(t, "http://git.example.com"), nil))
|
|
assert.Error(t, allowed.checkHostIPs(hostURL(t, "http://other.com"), ips("10.0.0.5", "192.168.0.1")))
|
|
assert.Error(t, allowed.checkHostIPs(hostURL(t, "http://other.com"), nil))
|
|
|
|
builtins := NewPolicy("test", Lax, WithAllow("private, loopback", ""))
|
|
assert.NoError(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips("8.8.8.8", "100.64.0.1", "::1")))
|
|
assert.NoError(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips("100.100.100.200"))) // cloud metadata is opt-in with its containing range
|
|
for _, ip := range []string{
|
|
"0.1.2.3", "168.63.129.16", "169.254.169.254", "192.0.2.1", "192.88.99.1", "198.18.0.1",
|
|
"198.51.100.1", "203.0.113.1", "::7f00:1", "::ffff:0:a00:5", "64:ff9b::a9fe:a9fe", "64:ff9b::808:808", "2001::1", "2001:db8::1",
|
|
"2002::1", "fe80::1",
|
|
} {
|
|
assert.Error(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips(ip)), ip)
|
|
}
|
|
}
|
|
|
|
func TestNewDialContext(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
addr := ln.Addr().String()
|
|
|
|
dial := func(proxy string, allowProxies bool) error {
|
|
policy := NewPolicy("test", Lax, WithBlock("loopback", ""), WithProxy(http.ProxyURL(&url.URL{Scheme: "http", Host: proxy})))
|
|
_, _ = policy.Proxy(&http.Request{})
|
|
conn, err := policy.dialContext(allowProxies)(t.Context(), "tcp", addr)
|
|
if err == nil {
|
|
_ = conn.Close()
|
|
}
|
|
return err
|
|
}
|
|
assert.NoError(t, dial(addr, true))
|
|
assert.ErrorIs(t, dial(addr, false), ErrDenied)
|
|
assert.ErrorIs(t, dial("127.0.0.1:1", true), ErrDenied)
|
|
}
|
|
|
|
func TestProxy(t *testing.T) {
|
|
for raw, want := range map[string]string{
|
|
"http://127.0.0.1": "127.0.0.1:80",
|
|
"socks5://[::1]": "[::1]:1080",
|
|
"https://proxy.corp:8443": "proxy.corp:8443",
|
|
} {
|
|
u, err := url.Parse(raw)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, want, ProxyDialAddr(u), raw)
|
|
}
|
|
|
|
_, err := NewPolicy("test", Lax, WithProxy(http.ProxyURL(&url.URL{Scheme: "socks4", Host: "proxy.corp:1080"}))).Proxy(&http.Request{})
|
|
assert.ErrorContains(t, err, "unsupported proxy scheme")
|
|
}
|