mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-24 01:24:39 +02:00
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:
57 files changed
+2356
-1106
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user