Files
gitea/modules/egress/policy/matchlist.go
T
TheFox0x7andwxiaoguang bd2a6c40d7 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 <thefox0x7@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-06 14:36:06 +08:00

471 lines
14 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package policy
import (
"errors"
"fmt"
"net"
"net/netip"
"slices"
"strconv"
"strings"
"sync"
"unicode/utf8"
)
// matchList keeps a list of IPs and hostnames
type matchList struct {
patterns []domainRule
ipv6List, ipv4List []prefixRule
rejected []string
}
type portRange struct {
start uint16
end uint16
}
func (p *portRange) Contains(port uint16) bool {
return port >= p.start && port <= p.end
}
type prefixRule struct {
prefix netip.Prefix
portRanges []portRange
}
func (p *prefixRule) Contains(port netip.AddrPort) bool {
return p.prefix.Contains(port.Addr()) && slices.ContainsFunc(p.portRanges, func(r portRange) bool {
return r.Contains(port.Port())
})
}
type domainRule struct {
pattern string
portRanges []portRange
}
func (p *domainRule) Contains(hostname string, port uint16) bool {
return matchDomain(p.pattern, hostname) && slices.ContainsFunc(p.portRanges, func(r portRange) bool {
return r.Contains(port)
})
}
const (
AliasPrivate = "private"
AliasLoopback = "loopback"
)
var namedRanges = sync.OnceValue(func() map[string][]netip.Prefix {
base := map[string][]string{
// private ranges and CGNAT
AliasPrivate: {"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", "100.64.0.0/10"},
AliasLoopback: {"127.0.0.0/8", "::1/128"},
}
out := make(map[string][]netip.Prefix, 2)
for name, ranges := range base {
out[name] = make([]netip.Prefix, 0, len(ranges))
for _, r := range ranges {
out[name] = append(out[name], netip.MustParsePrefix(r))
}
}
return out
})
type (
AllowList struct{ matchList }
BlockList struct{ matchList }
)
type listEntry interface {
addTo(*matchList)
}
func (p prefixRule) addTo(m *matchList) {
if p.prefix.Addr().Is4() {
m.ipv4List = append(m.ipv4List, p)
} else {
m.ipv6List = append(m.ipv6List, p)
}
}
func (p domainRule) addTo(m *matchList) { m.patterns = append(m.patterns, p) }
type aliasExpansion []prefixRule
func (r aliasExpansion) addTo(m *matchList) {
for _, pr := range r {
pr.addTo(m)
}
}
func parseList(hostlist string, mode Mode, isBlocklist bool) (list matchList) {
for entry := range strings.SplitSeq(hostlist, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
rule, err := parseRule(entry, mode, isBlocklist)
if err != nil {
list.rejected = append(list.rejected, err.Error())
continue
}
rule.addTo(&list)
}
return list
}
func splitEntry(entry string) (target, portSpec string, err error) {
if strings.HasPrefix(entry, "[") {
end := strings.IndexByte(entry, ']')
if end < 0 {
return "", "", errors.New("missing closing bracket")
}
target = entry[1:end]
if target == "" {
return "", "", errors.New("empty host")
}
rest := entry[end+1:]
if rest == "" {
return target, "", nil
}
if !strings.HasPrefix(rest, ":") {
return "", "", fmt.Errorf("unexpected %q after bracketed target", rest)
}
portSpec = rest[1:]
if portSpec == "" {
return "", "", errors.New("empty port")
}
return target, portSpec, nil
}
// check for host:port
if strings.Count(entry, ":") == 1 {
i := strings.IndexByte(entry, ':')
target, portSpec = entry[:i], entry[i+1:]
switch {
case target == "":
return "", "", fmt.Errorf("empty host: '%s'", entry)
case portSpec == "":
return "", "", fmt.Errorf("empty port: '%s'", entry)
}
return target, portSpec, nil
}
// Portless: patterns, IPs, CIDRs - and multi-colon forms
return entry, "", nil
}
func parseRule(entry string, mode Mode, isBlocklist bool) (listEntry, error) {
target, portSpec, err := splitEntry(entry)
if err != nil {
return nil, fmt.Errorf("failed to split entry %s: %w", entry, err)
}
portRanges, err := parsePortSpec(portSpec, mode, isBlocklist)
if err != nil {
return nil, fmt.Errorf("invalid port syntax on entry %s: %w", entry, err)
}
return classifyTarget(target, portRanges)
}
func classifyTarget(target string, ranges []portRange) (listEntry, error) {
target = strings.ToLower(strings.TrimSpace(target))
target = strings.TrimSuffix(target, ".")
if expanded, ok := newNamedRanges(target, ranges); ok {
return expanded, nil
}
if prefix, err := newPrefixRule(target, ranges); !errors.Is(err, errNotIP) {
return prefix, err
}
return newDomainRule(target, ranges)
}
var errNotIP = errors.New("not an IP address")
func newPrefixRule(target string, ranges []portRange) (prefixRule, error) {
if strings.ContainsRune(target, '/') {
prefix, err := netip.ParsePrefix(target)
if err != nil {
if strings.ContainsRune(target, ':') {
return prefixRule{}, fmt.Errorf("invalid IPv6 CIDR %q (unbracketed IPv6 with port? use [addr] or [addr]:port): %w", target, err)
}
return prefixRule{}, fmt.Errorf("invalid CIDR %q: %w", target, err)
}
if prefix.Bits() == 0 {
return prefixRule{}, fmt.Errorf("catch-all CIDR %q covers every address and is not allowed", target)
}
if masked := prefix.Masked(); masked != prefix {
return prefixRule{}, fmt.Errorf("invalid CIDR %q: host bits must be zero, use %q", target, masked)
}
return prefixRule{prefix: prefix, portRanges: ranges}, nil
}
addr, addrErr := netip.ParseAddr(target)
if addrErr == nil {
if addr.Zone() != "" {
return prefixRule{}, fmt.Errorf("invalid address %q: address zone is not dialable", target)
}
addr = addr.Unmap()
return prefixRule{prefix: netip.PrefixFrom(addr, addr.BitLen()), portRanges: ranges}, nil
}
if !ipShaped(target) {
return prefixRule{}, errNotIP
}
return prefixRule{}, fmt.Errorf("target %q looks like an IP address but is not a valid one: %w", target, addrErr)
}
func ipShaped(target string) bool {
if strings.ContainsRune(target, ':') {
return true
}
return strings.ContainsRune(target, '.') && !strings.ContainsFunc(target, func(r rune) bool {
return (r < '0' || r > '9') && r != '.'
})
}
// newNamedRanges expands a named range alias into one prefixRule per CIDR. ok
// is false when target is not an alias.
func newNamedRanges(target string, ranges []portRange) (aliasExpansion, bool) {
prefixes, ok := namedRanges()[target]
if !ok {
return nil, false
}
expanded := make(aliasExpansion, len(prefixes))
for i, prefix := range prefixes {
expanded[i] = prefixRule{prefix: prefix, portRanges: ranges}
}
return expanded, true
}
// newDomainRule classifies a hostname pattern.
func newDomainRule(target string, ranges []portRange) (domainRule, error) {
if target == "*" {
return domainRule{}, fmt.Errorf("catch-all host pattern %q matches every host and is not allowed", target)
}
if target == "external" {
return domainRule{}, errors.New(`the "external" builtin was replaced by EGRESS_MODE = lax`)
}
if !validDomainPattern(target) {
return domainRule{}, fmt.Errorf("target %q is not an IP, CIDR, named range, or valid hostname pattern", target)
}
return domainRule{pattern: target, portRanges: ranges}, nil
}
// parsePortSpec parses a port spec: "" means the context default per defaultPorts, "*"
// all ports, otherwise a port, a "lo-hi" range, or a bracketed "[p|p-p|...]" set.
func parsePortSpec(spec string, mode Mode, isBlocklist bool) ([]portRange, error) {
if spec == "" {
return defaultPorts(mode, isBlocklist), nil
}
if spec == "*" {
return []portRange{{start: 0, end: 65535}}, nil
}
if strings.HasPrefix(spec, "[") && strings.HasSuffix(spec, "]") {
inner := spec[1 : len(spec)-1]
if inner == "" {
return nil, fmt.Errorf("empty port set %q", spec)
}
var ranges []portRange
for item := range strings.SplitSeq(inner, "|") {
r, err := parsePortItem(item)
if err != nil {
return nil, err
}
ranges = append(ranges, r)
}
return ranges, nil
}
r, err := parsePortItem(spec)
if err != nil {
return nil, err
}
return []portRange{r}, nil
}
// parsePortItem parses "p" or "lo-hi" with 1 <= lo <= hi <= 65535. The parts
// are trimmed, so a bracketed set may be spaced ("[80 | 443]").
func parsePortItem(item string) (portRange, error) {
lo, hi, isRange := strings.Cut(item, "-")
start, err := parsePort(strings.TrimSpace(lo))
if err != nil {
return portRange{}, err
}
end := start
if isRange {
end, err = parsePort(strings.TrimSpace(hi))
if err != nil {
return portRange{}, err
}
}
if start > end {
return portRange{}, fmt.Errorf("reversed port range %q", item)
}
return portRange{start: start, end: end}, nil
}
func parsePort(s string) (uint16, error) {
p, err := strconv.ParseUint(s, 10, 16)
if err != nil || p == 0 {
return 0, fmt.Errorf("invalid port %q", s)
}
return uint16(p), nil
}
// defaultPorts: a portless block entry covers every port, a portless allow entry covers every port in Lax mode and only the web ports in Strict.
func defaultPorts(mode Mode, isBlocklist bool) []portRange {
if isBlocklist || mode == Lax {
return []portRange{{start: 0, end: 65535}}
}
return []portRange{{start: 80, end: 80}, {start: 443, end: 443}}
}
func NewAllowList(hostList string, mode Mode) *AllowList {
return &AllowList{parseList(hostList, mode, false)}
}
func NewBlockList(hostList string) *BlockList {
return &BlockList{parseList(hostList, Strict, true)}
}
func (m *matchList) MatchHostname(host string, port uint16) bool {
host = strings.ToLower(strings.TrimSpace(host))
if hostname, _, err := net.SplitHostPort(host); err == nil {
host = hostname
}
host = strings.TrimSuffix(host, ".")
for _, pattern := range m.patterns {
if pattern.Contains(host, port) {
return true
}
}
return false
}
// matchDomain implements the domain-matching model of x/net/http/httpproxy
// (dot-anchored suffix), like curl's and Go's NO_PROXY:
// - "example.com" matches that host and any subdomain, dot-anchored so
// "notexample.com" never matches
// - "*.example.com" and ".example.com" match only subdomains, apex
// excluded — the two spellings are equivalent, mirroring httpproxy's normalization
func matchDomain(pattern, host string) bool {
if strings.HasPrefix(pattern, "*.") || strings.HasPrefix(pattern, ".") {
suffix := strings.TrimPrefix(pattern, "*")
return strings.HasSuffix(host, suffix) && len(host) > len(suffix)
}
return pattern == host || strings.HasSuffix(host, "."+pattern)
}
// validDomainPattern reports whether p is a usable domain pattern: any glob
// metacharacter beyond the documented forms (?, character classes, backslash
// escapes, mid-pattern *) is rejected instead of silently never matching, and
// so is any non-ASCII rune, which byte-wise matching against a resolved
// hostname could never hit (IDN names must be given as punycode).
func validDomainPattern(p string) bool {
if strings.HasPrefix(p, "*.") || strings.HasPrefix(p, ".") {
p = p[1:]
}
if p == "" || strings.ContainsFunc(p, func(r rune) bool { return r >= utf8.RuneSelf }) {
return false
}
return !strings.ContainsAny(p, " *?[]/:\\")
}
// Rejected returns the entries dropped at parse, so callers can log them at startup.
func (m *matchList) Rejected() []string {
return slices.Clone(m.rejected)
}
// MatchIPAddr checks if the given IP is in the list.
func (m *matchList) MatchIPAddr(ip netip.AddrPort) bool {
match := m.ipv4List
if ip.Addr().Is6() {
match = m.ipv6List
}
for _, prefix := range match {
if prefix.Contains(ip) {
return true
}
}
return false
}
func (m *matchList) IsEmpty() bool {
return len(m.patterns) == 0 && len(m.ipv4List) == 0 && len(m.ipv6List) == 0
}
type addrClass uint8
const (
classPublic addrClass = iota
classRestricted
classReserved
)
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"
"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))
}
return ranges
}()
// classifyAddr reports the class of a canonical address.
func classifyAddr(ip netip.Addr) addrClass {
switch {
case ip.Zone() != "" || !ip.IsLoopback() && inRange(reservedRanges, ip):
return classReserved
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) })
}