fix(egress): expose more ranges as restricted rather than reserved (#39560) (#39624)

Backport #39560 by TheFox0x7

fixes: https://github.com/go-gitea/gitea/issues/39557

Signed-off-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Giteabot
2026-10-06 00:24:11 -07:00
committed by GitHub
parent a67171af37
commit e27f69bc88
6 changed files with 93 additions and 45 deletions
+6 -4
View File
@@ -536,7 +536,7 @@ INTERNAL_TOKEN =
;CONTENT_SECURITY_POLICY_GENERAL =
;;
;; Egress mode toggles between strictness of outgoing requests:
;; Lax requires addresses to be allowed only if they are in private ranges, it allows all public ones
;; Lax requires non-public targets (private, loopback, link-local, CGNAT and special-use ranges) to be allowed, it allows all public ones
;; Strict requires an explicit allow of all addresses
; EGRESS_MODE = lax
;;
@@ -551,10 +551,12 @@ INTERNAL_TOKEN =
;; a bracketed set of ports and ranges, | separated: *.mydomain.com:[80|443|3000-3010]
;; all ports: *.mydomain.com:*
;; A portless entry covers all ports in Lax mode, only 80 and 443 in Strict mode
;; Port specs apply only where the list is consulted: in Lax mode that is private, loopback and CGNAT
;; targets alone, public targets are allowed on every port whatever the list says. In Strict mode every
;; Port specs apply only where the list is consulted: in Lax mode that is non-public targets alone,
;; public targets are allowed on every port whatever the list says. In Strict mode every
;; target is checked, so ports restrict public hosts too.
;; Reserved addresses like link-local and cloud metadata are denied
;; Non-public targets need an IP or built-in entry, a host name entry alone never covers them.
;; Reserved addresses (the IPv4-embedding NAT64, Teredo and 6to4 ranges, this-network, multicast and
;; broadcast) are denied whatever the list says. To reach them configure an HTTP proxy
;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility.
;ALLOWED_HOST_LIST =
+2
View File
@@ -98,6 +98,7 @@ func NewWebhookPolicy() *policy.Policy {
}
p = policy.NewPolicy("webhook", policyMode(setting.Security.EgressMode),
policy.WithAllow(setting.Webhook.AllowedHostList, "security.ALLOWED_HOST_LIST"),
policy.WithLocalNeedsIPAllow(),
policy.WithProxy(selectProxy))
return p
@@ -106,6 +107,7 @@ func NewWebhookPolicy() *policy.Policy {
func NewSecurityPolicy(usage string) *policy.Policy {
return policy.NewPolicy(usage, policyMode(setting.Security.EgressMode),
policy.WithAllow(setting.Security.AllowedHostList, "security.ALLOWED_HOST_LIST"),
policy.WithLocalNeedsIPAllow(),
policy.WithProxy(proxy.Proxy()))
}
+24
View File
@@ -4,10 +4,13 @@
package egress
import (
"net"
"net/http"
"net/url"
"strconv"
"testing"
"gitea.dev/modules/egress/policy"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
@@ -70,6 +73,27 @@ func TestWebhookPolicyProxy(t *testing.T) {
}
}
func TestWebhookPolicyNeedsIPAllow(t *testing.T) {
defer test.MockVariableValue(&setting.Webhook.AllowedHostList, "localhost")()
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
dial := func() error {
tcpAddr, ok := ln.Addr().(*net.TCPAddr)
require.True(t, ok)
target := net.JoinHostPort("localhost", strconv.Itoa(tcpAddr.Port))
conn, err := NewWebhookPolicy().NewDialContext()(t.Context(), "tcp", target)
if err == nil {
_ = conn.Close()
}
return err
}
assert.ErrorIs(t, dial(), policy.ErrDenied) // a host name entry doesn't cover the loopback address
setting.Webhook.AllowedHostList = "loopback"
assert.NoError(t, dial()) // an IP entry does
}
func TestSecurityPolicy(t *testing.T) {
defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")()
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
+43 -33
View File
@@ -411,37 +411,43 @@ var cgnatRange = netip.MustParsePrefix("100.64.0.0/10") // RFC 6598
// reservedRanges are never dialable, based on https://microsoft.github.io/AntiSSRF/ipaddressranges.html
var reservedRanges = func() (ranges []netip.Prefix) {
for _, cidr := range []string{
"0.0.0.0/8", // "this network"
"100.100.100.200/32", // Alibaba Cloud metadata
"168.63.129.16/32", // Azure WireServer
"169.254.0.0/16", // link-local, cloud metadata endpoints
"192.0.0.0/24", // IETF protocol assignments
"192.0.2.0/24", // TEST-NET-1
"192.31.196.0/24", // AS112
"192.52.193.0/24", // AMT
"192.88.99.0/24", // 6to4 relay anycast
"192.175.48.0/24", // AS112
"198.18.0.0/15", // benchmarking
"198.51.100.0/24", // TEST-NET-2
"203.0.113.0/24", // TEST-NET-3
"224.0.0.0/4", // multicast
"240.0.0.0/4", // reserved, incl. limited broadcast
"::/96", // IPv4-compatible, embeds IPv4
"::ffff:0:0:0/96", // IPv4-translated, embeds IPv4
"64:ff9b::/96", // wkp NAT64
"64:ff9b:1::/48", // local-use NAT64
"100::/64", // discard-only
"100:0:0:1::/64", // dummy
"2001::/23", // IETF protocol assignments, incl. Teredo and ORCHID
"2001:db8::/32", // documentation
"2002::/16", // 6to4, embeds IPv4
"2620:4f:8000::/48", // AS112
"3fff::/20", // documentation
"5f00::/16", // SRv6 SIDs
"fd00:ec2::254/128", // AWS IMDS
"fe80::/10", // link-local
"fec0::/10", // site-local
"ff00::/8", // multicast
"0.0.0.0/8", // "this network"
"168.63.129.16/32", // Azure WireServer
"192.88.99.0/24", // 6to4 relay anycast
"224.0.0.0/4", // multicast
"240.0.0.0/4", // reserved, incl. limited broadcast
"::/96", // IPv4-compatible, embeds IPv4
"::ffff:0:0:0/96", // IPv4-translated, embeds IPv4
"64:ff9b::/96", // wkp NAT64
"64:ff9b:1::/48", // local-use NAT64
"2001::/32", // Teredo, embeds IPv4
"2002::/16", // 6to4, embeds IPv4
"ff00::/8", // multicast
} {
ranges = append(ranges, netip.MustParsePrefix(cidr))
}
return ranges
}()
// restrictedRanges are dialable if they have been explicitly allowed.
var restrictedRanges = func() (ranges []netip.Prefix) {
for _, cidr := range []string{
"192.0.0.0/24", // IETF protocol assignments
"192.0.2.0/24", // TEST-NET-1
"192.31.196.0/24", // AS112
"192.52.193.0/24", // AMT
"192.175.48.0/24", // AS112
"198.18.0.0/15", // benchmarking
"198.51.100.0/24", // TEST-NET-2
"203.0.113.0/24", // TEST-NET-3
"100::/64", // discard-only
"100:0:0:1::/64", // dummy
"2001::/23", // IETF protocol assignments
"2001:db8::/32", // documentation
"2620:4f:8000::/48", // AS112
"3fff::/20", // documentation
"5f00::/16", // SRv6 SIDs
"fec0::/10", // site-local
} {
ranges = append(ranges, netip.MustParsePrefix(cidr))
}
@@ -451,10 +457,14 @@ var reservedRanges = func() (ranges []netip.Prefix) {
// classifyAddr reports the class of a canonical address.
func classifyAddr(ip netip.Addr) addrClass {
switch {
case ip.Zone() != "" || !ip.IsLoopback() && slices.ContainsFunc(reservedRanges, func(p netip.Prefix) bool { return p.Contains(ip) }):
case ip.Zone() != "" || !ip.IsLoopback() && inRange(reservedRanges, ip):
return classReserved
case ip.IsPrivate() || ip.IsLoopback() || cgnatRange.Contains(ip):
case ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || cgnatRange.Contains(ip) || inRange(restrictedRanges, ip):
return classRestricted
}
return classPublic
}
func inRange(p []netip.Prefix, ip netip.Addr) bool {
return slices.ContainsFunc(p, func(p netip.Prefix) bool { return p.Contains(ip) })
}
+3 -3
View File
@@ -56,7 +56,7 @@ func WithBlock(hostList, key string) Option {
}
}
// WithLocalNeedsIPAllow requires private, loopback and CGNAT targets to match an IP allow entry (CIDR or named range), a host name match is not enough.
// WithLocalNeedsIPAllow requires non-public targets (private, loopback, link-local, CGNAT and special-use ranges) to match an IP allow entry (CIDR or named range), a host name match is not enough.
func WithLocalNeedsIPAllow() Option {
return func(p *Policy) {
p.localNeedsIPAllow = true
@@ -140,9 +140,9 @@ func (p *Policy) allowCheck(host string, ip netip.AddrPort, class addrClass) err
return p.notAllowedError(denyTarget(host, ip))
}
if !hostnameOk {
return fmt.Errorf("%s needs an explicit IP allow entry (private/loopback/CGNAT)", denyTarget(host, ip))
return fmt.Errorf("%s needs an explicit IP allow entry (non-public address)", denyTarget(host, ip))
}
return fmt.Errorf("%s needs an explicit allow entry (private/loopback/CGNAT)", denyTarget(host, ip))
return fmt.Errorf("%s needs an explicit allow entry (non-public address)", denyTarget(host, ip))
}
func (p *Policy) blockReason(host string, ip netip.AddrPort) error {
+15 -5
View File
@@ -33,8 +33,16 @@ func TestCheckAddr(t *testing.T) {
{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: "reserved denied by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254"},
{name: "reserved denied ipv4-mapped", allow: "169.254.0.0/16", ip: "::ffff:169.254.169.254"},
{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},
@@ -46,7 +54,8 @@ func TestCheckAddr(t *testing.T) {
{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 by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", 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},
} {
@@ -63,7 +72,7 @@ func TestCheckAddr(t *testing.T) {
mode = Strict
}
err := NewPolicy("test", mode, opts...).checkAddr(tc.host, netip.AddrPortFrom(addr, 80))
assert.Equal(t, tc.want, err == nil, "%s: %v", tc.name, err)
assert.Equal(t, tc.want, err == nil, "%s (%s): %v", tc.name, tc.ip, err)
}
}
@@ -115,8 +124,9 @@ func TestCheckHostIPs(t *testing.T) {
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", "100.100.100.200", "168.63.129.16", "169.254.169.254", "192.0.2.1", "192.88.99.1", "198.18.0.1",
"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",
} {