From bd2a6c40d716e5cc75bb9521aec662c44ed68dcb Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Tue, 6 Oct 2026 08:36:06 +0200 Subject: [PATCH] fix(egress): expose more ranges as restricted rather than reserved (#39560) 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 Co-authored-by: wxiaoguang --- custom/conf/app.example.ini | 10 ++-- modules/egress/policies.go | 2 + modules/egress/policies_test.go | 24 +++++++++ modules/egress/policy/matchlist.go | 76 ++++++++++++++++------------ modules/egress/policy/policy.go | 6 +-- modules/egress/policy/policy_test.go | 20 ++++++-- 6 files changed, 93 insertions(+), 45 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 6c5eaee0818..716edbd4d6d 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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 = diff --git a/modules/egress/policies.go b/modules/egress/policies.go index 317ded5bf1f..398377bc937 100644 --- a/modules/egress/policies.go +++ b/modules/egress/policies.go @@ -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())) } diff --git a/modules/egress/policies_test.go b/modules/egress/policies_test.go index fb5d8f76b15..138427eec14 100644 --- a/modules/egress/policies_test.go +++ b/modules/egress/policies_test.go @@ -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")() diff --git a/modules/egress/policy/matchlist.go b/modules/egress/policy/matchlist.go index 1bd3077ce05..c03ab32f817 100644 --- a/modules/egress/policy/matchlist.go +++ b/modules/egress/policy/matchlist.go @@ -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) }) +} diff --git a/modules/egress/policy/policy.go b/modules/egress/policy/policy.go index e5f74f1b02b..2644085c4f4 100644 --- a/modules/egress/policy/policy.go +++ b/modules/egress/policy/policy.go @@ -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 { diff --git a/modules/egress/policy/policy_test.go b/modules/egress/policy/policy_test.go index 3d1b413a2d8..6b31e1ded58 100644 --- a/modules/egress/policy/policy_test.go +++ b/modules/egress/policy/policy_test.go @@ -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", } {