Files
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

253 lines
8.5 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package policy
import (
"cmp"
"context"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"sync"
"syscall"
"time"
)
type Mode uint8
const (
// Lax mode allows public and unresolved targets by default, restricted ones need an allow entry
Lax Mode = iota
// Strict mode requires every target to match an allow entry
Strict
)
// ErrDenied wraps a policy rejection, so callers can tell it from a network failure.
var ErrDenied = errors.New("denied by egress policy")
type Policy struct {
usage string
mode Mode
allow AllowList
block BlockList
allowKey, blockKey string // the settings the lists were read from, named in rejections
localNeedsIPAllow bool
proxyFunc func(*http.Request) (*url.URL, error)
proxyAddrs sync.Map // dial addresses proxyFunc returned, the operator's proxies are exempt from the lists
}
type Option func(*Policy)
// WithAllow sets the allow list from the setting named by key
func WithAllow(hostList, key string) Option {
return func(p *Policy) {
p.allow, p.allowKey = *NewAllowList(hostList, p.mode), key
}
}
func WithBlock(hostList, key string) Option {
return func(p *Policy) {
p.block, p.blockKey = *NewBlockList(hostList), key
}
}
// 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
}
}
func WithProxy(proxyFunc func(*http.Request) (*url.URL, error)) Option {
return func(p *Policy) {
p.proxyFunc = proxyFunc
}
}
// NewPolicy compiles a policy enforced on every outbound dial, usage names the caller in rejections.
// The mode sets the policy posture and the default ports of portless allow entries.
func NewPolicy(usage string, mode Mode, opts ...Option) *Policy {
p := &Policy{usage: usage, mode: mode} // the zero lists are valid and behave as empty ones
for _, opt := range opts {
opt(p)
}
return p
}
// proxyPorts maps the proxy schemes net/http speaks to their default ports, it speaks HTTP to any other
var proxyPorts = map[string]string{"http": "80", "https": "443", "socks5": "1080", "socks5h": "1080"}
// targetPorts maps target schemes to their default dial port, http and anything else dials 80
var targetPorts = map[string]uint16{"https": 443, "git": 9418}
// dialPort resolves the port a target URL is dialed on, empty port means the scheme default
func dialPort(u *url.URL) uint16 {
if port, err := strconv.ParseUint(u.Port(), 10, 16); err == nil && port != 0 {
return uint16(port)
}
return cmp.Or(targetPorts[u.Scheme], 80)
}
// ProxyDialAddr returns the address the transport dials for proxy URL u
func ProxyDialAddr(u *url.URL) string {
return net.JoinHostPort(u.Hostname(), cmp.Or(u.Port(), proxyPorts[u.Scheme]))
}
func (p *Policy) blockedError(target string) error {
return fmt.Errorf("%s can not call blocked HTTP servers (check your %s setting), deny '%s'", p.usage, p.blockKey, target)
}
func (p *Policy) notAllowedError(target string) error {
return fmt.Errorf("%s can only call allowed HTTP servers (check your %s setting), deny '%s'", p.usage, p.allowKey, target)
}
func (p *Policy) checkAddr(host string, ip netip.AddrPort) error {
ip = netip.AddrPortFrom(ip.Addr().Unmap(), ip.Port())
class := classifyAddr(ip.Addr())
if class == classReserved {
return fmt.Errorf("%s can not call reserved addresses, deny '%s'", p.usage, denyTarget(host, ip))
}
return p.gate(host, ip, class)
}
// gate enforces the deny list, then the allow list, lax mode exempts public and unresolved targets, the dial-time check classifies the resolved address
func (p *Policy) gate(host string, ip netip.AddrPort, class addrClass) error {
if err := p.blockReason(host, ip); err != nil {
return err
}
if p.mode == Lax && (class == classPublic) {
return nil
}
return p.allowCheck(host, ip, class)
}
// allowCheck returns nil when the allow list names the target, else an error naming the allow entry it needs
func (p *Policy) allowCheck(host string, ip netip.AddrPort, class addrClass) error {
if p.allow.MatchIPAddr(ip) {
return nil
}
// with localNeedsIPAllow a restricted target needs an IP entry, a hostname match is not enough
hostnameOk := !p.localNeedsIPAllow || class != classRestricted
if hostnameOk && p.allow.MatchHostname(host, ip.Port()) {
return nil
}
if p.mode == Strict {
return p.notAllowedError(denyTarget(host, ip))
}
if !hostnameOk {
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 (non-public address)", denyTarget(host, ip))
}
func (p *Policy) blockReason(host string, ip netip.AddrPort) error {
if p.block.MatchHostname(host, ip.Port()) || p.block.MatchIPAddr(ip) {
return p.blockedError(denyTarget(host, ip))
}
return nil
}
// denyTarget renders the checked address for denial messages, host is empty for an IP literal, ip is invalid for an unresolved host name
func denyTarget(host string, ip netip.AddrPort) string {
if !ip.Addr().IsValid() {
return host
}
if host == "" {
return ip.Addr().String()
}
return fmt.Sprintf("%s(%s)", host, ip.Addr())
}
// CheckHost pre-screens a target URL whose host name may be unresolved or an IP literal.
func (p *Policy) CheckHost(u *url.URL) error {
host, port := u.Hostname(), dialPort(u)
ip, err := netip.ParseAddr(host)
addrPort := netip.AddrPortFrom(ip, port)
if err == nil {
return p.checkAddr("", addrPort)
}
return p.checkAddr(host, addrPort) // invalid ip doesn't match anything
}
// CheckHostIPs reports whether u's host may be called, it resolves the host and every address must pass as the dialer may pick any.
func (p *Policy) CheckHostIPs(u *url.URL) error {
// hosts behind a proxy may have no DNS resolver, the name-only CheckHost screen still applies
ips, _ := net.LookupIP(u.Hostname())
return p.checkHostIPs(u, ips)
}
func (p *Policy) checkHostIPs(u *url.URL, ips []net.IP) error {
if len(ips) == 0 {
return p.CheckHost(u)
}
host, port := u.Hostname(), dialPort(u)
for _, ip := range ips {
addr, _ := netip.AddrFromSlice(ip)
addrPort := netip.AddrPortFrom(addr, port)
if err := p.checkAddr(host, addrPort); err != nil {
return err
}
}
return nil
}
// NewDialContext returns a dial function that checks the resolved address at connect time, so DNS rebinding can't bypass it.
func (p *Policy) NewDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) {
return p.dialContext(false)
}
// dialContext can let through the proxies the selector returned, for a transport dialing proxies and targets alike
func (p *Policy) dialContext(allowProxies bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
if _, isProxy := p.proxyAddrs.Load(addr); !allowProxies || !isProxy {
host, _, err := net.SplitHostPort(addr) // the requested host, also on redirects where the request's Host is empty
if err != nil {
return nil, err
}
dialer.Control = func(_, ipAddr string, _ syscall.RawConn) error {
addrPort, err := netip.ParseAddrPort(ipAddr)
if err != nil {
return fmt.Errorf("%s can only call HTTP servers via TCP, deny '%s(%s)': %w", p.usage, host, ipAddr, err)
}
if err := p.checkAddr(host, addrPort); err != nil {
return fmt.Errorf("%w: %w", ErrDenied, err)
}
return nil
}
}
return dialer.DialContext(ctx, network, addr)
}
}
// Proxy selects the proxy for req and lets the dialer reach it.
func (p *Policy) Proxy(req *http.Request) (proxyURL *url.URL, err error) {
if p.proxyFunc != nil {
proxyURL, err = p.proxyFunc(req)
}
if proxyURL != nil {
if _, ok := proxyPorts[proxyURL.Scheme]; !ok {
return nil, fmt.Errorf("unsupported proxy scheme %q, use http, https or socks5", proxyURL.Scheme)
}
p.proxyAddrs.LoadOrStore(ProxyDialAddr(proxyURL), struct{}{})
}
return proxyURL, err
}
func (p *Policy) NewHTTPTransport() *http.Transport {
return &http.Transport{
Proxy: p.Proxy,
DialContext: p.dialContext(true),
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
}