mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-21 09:06:46 +09: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>
471 lines
14 KiB
Go
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) })
|
|
}
|