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", } {