chore: initialize Gitea v1.27.2 fork
giteabot backport / giteabot (push) Canceled after 0s
giteabot / giteabot (push) Canceled after 0s
release-nightly / nightly-binary (push) Canceled after 0s
release-nightly / nightly-container (push) Canceled after 0s
cache-seeder / gobuild (push) Canceled after 0s
cache-seeder / lint (bindata, lint-backend) (push) Canceled after 0s
release-nightly-snapcraft / build-and-publish (push) Canceled after 0s

Includes direct password setup links in registration emails.

Assisted-by: Codex:GPT-5
This commit is contained in:
2026-08-15 22:56:18 +08:00
commit e4afba3416
6210 changed files with 802505 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
// 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
@@ -0,0 +1,218 @@
// 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
@@ -0,0 +1,81 @@
// 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),
}
}