fix(git)!: use internal proxy for all git operations (#39426)

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>
This commit is contained in:
TheFox0x7
2026-09-29 14:43:36 +02:00
committed by GitHub
parent ebcb0150d1
commit 1b1274486c
57 changed files with 2356 additions and 1106 deletions
+3 -9
View File
@@ -8,9 +8,7 @@ import (
"sync"
"time"
"gitea.dev/modules/hostmatcher"
"gitea.dev/modules/proxy"
"gitea.dev/modules/setting"
"gitea.dev/modules/egress"
"github.com/yohcop/openid-go"
)
@@ -26,15 +24,11 @@ var (
nonceStore = openid.NewSimpleNonceStore()
discoveryCache = newTimedDiscoveryCache(24 * time.Hour)
// openIDInstance does discovery/verification via an SSRF-protected client, so a user-supplied
// OpenID identifier can't reach internal/loopback/reserved addresses. It honors the operator's
// [security] ALLOWED_HOST_LIST (empty defaults to "external"), matching the avatar/webhook/migration
// clients, and validates the proxy path too. Lazy: reads proxy/settings once.
// openIDInstance keeps user-supplied OpenID identifiers within [security] ALLOWED_HOST_LIST
openIDInstance = sync.OnceValue(func() *openid.OpenID {
allowList := hostmatcher.ParseHostMatchList("security.ALLOWED_HOST_LIST", setting.Security.AllowedHostList)
return openid.NewOpenID(&http.Client{
Timeout: 30 * time.Second,
Transport: hostmatcher.NewHTTPTransport("openid", allowList, nil, proxy.Proxy(), setting.Proxy.ProxyURLFixed, nil),
Transport: egress.NewSecurityPolicy("openid").NewHTTPTransport(),
})
})
)
+117
View File
@@ -0,0 +1,117 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package egress
import (
"cmp"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"gitea.dev/modules/egress/policy"
"gitea.dev/modules/proxy"
"gitea.dev/modules/setting"
"golang.org/x/net/http/httpproxy"
)
func NewMigrationPolicy() *policy.Policy {
return newMigrationPolicy(proxy.Proxy())
}
// NewGitPolicy is the migration policy for the git proxy, which keeps git's own proxy choice
func NewGitPolicy() (*policy.Policy, error) {
selectProxy, err := gitProxySelector()
if err != nil {
return nil, err
}
return newMigrationPolicy(selectProxy), nil
}
// gitProxySelector picks proxies like git did: [git.config] http.proxy, else a [proxy] PROXY_URL, else the environment incl. ALL_PROXY
func gitProxySelector() (func(*http.Request) (*url.URL, error), error) {
env := httpproxy.FromEnvironment()
if rawURL, ok := setting.GitConfig.Options["http.proxy"]; ok {
gitProxy, err := normalizeGitProxy(rawURL)
if err != nil {
return nil, fmt.Errorf("[git.config] http.proxy: %w", err)
}
env.HTTPProxy, env.HTTPSProxy = gitProxy, gitProxy
return requestProxy(env), nil
}
if setting.Proxy.Enabled && setting.Proxy.ProxyURL != "" {
return proxy.Proxy(), nil
}
allProxy := cmp.Or(os.Getenv("all_proxy"), os.Getenv("ALL_PROXY"))
env.HTTPProxy, env.HTTPSProxy = cmp.Or(env.HTTPProxy, allProxy), cmp.Or(env.HTTPSProxy, allProxy)
return requestProxy(env), nil
}
func requestProxy(cfg *httpproxy.Config) func(*http.Request) (*url.URL, error) {
proxyFunc := cfg.ProxyFunc()
return func(req *http.Request) (*url.URL, error) { return proxyFunc(req.URL) }
}
// normalizeGitProxy reads a proxy URL the way git reads http.proxy: http is the default scheme, 1080 curl's default port
func normalizeGitProxy(rawURL string) (string, error) {
if rawURL == "" {
return "", nil
}
if !strings.Contains(rawURL, "://") {
rawURL = "http://" + rawURL
}
proxyURL, err := url.Parse(rawURL)
if err != nil {
return "", errors.New("invalid URL") // the parse error would echo its credentials
}
if proxyURL.Scheme == "http" && proxyURL.Port() == "" {
proxyURL.Host = net.JoinHostPort(proxyURL.Hostname(), "1080")
}
return proxyURL.String(), nil
}
func newMigrationPolicy(selectProxy func(*http.Request) (*url.URL, error)) *policy.Policy {
return policy.NewPolicy("migrations", policyMode(setting.Migrations.EgressMode),
policy.WithAllow(setting.Migrations.AllowedHostList, "migrations.ALLOWED_HOST_LIST"),
policy.WithBlock(setting.Migrations.BlockedHostList, "migrations.BLOCKED_HOST_LIST"),
policy.WithLocalNeedsIPAllow(),
policy.WithProxy(selectProxy))
}
func NewWebhookPolicy() *policy.Policy {
var p *policy.Policy
selectProxy := proxy.WebHookProxy()
if webhookProxy := setting.Webhook.ProxyURLFixed; webhookProxy != nil {
next := selectProxy
selectProxy = func(req *http.Request) (*url.URL, error) {
u, err := next(req)
if err == nil && u == webhookProxy {
err = p.CheckHost(req.URL) // the webhook proxy resolves the target, so only its name can be checked
}
return u, err
}
}
p = policy.NewPolicy("webhook", policyMode(setting.Security.EgressMode),
policy.WithAllow(setting.Webhook.AllowedHostList, "security.ALLOWED_HOST_LIST"),
policy.WithProxy(selectProxy))
return p
}
func NewSecurityPolicy(usage string) *policy.Policy {
return policy.NewPolicy(usage, policyMode(setting.Security.EgressMode),
policy.WithAllow(setting.Security.AllowedHostList, "security.ALLOWED_HOST_LIST"),
policy.WithProxy(proxy.Proxy()))
}
func policyMode(mode string) policy.Mode {
if mode == "strict" {
return policy.Strict
}
return policy.Lax
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package egress
import (
"net/http"
"net/url"
"testing"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
require.NoError(t, err)
return u
}
func TestNewMigrationPolicy(t *testing.T) {
defer test.MockVariableValue(&setting.Migrations)()
for _, tc := range []struct {
allow, block, mode, target string
want bool
}{
{target: "https://1.2.3.4", want: true},
{allow: "github.com", target: "https://10.0.0.1"}, // a hostname allow doesn't cover a private IP
{allow: "github.com", target: "https://8.8.8.8", want: true}, // lax exempts public targets
{allow: "github.com", mode: "strict", target: "https://8.8.8.8"},
{block: "8.8.0.0/16", target: "https://8.8.8.8"},
} {
setting.Migrations.AllowedHostList, setting.Migrations.BlockedHostList, setting.Migrations.EgressMode = tc.allow, tc.block, tc.mode
u, err := url.Parse(tc.target)
require.NoError(t, err)
err = NewMigrationPolicy().CheckHostIPs(u)
assert.Equal(t, tc.want, err == nil, "%+v: %v", tc, err)
}
}
func TestWebhookPolicyProxy(t *testing.T) {
proxyURL := &url.URL{Scheme: "http", Host: "localhost:8080"}
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
defer test.MockVariableValue(&setting.Webhook.AllowedHostList, "discordapp.com,s.discordapp.com")()
defer test.MockVariableValue(&setting.Webhook.ProxyURL, proxyURL.String())()
defer test.MockVariableValue(&setting.Webhook.ProxyURLFixed, proxyURL)()
defer test.MockVariableValue(&setting.Webhook.ProxyHosts, []string{"*.discordapp.com", "discordapp.com"})()
selectProxy := NewWebhookPolicy().NewHTTPTransport().Proxy
for target, want := range map[string]string{
"https://discordapp.com/api/webhooks/xxxxxxxxx/xxxxxxxxxxxxxxxxxxx": proxyURL.String(),
"http://s.discordapp.com/assets/xxxxxx": proxyURL.String(),
"http://github.com/a/b": "",
"http://www.discordapp.com/assets/xxxxxx": proxyURL.String(),
} {
req, err := http.NewRequest(http.MethodPost, target, nil)
require.NoError(t, err)
req.Host = ""
u, err := selectProxy(req)
require.NoError(t, err, target)
if want == "" {
assert.Nil(t, u, target)
} else {
assert.Equal(t, want, u.String(), target)
}
}
}
func TestSecurityPolicy(t *testing.T) {
defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")()
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
lax := NewSecurityPolicy("test")
assert.NoError(t, lax.CheckHost(mustURL(t, "https://avatars.example.com")))
assert.NoError(t, lax.CheckHost(mustURL(t, "https://8.8.8.8"))) // lax exempts public targets
assert.Error(t, lax.CheckHost(mustURL(t, "https://10.0.0.1"))) // restricted targets still need an allow entry
setting.Security.EgressMode = "strict"
strict := NewSecurityPolicy("test")
assert.NoError(t, strict.CheckHost(mustURL(t, "https://avatars.example.com")))
assert.Error(t, strict.CheckHost(mustURL(t, "https://8.8.8.8")))
}
func TestNewGitPolicy(t *testing.T) {
gitConfig := map[string]string{"http.proxy": "proxy.corp"}
defer test.MockVariableValue(&setting.GitConfig.Options, gitConfig)()
gitPolicy, err := NewGitPolicy()
require.NoError(t, err)
selected, err := gitPolicy.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "git.example.com"}})
require.NoError(t, err)
assert.Equal(t, "http://proxy.corp:1080", selected.String())
gitConfig["http.proxy"] = "http://user:secret@[::1"
_, err = NewGitPolicy()
require.Error(t, err)
assert.NotContains(t, err.Error(), "secret")
}
+460
View File
@@ -0,0 +1,460 @@
// 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
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package policy
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMatchHostname(t *testing.T) {
for _, tc := range []struct {
pattern, host string
port uint16
want bool
}{
// a bare entry matches its host and subdomains like NO_PROXY, a wildcard or leading dot matches subdomains only
{pattern: "example.com", host: "example.com", port: 80, want: true},
{pattern: "example.com", host: "sub.example.com", port: 80, want: true},
{pattern: "example.com", host: "notexample.com", port: 80}, // dot-anchored
{pattern: "*.example.com", host: "sub.example.com", port: 80, want: true},
{pattern: ".example.com", host: "sub.example.com", port: 80, want: true},
{pattern: "*.example.com", host: "example.com", port: 80}, // apex never matches
{pattern: "*.example.com", host: "notexample.com", port: 80},
{pattern: ".example.com", host: "notexample.com", port: 80},
// matching is case-insensitive and tolerates spaces, a port suffix and a trailing dot
{pattern: "example.com", host: " EXAMPLE.com.:8080 ", port: 80, want: true},
// strict entries without a port cover the web ports 80 and 443
{pattern: "example.com", host: "example.com", port: 443, want: true},
{pattern: "example.com", host: "example.com", port: 8080},
{pattern: "example.com:*", host: "example.com", port: 8080, want: true},
{pattern: "example.com:8080", host: "example.com", port: 8080, want: true},
{pattern: "example.com:8080", host: "example.com", port: 80},
{pattern: "example.com:[80|443-445]", host: "example.com", port: 444, want: true},
{pattern: "example.com:[80|443-445]", host: "example.com", port: 446},
} {
assert.Equalf(t, tc.want, NewAllowList(tc.pattern, Strict).MatchHostname(tc.host, tc.port), "pattern %q host %q port %d", tc.pattern, tc.host, tc.port)
}
// lax entries without a port cover every port
assert.True(t, NewAllowList("example.com", Lax).MatchHostname("example.com", 8080))
assert.True(t, NewAllowList(" , ", Strict).IsEmpty(), "blank entries are skipped")
assert.True(t, NewAllowList("", Strict).IsEmpty())
assert.False(t, NewAllowList("example.com", Strict).IsEmpty())
}
func TestMatchIPAddr(t *testing.T) {
for _, tc := range []struct {
pattern, ip string
port uint16
want bool
}{
{pattern: "10.0.0.0/8", ip: "10.1.2.3", port: 80, want: true},
{pattern: "10.0.0.0/8", ip: "11.1.2.3", port: 80},
// a bare IP entry carries the default ports like a hostname entry
{pattern: "192.168.1.1", ip: "192.168.1.1", port: 443, want: true},
{pattern: "192.168.1.1", ip: "192.168.1.1", port: 8080},
{pattern: "10.0.0.0/8:22", ip: "10.1.2.3", port: 22, want: true},
{pattern: "10.0.0.0/8:22", ip: "10.1.2.3", port: 80},
{pattern: "2001:db8::/64", ip: "2001:db8::1", port: 443, want: true},
{pattern: "2001:db8::/64", ip: "2001:db8::1", port: 8080},
{pattern: "[2001:db8::/64]:9418", ip: "2001:db8::1", port: 9418, want: true},
{pattern: "[2001:db8::/64]:9418", ip: "2001:db9::1", port: 9418},
{pattern: "[::1]:8080", ip: "::1", port: 8080, want: true},
{pattern: "[::1]:8080", ip: "::1", port: 80},
// named ranges expand to CIDRs
{pattern: "loopback", ip: "127.0.0.1", port: 80, want: true},
{pattern: "loopback", ip: "::1", port: 80, want: true},
{pattern: "loopback", ip: "10.1.2.3", port: 80},
{pattern: "private", ip: "100.64.0.1", port: 80, want: true}, // CGNAT
{pattern: "private", ip: "8.8.8.8", port: 80},
{pattern: "private:22", ip: "fd00::1", port: 22, want: true},
{pattern: "private:22", ip: "fd00::1", port: 80},
} {
addr := netip.AddrPortFrom(netip.MustParseAddr(tc.ip), tc.port)
assert.Equalf(t, tc.want, NewAllowList(tc.pattern, Strict).MatchIPAddr(addr), "pattern %q ip %s port %d", tc.pattern, tc.ip, tc.port)
}
}
func TestBlockListDefaultPorts(t *testing.T) {
// deny entries without a port cover every port, allow entries default to the web ports in strict mode
assert.True(t, NewBlockList("example.com").MatchHostname("example.com", 22))
assert.True(t, NewBlockList("10.0.0.0/8").MatchIPAddr(netip.AddrPortFrom(netip.MustParseAddr("10.0.0.5"), 12345)))
blocked := NewBlockList("example.com:22")
assert.True(t, blocked.MatchHostname("example.com", 22))
assert.False(t, blocked.MatchHostname("example.com", 80))
}
func TestRejectedEntries(t *testing.T) {
for _, tc := range []struct{ entry, wantErr string }{
{entry: "*", wantErr: `catch-all host pattern "*" matches every host and is not allowed`},
{entry: "external", wantErr: "EGRESS_MODE"},
{entry: "0.0.0.0/0", wantErr: `catch-all CIDR "0.0.0.0/0" covers every address and is not allowed`},
{entry: "::/0", wantErr: `catch-all CIDR "::/0" covers every address and is not allowed`},
{entry: "10.0.0.5/8", wantErr: `host bits must be zero, use "10.0.0.0/8"`},
{entry: "fe80::/64:80", wantErr: "unbracketed IPv6 with port"},
{entry: "[2001:db8::]/64", wantErr: `unexpected "/64" after bracketed target`},
{entry: "fe80::1%eth0", wantErr: "address zone is not dialable"},
{entry: "999.1.1.1", wantErr: `target "999.1.1.1" looks like an IP address but is not a valid one`},
{entry: "sub.*.example.com", wantErr: "is not an IP, CIDR, named range, or valid hostname pattern"},
{entry: "exämple.com", wantErr: "is not an IP, CIDR, named range, or valid hostname pattern"},
{entry: "example.com:99999", wantErr: `invalid port "99999"`},
{entry: "example.com:443-80", wantErr: `reversed port range "443-80"`},
{entry: "example.com:[]", wantErr: `empty port set "[]"`},
{entry: ":80", wantErr: "empty host: ':80'"},
{entry: "example.com:", wantErr: "empty port: 'example.com:'"},
{entry: "[::1", wantErr: "missing closing bracket"},
{entry: "[::1]:", wantErr: "empty port"},
{entry: "[]:80", wantErr: "empty host"},
} {
list := NewAllowList(tc.entry, Strict)
require.Lenf(t, list.Rejected(), 1, "entry %q", tc.entry)
assert.Containsf(t, list.Rejected()[0], tc.wantErr, "entry %q", tc.entry)
}
// the deny list rejects the same catch-alls, a rejected entry matches nothing
for _, entry := range []string{"*", "0.0.0.0/0", "::/0"} {
block := NewBlockList(entry)
require.Lenf(t, block.Rejected(), 1, "block entry %q", entry)
assert.Truef(t, block.IsEmpty(), "block entry %q", entry)
}
// a rejected entry drops only itself
list := NewAllowList("example.com, 10.0.0.5/8, example.org", Strict)
assert.True(t, list.MatchHostname("example.com", 80))
assert.True(t, list.MatchHostname("example.org", 443))
require.Len(t, list.Rejected(), 1)
assert.Contains(t, list.Rejected()[0], `use "10.0.0.0/8"`)
assert.Empty(t, NewAllowList("example.com", Strict).Rejected())
}
+252
View File
@@ -0,0 +1,252 @@
// 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 private, loopback and CGNAT targets 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 (private/loopback/CGNAT)", denyTarget(host, ip))
}
return fmt.Errorf("%s needs an explicit allow entry (private/loopback/CGNAT)", 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,
}
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package policy
import (
"net"
"net/http"
"net/netip"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckAddr(t *testing.T) {
for _, tc := range []struct {
name, allow, block, host, ip string
localNeedsIPAllow, strict, want bool
}{
// lax mode exempts public and unresolved targets, restricted ones need an allow entry
{name: "empty lists allow public", host: "github.com", ip: "8.8.8.8", want: true},
{name: "empty lists deny private", ip: "10.0.0.5"},
{name: "lax exempts public despite allow list", allow: "example.com", host: "other.com", ip: "8.8.8.8", want: true},
{name: "lax exempts unresolved despite allow list", allow: "example.com", host: "other.com", want: true},
{name: "block unresolved", block: "evil.example.com", host: "evil.example.com"},
{name: "block host", block: "evil.example.com", host: "evil.example.com", ip: "8.8.8.8"},
{name: "block cidr", block: "127.0.0.0/8", ip: "127.0.0.1"},
{name: "block ipv4-mapped", block: "loopback", ip: "::ffff:127.0.0.1"},
{name: "block nat64 by cidr", block: "10.0.0.0/8", ip: "64:ff9b::a00:1"},
{name: "allow loopback", allow: "loopback", ip: "127.0.0.1", want: true},
{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: "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},
// strict mode requires every target to match the allow list, an empty list denies all
{name: "strict denies public with empty list", ip: "8.8.8.8", strict: true},
{name: "strict denies unresolved with empty list", host: "example.com", strict: true},
{name: "strict allows unresolved host", allow: "example.com", host: "example.com", strict: true, want: true},
{name: "strict rejects unlisted public", allow: "loopback", ip: "8.8.8.8", strict: true},
{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 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},
} {
opts := []Option{WithAllow(tc.allow, "test.ALLOWED"), WithBlock(tc.block, "test.BLOCKED")}
if tc.localNeedsIPAllow {
opts = append(opts, WithLocalNeedsIPAllow())
}
var addr netip.Addr
if tc.ip != "" {
addr = netip.MustParseAddr(tc.ip)
}
mode := Lax
if tc.strict {
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)
}
}
// a policy without list options behaves like one with empty lists
func TestPolicyWithoutLists(t *testing.T) {
lax := NewPolicy("test", Lax)
assert.NoError(t, lax.checkAddr("", netip.AddrPortFrom(netip.MustParseAddr("8.8.8.8"), 80))) // public targets pass
assert.Error(t, lax.checkAddr("", netip.AddrPortFrom(netip.MustParseAddr("10.0.0.5"), 80))) // restricted targets need an entry
strict := NewPolicy("test", Strict)
err := strict.checkAddr("", netip.AddrPortFrom(netip.MustParseAddr("8.8.8.8"), 80)) // strict denies without an entry
assert.ErrorContains(t, err, "can only call allowed HTTP servers")
}
func TestDenialNamesSetting(t *testing.T) {
err := NewPolicy("webhook", Strict, WithAllow("example.com", "security.ALLOWED_HOST_LIST")).checkAddr("other.com", netip.AddrPortFrom(netip.MustParseAddr("8.8.8.8"), 80))
assert.EqualError(t, err, "webhook can only call allowed HTTP servers (check your security.ALLOWED_HOST_LIST setting), deny 'other.com(8.8.8.8)'")
err = NewPolicy("webhook", Lax, WithBlock("evil.com", "migrations.BLOCKED_HOST_LIST")).CheckHost(hostURL(t, "http://evil.com"))
assert.EqualError(t, err, "webhook can not call blocked HTTP servers (check your migrations.BLOCKED_HOST_LIST setting), deny 'evil.com'")
}
func hostURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
require.NoError(t, err)
return u
}
func TestCheckHostIPs(t *testing.T) {
ips := func(addrs ...string) (ret []net.IP) {
for _, addr := range addrs {
ret = append(ret, net.ParseIP(addr))
}
return ret
}
blocked := NewPolicy("test", Lax, WithAllow("private", ""), WithBlock("blocked.example.com", ""))
assert.NoError(t, blocked.checkHostIPs(hostURL(t, "http://example.com"), ips("8.8.8.8", "10.0.0.5")))
assert.NoError(t, blocked.checkHostIPs(hostURL(t, "http://example.com"), nil)) // unresolved name, the dialer re-checks the resolved address
assert.Error(t, blocked.checkHostIPs(hostURL(t, "http://blocked.example.com"), ips("8.8.8.8")))
assert.Error(t, blocked.checkHostIPs(hostURL(t, "http://blocked.example.com"), nil))
allowed := NewPolicy("test", Strict, WithAllow("10.0.0.0/8, *.example.com", ""))
assert.NoError(t, allowed.checkHostIPs(hostURL(t, "http://"), ips("10.0.0.5")))
assert.NoError(t, allowed.checkHostIPs(hostURL(t, "http://git.example.com"), ips("192.168.0.1")))
assert.NoError(t, allowed.checkHostIPs(hostURL(t, "http://git.example.com"), nil))
assert.Error(t, allowed.checkHostIPs(hostURL(t, "http://other.com"), ips("10.0.0.5", "192.168.0.1")))
assert.Error(t, allowed.checkHostIPs(hostURL(t, "http://other.com"), nil))
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")))
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",
"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",
} {
assert.Error(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips(ip)), ip)
}
}
func TestNewDialContext(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
addr := ln.Addr().String()
dial := func(proxy string, allowProxies bool) error {
policy := NewPolicy("test", Lax, WithBlock("loopback", ""), WithProxy(http.ProxyURL(&url.URL{Scheme: "http", Host: proxy})))
_, _ = policy.Proxy(&http.Request{})
conn, err := policy.dialContext(allowProxies)(t.Context(), "tcp", addr)
if err == nil {
_ = conn.Close()
}
return err
}
assert.NoError(t, dial(addr, true))
assert.ErrorIs(t, dial(addr, false), ErrDenied)
assert.ErrorIs(t, dial("127.0.0.1:1", true), ErrDenied)
}
func TestProxy(t *testing.T) {
for raw, want := range map[string]string{
"http://127.0.0.1": "127.0.0.1:80",
"socks5://[::1]": "[::1]:1080",
"https://proxy.corp:8443": "proxy.corp:8443",
} {
u, err := url.Parse(raw)
require.NoError(t, err)
assert.Equal(t, want, ProxyDialAddr(u), raw)
}
_, err := NewPolicy("test", Lax, WithProxy(http.ProxyURL(&url.URL{Scheme: "socks4", Host: "proxy.corp:1080"}))).Proxy(&http.Request{})
assert.ErrorContains(t, err, "unsupported proxy scheme")
}
+5 -1
View File
@@ -255,10 +255,14 @@ func commonBaseEnvs() []string {
// CommonGitCmdEnvs returns the common environment variables for a "git" command.
func CommonGitCmdEnvs() []string {
return append(commonBaseEnvs(), []string{
envs := append(commonBaseEnvs(), []string{
"LC_ALL=C", // ensure git output is in English, error messages are parsed in English
"GIT_TERMINAL_PROMPT=0", // avoid prompting for credentials interactively, supported since git v2.3
}...)
if extra := extraEnvs.Load(); extra != nil {
envs = append(envs, *extra...)
}
return envs
}
// CommonCmdServEnvs is like CommonGitCmdEnvs, but it only returns minimal required environment variables for the "gitea serv" command
+8
View File
@@ -6,6 +6,7 @@ package gitcmd
import (
"fmt"
"os/exec"
"sync/atomic"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
@@ -13,6 +14,13 @@ import (
var GitExecutable = "git" // the command name of git, will be updated to an absolute path during initialization
var extraEnvs atomic.Pointer[[]string]
// SetExtraEnvs adds envs to every git command, the git proxy routes git's network remotes with them
func SetExtraEnvs(envs []string) {
extraEnvs.Store(&envs)
}
// SetExecutablePath changes the path of git executable and checks the file permission and version.
func SetExecutablePath(path string) error {
// If path is empty, we use the default value of GitExecutable "git" to search for the location of git.
-15
View File
@@ -1,15 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import "gitea.dev/modules/git/gitcmd"
func HandleGitCmdHTTPRedirection(cmd *gitcmd.Command, targets ...string) {
// Protect from SSRF vector (e.g. migrating from an attacker URL).
// cmd.AddConfig("http.followRedirects", "false")
// However, we can't do so at the moment:
// this fails due to 301: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}
// this succeeds: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}.git
// FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future
}
+1 -17
View File
@@ -7,7 +7,6 @@ package git
import (
"context"
"fmt"
"net/url"
"os"
"path"
"path/filepath"
@@ -19,7 +18,6 @@ import (
"gitea.dev/modules/cache"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/git/gitrepo"
"gitea.dev/modules/proxy"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
@@ -183,7 +181,6 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
}
cmd := gitcmd.NewCommand().AddArguments("clone")
HandleGitCmdHTTPRedirection(cmd, from, to)
if opts.SkipTLSVerify {
cmd.AddArguments("-c", "http.sslVerify=false")
}
@@ -220,20 +217,7 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
opts.Timeout = -1
}
envs := os.Environ()
if opts.Env != nil {
envs = opts.Env
} else {
u, err := url.Parse(from)
if err == nil {
envs = proxy.EnvWithProxy(u)
}
}
return cmd.
WithTimeout(opts.Timeout).
WithEnv(envs).
RunWithStderr(ctx)
return cmd.WithTimeout(opts.Timeout).WithEnv(opts.Env).RunWithStderr(ctx)
}
// PushOptions options when push to remote
-24
View File
@@ -4,10 +4,7 @@
package git
import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
@@ -22,24 +19,3 @@ func TestRepoIsEmpty(t *testing.T) {
assert.NoError(t, err)
assert.True(t, isEmpty)
}
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
func TestCloneRefusesRedirects(t *testing.T) {
t.Skip("FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future")
var targetHit atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHit.Store(true)
w.WriteHeader(http.StatusNotFound)
}))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound)
}))
defer redirect.Close()
err := Clone(t.Context(), redirect.URL, filepath.Join(t.TempDir(), "dst"), CloneRepoOptions{})
assert.Error(t, err)
assert.False(t, targetHit.Load(), "git must not follow the redirect to the target")
}
-211
View File
@@ -1,211 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package hostmatcher
import (
"net"
"path/filepath"
"slices"
"strings"
"sync"
)
// HostMatchList is used to check if a host or IP is in a list.
type HostMatchList struct {
SettingKeyHint string
SettingValue string
// builtins networks
builtins []string
// patterns for host names (with wildcard support)
patterns []string
// ipNets is the CIDR network list
ipNets []*net.IPNet
}
// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate)
// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet.
const MatchBuiltinExternal = "external"
// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be
// treated as public/external destinations (CGNAT, cloud metadata, IPv6 transition, etc.). We layer
// these on top of net.IP.IsPrivate (RFC 1918 / RFC 4193) so future additions to Go's IsPrivate are
// picked up automatically, while still covering the ranges it leaves out; otherwise the default
// allow-list would let authenticated users reach cloud metadata, internal, and IPv6 transition
// endpoints (SSRF), and a "private" block-list would fail to catch them.
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
var nets []*net.IPNet
for _, cidr := range []string{
// IPv4
"100.64.0.0/10", // RFC 6598 Carrier-Grade NAT
"168.63.129.16/32", // Azure WireServer metadata endpoint
"192.0.0.0/24", // RFC 6890 IETF protocol assignments
"192.0.2.0/24", // RFC 5737 TEST-NET-1
"192.88.99.0/24", // RFC 7526 6to4 relay anycast (deprecated)
"198.18.0.0/15", // RFC 2544 benchmarking
"198.51.100.0/24", // RFC 5737 TEST-NET-2
"203.0.113.0/24", // RFC 5737 TEST-NET-3
// IPv6
"100::/64", // RFC 6666 discard-only
"64:ff9b::/96", // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254)
"64:ff9b:1::/48", // RFC 8215 local-use NAT64
"2001::/32", // RFC 4380 Teredo tunneling (embeds IPv4)
"2001:10::/28", // RFC 4843 ORCHID (deprecated)
"2001:20::/28", // RFC 7343 ORCHIDv2
"2001:db8::/32", // RFC 3849 documentation
"2002::/16", // RFC 3056 6to4 (embeds IPv4)
} {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
panic("hostmatcher: invalid reserved CIDR " + cidr + ": " + err.Error())
}
nets = append(nets, ipNet)
}
return nets
})
// isReservedIP reports whether ip falls in reserved special-purpose
// range (see reservedIPNets) that must not be considered a public/external destination.
func isReservedIP(ip net.IP) bool {
for _, ipNet := range reservedIPNets() {
if ipNet.Contains(ip) {
return true
}
}
return false
}
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7),
// plus the reserved special-purpose ranges in reservedIPNets (CGNAT, NAT64, cloud metadata, etc.).
// Also called LAN/Intranet.
const MatchBuiltinPrivate = "private"
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
const MatchBuiltinLoopback = "loopback"
func isBuiltin(s string) bool {
return s == MatchBuiltinExternal || s == MatchBuiltinPrivate || s == MatchBuiltinLoopback
}
// ParseHostMatchList parses the host list HostMatchList
func ParseHostMatchList(settingKeyHint, hostList string) *HostMatchList {
hl := &HostMatchList{SettingKeyHint: settingKeyHint, SettingValue: hostList}
for s := range strings.SplitSeq(hostList, ",") {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
continue
}
_, ipNet, err := net.ParseCIDR(s)
if err == nil {
hl.ipNets = append(hl.ipNets, ipNet)
} else if isBuiltin(s) {
hl.builtins = append(hl.builtins, s)
} else {
hl.patterns = append(hl.patterns, s)
}
}
return hl
}
// ParseSimpleMatchList parse a simple matchlist (no built-in networks, no CIDR support, only wildcard pattern match)
func ParseSimpleMatchList(settingKeyHint, matchList string) *HostMatchList {
hl := &HostMatchList{
SettingKeyHint: settingKeyHint,
SettingValue: matchList,
}
for s := range strings.SplitSeq(matchList, ",") {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
continue
}
// we keep the same result as old `matchlist`, so no builtin/CIDR support here, we only match wildcard patterns
hl.patterns = append(hl.patterns, s)
}
return hl
}
// AppendBuiltin appends more builtins to match
func (hl *HostMatchList) AppendBuiltin(builtin string) {
hl.builtins = append(hl.builtins, builtin)
}
// IsEmpty checks if the checklist is empty
func (hl *HostMatchList) IsEmpty() bool {
return hl == nil || (len(hl.builtins) == 0 && len(hl.patterns) == 0 && len(hl.ipNets) == 0)
}
func (hl *HostMatchList) checkPattern(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
for _, pattern := range hl.patterns {
if matched, _ := filepath.Match(pattern, host); matched {
return true
}
}
return false
}
// matchesIP determines if the given IP matches any of the configured rules
func (hl *HostMatchList) matchesIP(ip net.IP) bool {
if slices.Contains(hl.patterns, "*") {
return true
}
for _, builtin := range hl.builtins {
switch builtin {
case MatchBuiltinExternal:
// External address must be a global unicast, must not be in reserved range and must not be in private range
if ip.IsGlobalUnicast() && !isReservedIP(ip) && !ip.IsPrivate() {
return true
}
case MatchBuiltinPrivate:
// Private address must be global unicast, must not be in range we explicitly exclude for security reasons
// and must be in private range
if ip.IsGlobalUnicast() && !isReservedIP(ip) && ip.IsPrivate() {
return true
}
case MatchBuiltinLoopback:
if ip.IsLoopback() {
return true
}
}
}
for _, ipNet := range hl.ipNets {
if ipNet.Contains(ip) {
return true
}
}
return false
}
// MatchHostName checks if the host matches an allow/deny(block) list
func (hl *HostMatchList) MatchHostName(host string) bool {
if hl == nil {
return false
}
hostname, _, err := net.SplitHostPort(host)
if err != nil {
hostname = host
}
if hl.checkPattern(hostname) {
return true
}
if ip := net.ParseIP(hostname); ip != nil {
return hl.matchesIP(ip)
}
return false
}
// MatchIPAddr checks if the IP matches an allow/deny(block) list, it's safe to pass `nil` to `ip`
func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {
if hl == nil {
return false
}
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
return hl.checkPattern(host) || hl.matchesIP(ip)
}
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
func (hl *HostMatchList) MatchHostOrIP(host string, ip net.IP) bool {
return hl.MatchHostName(host) || hl.MatchIPAddr(ip)
}
-218
View File
@@ -1,218 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package hostmatcher
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
)
func TestHostOrIPMatchesList(t *testing.T) {
type tc struct {
host string
ip net.IP
expected bool
}
// for IPv6: "::1" is loopback, "fd00::/8" is private
hl := ParseHostMatchList("", "private, External, *.myDomain.com, 169.254.1.0/24")
test := func(cases []tc) {
for _, c := range cases {
assert.Equalf(t, c.expected, hl.MatchHostOrIP(c.host, c.ip), "case domain=%s, ip=%v, expected=%v", c.host, c.ip, c.expected)
}
}
cases := []tc{
{"", net.IPv4zero, false},
{"", net.IPv6zero, false},
{"", net.ParseIP("127.0.0.1"), false},
{"127.0.0.1", nil, false},
{"", net.ParseIP("::1"), false},
{"", net.ParseIP("10.0.1.1"), true},
{"10.0.1.1", nil, true},
{"10.0.1.1:8080", nil, true},
{"", net.ParseIP("192.168.1.1"), true},
{"192.168.1.1", nil, true},
{"", net.ParseIP("fd00::1"), true},
{"fd00::1", nil, true},
{"", net.ParseIP("8.8.8.8"), true},
{"", net.ParseIP("1001::1"), true},
{"mydomain.com", net.IPv4zero, false},
{"sub.mydomain.com", net.IPv4zero, true},
{"sub.mydomain.com:8080", net.IPv4zero, true},
{"", net.ParseIP("169.254.1.1"), true},
{"169.254.1.1", nil, true},
{"", net.ParseIP("169.254.2.2"), false},
{"169.254.2.2", nil, false},
}
test(cases)
hl = ParseHostMatchList("", "loopback")
cases = []tc{
{"", net.IPv4zero, false},
{"", net.ParseIP("127.0.0.1"), true},
{"", net.ParseIP("10.0.1.1"), false},
{"", net.ParseIP("192.168.1.1"), false},
{"", net.ParseIP("8.8.8.8"), false},
{"", net.ParseIP("::1"), true},
{"", net.ParseIP("fd00::1"), false},
{"", net.ParseIP("1000::1"), false},
{"mydomain.com", net.IPv4zero, false},
}
test(cases)
hl = ParseHostMatchList("", "private")
cases = []tc{
{"", net.IPv4zero, false},
{"", net.ParseIP("127.0.0.1"), false},
{"", net.ParseIP("10.0.1.1"), true},
{"", net.ParseIP("192.168.1.1"), true},
{"", net.ParseIP("8.8.8.8"), false},
{"", net.ParseIP("::1"), false},
{"", net.ParseIP("fd00::1"), true},
{"", net.ParseIP("1000::1"), false},
{"mydomain.com", net.IPv4zero, false},
}
test(cases)
hl = ParseHostMatchList("", "external")
cases = []tc{
{"", net.IPv4zero, false},
{"", net.ParseIP("127.0.0.1"), false},
{"", net.ParseIP("10.0.1.1"), false},
{"", net.ParseIP("192.168.1.1"), false},
{"", net.ParseIP("8.8.8.8"), true},
{"", net.ParseIP("::1"), false},
{"", net.ParseIP("fd00::1"), false},
{"", net.ParseIP("1000::1"), true},
{"mydomain.com", net.IPv4zero, false},
}
test(cases)
hl = ParseHostMatchList("", "*")
cases = []tc{
{"", net.IPv4zero, true},
{"", net.ParseIP("127.0.0.1"), true},
{"", net.ParseIP("10.0.1.1"), true},
{"", net.ParseIP("192.168.1.1"), true},
{"", net.ParseIP("8.8.8.8"), true},
{"", net.ParseIP("::1"), true},
{"", net.ParseIP("fd00::1"), true},
{"", net.ParseIP("1000::1"), true},
{"mydomain.com", net.IPv4zero, true},
}
test(cases)
// built-in network names can be escaped (warping the first char with `[]`) to be used as a real host name
// this mechanism is reversed for internal usage only (maybe for some rare cases), it's not supposed to be used by end users
// a real user should never use loopback/private/external as their host names
hl = ParseHostMatchList("", "loopback, [p]rivate")
cases = []tc{
{"loopback", nil, false},
{"", net.ParseIP("127.0.0.1"), true},
{"private", nil, true},
{"", net.ParseIP("192.168.1.1"), false},
}
test(cases)
hl = ParseSimpleMatchList("", "loopback, *.domain.com")
cases = []tc{
{"loopback", nil, true},
{"", net.ParseIP("127.0.0.1"), false},
{"sub.domain.com", nil, true},
{"other.com", nil, false},
{"", net.ParseIP("1.1.1.1"), false},
}
test(cases)
hl = ParseSimpleMatchList("", "external")
cases = []tc{
{"", net.ParseIP("192.168.1.1"), false},
{"", net.ParseIP("1.1.1.1"), false},
{"external", nil, true},
}
test(cases)
hl = ParseSimpleMatchList("", "")
cases = []tc{
{"", net.ParseIP("192.168.1.1"), false},
{"", net.ParseIP("1.1.1.1"), false},
{"external", nil, false},
}
test(cases)
}
// TestReservedRanges ensures special-purpose ranges that net.IP.IsPrivate misses are kept out of the
// "external" allow-list (the default for webhook delivery and repository migrations) and folded into
// the "private" block-list, so they cannot be used for SSRF to metadata/internal endpoints.
func TestReservedRanges(t *testing.T) {
external := ParseHostMatchList("", "external")
private := ParseHostMatchList("", "private")
// legitimate public destinations: external, not private
for _, ip := range []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "1000::1"} {
addr := net.ParseIP(ip)
assert.Truef(t, external.MatchIPAddr(addr), "public ip %s should be external", ip)
assert.Falsef(t, private.MatchIPAddr(addr), "public ip %s should not be private", ip)
}
// RFC 1918 / RFC 4193 private ranges (now folded into privateIPNets instead of net.IP.IsPrivate):
// not external, blockable as private. Includes range edges to guard the CIDR boundaries.
for _, ip := range []string{
"10.0.0.0", "10.255.255.255", // 10.0.0.0/8
"172.16.0.0", "172.31.255.255", // 172.16.0.0/12
"192.168.0.0", "192.168.255.255", // 192.168.0.0/16
"fc00::", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", // fc00::/7
} {
addr := net.ParseIP(ip)
assert.Falsef(t, external.MatchIPAddr(addr), "private ip %s must not be external", ip)
assert.Truef(t, private.MatchIPAddr(addr), "private ip %s should match private block-list", ip)
}
// 172.32.0.0 is just outside 172.16.0.0/12: a public destination, not private
if addr := net.ParseIP("172.32.0.0"); assert.NotNil(t, addr) {
assert.True(t, external.MatchIPAddr(addr), "172.32.0.0 should be external")
assert.False(t, private.MatchIPAddr(addr), "172.32.0.0 should not be private")
}
// reserved ranges that IsPrivate does not cover: not external, but blockable as private
for _, ip := range []string{
"100.64.0.1", // CGNAT
"100.127.255.254", // CGNAT
"168.63.129.16", // Azure WireServer
"192.0.2.1", // TEST-NET-1
"198.18.0.1", // benchmarking
"198.51.100.1", // TEST-NET-2
"203.0.113.1", // TEST-NET-3
"169.254.169.254", // Cloud metadata
"192.88.99.1", // 6to4 relay anycast
"64:ff9b::1", // NAT64
"64:ff9b::a9fe:a9fe", // NAT64 embedding 169.254.169.254
"2001::1", // Teredo
"2002::1", // 6to4
"2001:db8::1", // documentation
"fe80::1", // link local address
} {
addr := net.ParseIP(ip)
assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip)
assert.Falsef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip)
}
}
-81
View File
@@ -1,81 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package hostmatcher
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/url"
"syscall"
"time"
)
// NewDialContext returns a DialContext for Transport, the DialContext will do allow/block list check
func NewDialContext(usage string, allowList, blockList *HostMatchList, proxy *url.URL) func(ctx context.Context, network, addr string) (net.Conn, error) {
// How Go HTTP Client works with redirection:
// transport.RoundTrip URL=http://domain.com, Host=domain.com
// transport.DialContext addrOrHost=domain.com:80
// dialer.Control tcp4:11.22.33.44:80
// transport.RoundTrip URL=http://www.domain.com/, Host=(empty here, in the direction, HTTP client doesn't fill the Host field)
// transport.DialContext addrOrHost=domain.com:80
// dialer.Control tcp4:11.22.33.44:80
return func(ctx context.Context, network, addrOrHost string) (net.Conn, error) {
dialer := net.Dialer{
// default values comes from http.DefaultTransport
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
Control: func(network, ipAddr string, c syscall.RawConn) error {
host, port, err := net.SplitHostPort(addrOrHost)
if err != nil {
return err
}
if proxy != nil {
// Always allow the host of the proxy, but only on the specified port.
if host == proxy.Hostname() && port == proxy.Port() {
return nil
}
}
// in Control func, the addr was already resolved to IP:PORT format, there is no cost to do ResolveTCPAddr here
tcpAddr, err := net.ResolveTCPAddr(network, ipAddr)
if err != nil {
return fmt.Errorf("%s can only call HTTP servers via TCP, deny '%s(%s:%s)', err=%w", usage, host, network, ipAddr, err)
}
var blockedError error
if blockList.MatchHostOrIP(host, tcpAddr.IP) {
blockedError = fmt.Errorf("%s can not call blocked HTTP servers (check your %s setting), deny '%s(%s)'", usage, blockList.SettingKeyHint, host, ipAddr)
}
// if we have an allow-list, check the allow-list first
if !allowList.IsEmpty() {
if !allowList.MatchHostOrIP(host, tcpAddr.IP) {
return fmt.Errorf("%s can only call allowed HTTP servers (check your %s setting), deny '%s(%s)'", usage, allowList.SettingKeyHint, host, ipAddr)
}
}
// otherwise, we always follow the blocked list
return blockedError
},
}
return dialer.DialContext(ctx, network, addrOrHost)
}
}
// NewHTTPTransport builds an http.Transport that validates the request target against the allow/block
// lists on the direct-dial path (DialContext). When an HTTP proxy is configured the proxy resolves and
// dials the target itself, so restricting the proxied target is the proxy server's responsibility, not
// Gitea's. proxyFunc selects the proxy URL per request (the http.Transport.Proxy selector, e.g.
// proxy.Proxy()); proxyURLFixed is the fixed proxy address the dialer must always permit; tlsConfig may
// be nil. blockList may be nil for callers that only maintain an allow-list.
func NewHTTPTransport(usage string, allowList, blockList *HostMatchList, proxyFunc func(*http.Request) (*url.URL, error), proxyURLFixed *url.URL, tlsConfig *tls.Config) *http.Transport {
return &http.Transport{
TLSClientConfig: tlsConfig,
Proxy: proxyFunc,
DialContext: NewDialContext(usage, allowList, blockList, proxyURLFixed),
}
}
+30 -66
View File
@@ -6,8 +6,6 @@ package proxy
import (
"net/http"
"net/url"
"os"
"strings"
"sync"
"gitea.dev/modules/glob"
@@ -16,82 +14,48 @@ import (
)
var (
once sync.Once
hostMatchers []glob.Glob
globalProxyHosts = sync.OnceValue(func() []glob.Glob { return compileHosts(setting.Proxy.ProxyHosts) })
webhookProxyHosts = sync.OnceValue(func() []glob.Glob { return compileHosts(setting.Webhook.ProxyHosts) })
)
// GetProxyURL returns proxy url
func GetProxyURL() string {
if !setting.Proxy.Enabled {
return ""
}
if setting.Proxy.ProxyURL == "" {
if os.Getenv("http_proxy") != "" {
return os.Getenv("http_proxy")
func compileHosts(hosts []string) (globs []glob.Glob) {
for _, h := range hosts {
if g, err := glob.Compile(h); err == nil {
globs = append(globs, g)
} else {
log.Error("glob.Compile %s failed: %v", h, err)
}
return os.Getenv("https_proxy")
}
return setting.Proxy.ProxyURL
return globs
}
// Match return true if url needs to be proxied
func Match(u string) bool {
if !setting.Proxy.Enabled {
return false
}
// enforce do once
Proxy()
for _, v := range hostMatchers {
if v.Match(u) {
return true
}
}
return false
}
// Proxy returns the system proxy
func Proxy() func(req *http.Request) (*url.URL, error) {
if !setting.Proxy.Enabled {
return func(req *http.Request) (*url.URL, error) {
return nil, nil
}
}
if setting.Proxy.ProxyURL == "" {
return http.ProxyFromEnvironment
}
once.Do(func() {
for _, h := range setting.Proxy.ProxyHosts {
if g, err := glob.Compile(h); err == nil {
hostMatchers = append(hostMatchers, g)
} else {
log.Error("glob.Compile %s failed: %v", h, err)
}
}
})
// hostsProxy sends requests for hosts matching globs through proxyURL, others follow the environment
func hostsProxy(globs []glob.Glob, proxyURL *url.URL) func(req *http.Request) (*url.URL, error) {
return func(req *http.Request) (*url.URL, error) {
for _, v := range hostMatchers {
if v.Match(req.URL.Host) {
return http.ProxyURL(setting.Proxy.ProxyURLFixed)(req)
for _, g := range globs {
if g.Match(req.URL.Host) {
return proxyURL, nil
}
}
return http.ProxyFromEnvironment(req)
}
}
// EnvWithProxy returns os.Environ(), with a https_proxy env, if the given url
// needs to be proxied.
func EnvWithProxy(u *url.URL) []string {
envs := os.Environ()
if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") {
if Match(u.Host) {
envs = append(envs, "https_proxy="+GetProxyURL())
}
// Proxy returns the system proxy
func Proxy() func(req *http.Request) (*url.URL, error) {
if !setting.Proxy.Enabled {
return nil
}
return envs
if setting.Proxy.ProxyURL == "" {
return http.ProxyFromEnvironment
}
return hostsProxy(globalProxyHosts(), setting.Proxy.ProxyURLFixed)
}
// WebHookProxy returns the webhook proxy, falling back to the system proxy if no webhook proxy is set
func WebHookProxy() func(req *http.Request) (*url.URL, error) {
if setting.Webhook.ProxyURL == "" {
return Proxy()
}
return hostsProxy(webhookProxyHosts(), setting.Webhook.ProxyURLFixed)
}
+35 -9
View File
@@ -3,17 +3,20 @@
package setting
import "strings"
// Migrations settings
var Migrations = struct {
MaxAttempts int
RetryBackoff int
AllowedDomains string
BlockedDomains string
AllowLocalNetworks bool
SkipTLSVerify bool
MaxAttempts int
RetryBackoff int
EgressMode string
AllowedHostList string
BlockedHostList string
SkipTLSVerify bool
}{
MaxAttempts: 3,
RetryBackoff: 3,
EgressMode: "lax",
}
func loadMigrationsFrom(rootCfg ConfigProvider) {
@@ -21,8 +24,31 @@ func loadMigrationsFrom(rootCfg ConfigProvider) {
Migrations.MaxAttempts = sec.Key("MAX_ATTEMPTS").MustInt(Migrations.MaxAttempts)
Migrations.RetryBackoff = sec.Key("RETRY_BACKOFF").MustInt(Migrations.RetryBackoff)
Migrations.AllowedDomains = sec.Key("ALLOWED_DOMAINS").MustString("")
Migrations.BlockedDomains = sec.Key("BLOCKED_DOMAINS").MustString("")
Migrations.AllowLocalNetworks = sec.Key("ALLOW_LOCALNETWORKS").MustBool(false)
egressModeSet := sec.HasKey("EGRESS_MODE")
Migrations.EgressMode = normalizePolicyMode(sec.Key("EGRESS_MODE").String())
deprecatedSetting(rootCfg, "migrations", "ALLOWED_DOMAINS", "migrations", "ALLOWED_HOST_LIST", "v28.0.0")
deprecatedSetting(rootCfg, "migrations", "BLOCKED_DOMAINS", "migrations", "BLOCKED_HOST_LIST", "v28.0.0")
deprecatedSetting(rootCfg, "migrations", "ALLOW_LOCALNETWORKS", "migrations", "ALLOWED_HOST_LIST", "v28.0.0")
Migrations.AllowedHostList = ConfigSectionKeyString(sec, "ALLOWED_HOST_LIST")
if Migrations.AllowedHostList == "" {
var hosts []string
for host := range strings.SplitSeq(ConfigSectionKeyString(sec, "ALLOWED_DOMAINS"), ",") {
if host = strings.TrimSpace(host); host != "" {
hosts = append(hosts, host+":*")
}
}
if len(hosts) > 0 && !egressModeSet {
Migrations.EgressMode = "strict" // ALLOWED_DOMAINS allowed only its hosts, on any port
}
if ConfigSectionKeyBool(sec, "ALLOW_LOCALNETWORKS") {
hosts = append(hosts, "private:*", "loopback:*")
}
Migrations.AllowedHostList = strings.Join(hosts, ",")
}
Migrations.BlockedHostList = ConfigSectionKeyString(sec, "BLOCKED_HOST_LIST", ConfigSectionKeyString(sec, "BLOCKED_DOMAINS"))
checkHostList("[migrations] ALLOWED_HOST_LIST", Migrations.AllowedHostList, false)
checkHostList("[migrations] BLOCKED_HOST_LIST", Migrations.BlockedHostList, true)
Migrations.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool(false)
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"testing"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadMigrationsFrom(t *testing.T) {
defer test.MockVariableValue(&Migrations)()
for ini, want := range map[string][3]string{
`ALLOWED_DOMAINS = github.com
BLOCKED_DOMAINS = gitlab.com
ALLOW_LOCALNETWORKS = true`: {"github.com:*,private:*,loopback:*", "gitlab.com", "strict"},
`ALLOW_LOCALNETWORKS = true`: {"private:*,loopback:*", "", "lax"},
`ALLOWED_HOST_LIST = 10.0.0.0/8
ALLOWED_DOMAINS = github.com
ALLOW_LOCALNETWORKS = true
BLOCKED_HOST_LIST = evil.com
BLOCKED_DOMAINS = gitlab.com`: {"10.0.0.0/8", "evil.com", "lax"},
} {
cfg, err := NewConfigProviderFromData("[migrations]\n" + ini)
require.NoError(t, err)
loadMigrationsFrom(cfg)
assert.Equal(t, want, [3]string{Migrations.AllowedHostList, Migrations.BlockedHostList, Migrations.EgressMode}, ini)
}
}
+34 -1
View File
@@ -9,6 +9,7 @@ import (
"strings"
"gitea.dev/modules/auth/password/hash"
"gitea.dev/modules/egress/policy"
"gitea.dev/modules/generate"
"gitea.dev/modules/log"
)
@@ -20,11 +21,37 @@ var Security = struct {
XContentTypeOptions string
ContentSecurityPolicyGeneral string // it only supports empty (default policy) or "unset", maybe it can support more in the future
EgressMode string
AllowedHostList string
}{
XFrameOptions: "SAMEORIGIN",
XContentTypeOptions: "nosniff",
AllowedHostList: "external",
EgressMode: "lax",
}
// normalizePolicyMode validates a lax/strict egress policy EGRESS_MODE value, empty defaults to lax
func normalizePolicyMode(mode string) string {
mode = strings.ToLower(strings.TrimSpace(mode))
switch mode {
case "":
return "lax"
case "lax", "strict":
return mode
default:
log.Fatal("Invalid egress policy EGRESS_MODE %q, use lax or strict", mode)
return ""
}
}
// checkHostList reports the entries an egress host list drops, a dropped block entry would allow a blocked host so it stops startup
func checkHostList(key, hostList string, isBlockList bool) {
rejected := policy.NewAllowList(hostList, policy.Lax).Rejected()
for _, reason := range rejected {
LogStartupProblem(1, log.ERROR, "%s ignores an invalid entry: %s", key, reason)
}
if isBlockList && len(rejected) > 0 {
log.Fatal("%s has invalid entries, fix them so no blocked host is allowed", key)
}
}
var (
@@ -160,6 +187,12 @@ func loadSecurityFrom(rootCfg ConfigProvider) {
if err := sec.MapTo(&Security); err != nil {
log.Fatal("Failed to map security settings: %v", err)
}
egressModeSet := sec.HasKey("EGRESS_MODE")
Security.EgressMode = normalizePolicyMode(sec.Key("EGRESS_MODE").String())
checkHostList("[security] ALLOWED_HOST_LIST", Security.AllowedHostList, false)
if Security.AllowedHostList != "" && !egressModeSet {
LogStartupProblem(1, log.WARN, "[security] ALLOWED_HOST_LIST only restricts private hosts in the default lax mode, set EGRESS_MODE = strict to allow only the listed hosts, or lax to keep this")
}
twoFactorAuth := sec.Key("TWO_FACTOR_AUTH").String()
switch twoFactorAuth {
+4 -1
View File
@@ -12,11 +12,13 @@ import (
func TestLoadSecurityFrom(t *testing.T) {
assert.Equal(t, "SAMEORIGIN", Security.XFrameOptions)
assert.Equal(t, "nosniff", Security.XContentTypeOptions)
assert.Equal(t, "external", Security.AllowedHostList)
assert.Equal(t, "lax", Security.EgressMode)
assert.Equal(t, "", Security.AllowedHostList)
cfg, err := NewConfigProviderFromData(`[security]
X_FRAME_OPTIONS = DENY
X_CONTENT_TYPE_OPTIONS = unset
EGRESS_MODE = Strict
ALLOWED_HOST_LIST = foo
CONTENT_SECURITY_POLICY_GENERAL = "script-src *; foo"
`)
@@ -24,6 +26,7 @@ CONTENT_SECURITY_POLICY_GENERAL = "script-src *; foo"
loadSecurityFrom(cfg)
assert.Equal(t, "DENY", Security.XFrameOptions)
assert.Equal(t, "unset", Security.XContentTypeOptions)
assert.Equal(t, "strict", Security.EgressMode)
assert.Equal(t, "foo", Security.AllowedHostList)
assert.Equal(t, `"script-src *`, Security.ContentSecurityPolicyGeneral) // holy shit ini package bug
}
+3
View File
@@ -37,6 +37,9 @@ func loadWebhookFrom(rootCfg ConfigProvider) {
deprecatedSetting(rootCfg, "webhook", "ALLOWED_HOST_LIST", "security", "ALLOWED_HOST_LIST", "v28.0.0")
Webhook.AllowedHostList = sec.Key("ALLOWED_HOST_LIST").MustString(Security.AllowedHostList)
if Webhook.AllowedHostList != Security.AllowedHostList {
checkHostList("[webhook] ALLOWED_HOST_LIST", Webhook.AllowedHostList, false)
}
Webhook.Types = []string{"gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "matrix", "wechatwork", "packagist"}
Webhook.PagingNum = sec.Key("PAGING_NUM").MustInt(10)