mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-01 20:59:45 +09:00
1b1274486c
Introduces gitproxy module which spawns a small forward proxy as scanner for git calls Replaces hostmatcher with matchlist which supports port rules Deprecates ALLOWED_DOMAINS/BLOCKED_DOMAINS and ALLOW_LOCALNETWORKS settings in migration in favor of full names we have in security configs. Removes `external` preset in favor of lax/strict modes, strict mode requiring explicit ports if they aren't standard http/s ones. Breaking changes: - `external` preset no longer works as deny rule. To enforce that, use `strict` mode and allow ranges to connect to - Wildcards are no longer accepted in IP addresses - `*` is no longer allowed as entry in lists - domain rules now use curl like syntax `*.example.com` matching subdomains but not `example.com`, `example.com` matching itself and all subdomains. `example.*` is not a valid rule - In the default `lax` mode, `[security] ALLOWED_HOST_LIST` no longer restricts public hosts, set `EGRESS_MODE = strict` to keep an exclusive list. A startup warning flags this - Invalid list entries are logged at startup, invalid `BLOCKED_HOST_LIST`/`BLOCKED_DOMAINS` entries stop it Docs: https://gitea.com/gitea/docs/pulls/557 Signed-off-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: bircni <bircni@icloud.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
461 lines
13 KiB
Go
461 lines
13 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"
|
|
"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
|
|
} {
|
|
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() && slices.ContainsFunc(reservedRanges, func(p netip.Prefix) bool { return p.Contains(ip) }):
|
|
return classReserved
|
|
case ip.IsPrivate() || ip.IsLoopback() || cgnatRange.Contains(ip):
|
|
return classRestricted
|
|
}
|
|
return classPublic
|
|
}
|