mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-26 06:16:56 +09:00
fix: correctly parse and validate forwarded client IPs and remove unmaintained chi-middleware/proxy (#39190)
Replace the unmaintained `chi-middleware/proxy` with an in-tree handler that detects client IPs correctly behind common reverse proxies. 1. Handle repeated header lines and comma-separated proxy hops. 2. Accept and normalize IPs, including `ip:port`. 3. Keep the client port from the header value or `X-Real-Port` instead of logging port 0. 4. Prefer the last `X-Real-IP` line. 5. Keep the proxy's address when the selected value is invalid. 6. Read `Forwarded` only when `X-Forwarded-For` is absent. Docs PR: https://gitea.com/gitea/docs/pulls/560 Fixes: https://github.com/go-gitea/gitea/issues/35682 Fixes: https://github.com/go-gitea/gitea/issues/36814 Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
1 parent
5129ad5439
commit
5e4ed4d508
7 files changed
+215
-24
No files matched your search
@@ -0,0 +1,156 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
func ForwardedHeadersHandler(limit int, trustedProxies []string) func(h http.Handler) http.Handler {
|
||||
trustAll := slices.Contains(trustedProxies, "*")
|
||||
var trusted []netip.Prefix
|
||||
for _, s := range trustedProxies {
|
||||
if s == "*" {
|
||||
continue
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
addr, _ := netip.ParseAddr(s)
|
||||
prefix = netip.PrefixFrom(addr, addr.BitLen())
|
||||
}
|
||||
if prefix.Addr().Is4In6() && prefix.Bits() >= 96 {
|
||||
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
|
||||
}
|
||||
if !prefix.IsValid() {
|
||||
log.Error("Ignoring invalid trusted proxy %q", s)
|
||||
continue
|
||||
}
|
||||
trusted = append(trusted, prefix)
|
||||
}
|
||||
return func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
if req.RemoteAddr == "@" { // unix socket
|
||||
req.RemoteAddr = "127.0.0.1:0"
|
||||
}
|
||||
peer, _ := netip.ParseAddrPort(req.RemoteAddr)
|
||||
if trustAll || slices.ContainsFunc(trusted, func(p netip.Prefix) bool { return p.Contains(peer.Addr().Unmap()) }) {
|
||||
if client := forwardedClient(req.Header, limit); client.IsValid() {
|
||||
req.RemoteAddr = netip.AddrPortFrom(client.Addr().Unmap().WithZone(""), client.Port()).String()
|
||||
}
|
||||
}
|
||||
h.ServeHTTP(resp, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// forwardedClient reads Forwarded only without X-Forwarded-For, so proxies passing a client-sent Forwarded through stay safe
|
||||
func forwardedClient(header http.Header, limit int) netip.AddrPort {
|
||||
if realIPs := header["X-Real-Ip"]; len(realIPs) > 0 && realIPs[len(realIPs)-1] != "" {
|
||||
client := parseNode(realIPs[len(realIPs)-1]) // the closest hop wrote the last value
|
||||
if realPorts := header["X-Real-Port"]; client.Port() == 0 && len(realPorts) > 0 {
|
||||
if port, err := strconv.ParseUint(realPorts[len(realPorts)-1], 10, 16); err == nil {
|
||||
client = netip.AddrPortFrom(client.Addr(), uint16(port))
|
||||
}
|
||||
}
|
||||
return client
|
||||
}
|
||||
if xff := header["X-Forwarded-For"]; len(xff) > 0 {
|
||||
return parseNode(elementFromEnd(xff, limit))
|
||||
}
|
||||
return parseNode(forwardedFor(elementFromEnd(header["Forwarded"], limit)))
|
||||
}
|
||||
|
||||
// elementFromEnd skips empty elements, which RFC 9110 doesn't count
|
||||
func elementFromEnd(values []string, n int) string {
|
||||
list, entry := strings.Join(values, ","), ""
|
||||
for list != "" && n > 0 {
|
||||
var element string
|
||||
list, element = cutLast(list, ',')
|
||||
if element = strings.TrimSpace(element); element != "" {
|
||||
entry, n = element, n-1
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// forwardedFor returns nothing on a repeated "for", which RFC 7239 forbids
|
||||
func forwardedFor(element string) string {
|
||||
node, found := "", false
|
||||
for element != "" {
|
||||
var pair string
|
||||
element, pair = cutLast(element, ';')
|
||||
key, value, _ := strings.Cut(strings.TrimSpace(pair), "=")
|
||||
if !strings.EqualFold(key, "for") {
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
return ""
|
||||
}
|
||||
node, found = unquote(value), true
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// cutLast scans from the right so a malformed client-written prefix can't shift proxy-written elements
|
||||
func cutLast(s string, sep byte) (before, after string) {
|
||||
if i := strings.LastIndexByte(s, sep); !strings.Contains(s[i+1:], `"`) {
|
||||
return s[:max(i, 0)], s[i+1:]
|
||||
}
|
||||
quoted := false
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
switch s[i] {
|
||||
case '"':
|
||||
quoted = !quoted || (i-len(strings.TrimRight(s[:i], `\`)))%2 == 1
|
||||
case sep:
|
||||
if !quoted {
|
||||
return s[:i], s[i+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", s
|
||||
}
|
||||
|
||||
func unquote(s string) string {
|
||||
if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
|
||||
return s
|
||||
}
|
||||
s = s[1 : len(s)-1]
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
i++
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseNode keeps numeric ports and drops RFC 7239 brackets and obfuscated ports
|
||||
func parseNode(node string) netip.AddrPort {
|
||||
if node == "" {
|
||||
return netip.AddrPort{}
|
||||
}
|
||||
if addr, err := netip.ParseAddr(node); err == nil {
|
||||
return netip.AddrPortFrom(addr, 0)
|
||||
}
|
||||
if addrPort, err := netip.ParseAddrPort(node); err == nil {
|
||||
return addrPort
|
||||
}
|
||||
if strings.HasPrefix(node, "[") {
|
||||
node, _, _ = strings.Cut(node[1:], "]")
|
||||
} else {
|
||||
node, _, _ = strings.Cut(node, ":")
|
||||
}
|
||||
addr, _ := netip.ParseAddr(node)
|
||||
return netip.AddrPortFrom(addr, 0)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestForwardedHeadersHandler(t *testing.T) {
|
||||
defaultProxies := []string{"127.0.0.0/8", "::1/128"}
|
||||
cases := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
limit int
|
||||
proxies []string
|
||||
header http.Header
|
||||
expected string
|
||||
}{
|
||||
{"chain shorter than limit", "127.0.0.1:1234", 3, defaultProxies, http.Header{"X-Forwarded-For": {"1.1.1.1, 2.2.2.2"}}, "1.1.1.1:0"},
|
||||
{"repeated header without spaces", "127.0.0.1:1234", 2, defaultProxies, http.Header{"X-Forwarded-For": {"1.1.1.1,2.2.2.2", "3.3.3.3"}}, "2.2.2.2:0"},
|
||||
{"non-ip entry", "127.0.0.1:1234", 1, defaultProxies, http.Header{"X-Forwarded-For": {"evil, <script>"}}, "127.0.0.1:1234"},
|
||||
{"mapped ipv4", "[::ffff:127.0.0.1]:1234", 1, defaultProxies, http.Header{"X-Forwarded-For": {"::ffff:1.2.3.4"}}, "1.2.3.4:0"},
|
||||
{"entry with port", "127.0.0.1:1234", 1, defaultProxies, http.Header{"X-Forwarded-For": {"[2001:db8::1]:443"}}, "[2001:db8::1]:443"},
|
||||
{"zoned ipv6 entry", "[::1]:1234", 1, defaultProxies, http.Header{"X-Forwarded-For": {"2001:db8::1%eth0"}}, "[2001:db8::1]:0"},
|
||||
{"last real ip and port win", "127.0.0.1:1234", 1, defaultProxies, http.Header{"X-Real-Ip": {"8.8.8.8", "9.9.9.9"}, "X-Real-Port": {"1", "4321"}, "X-Forwarded-For": {"1.1.1.1"}}, "9.9.9.9:4321"},
|
||||
{"empty real ip falls through", "127.0.0.1:1234", 1, defaultProxies, http.Header{"X-Real-Ip": {""}, "X-Forwarded-For": {"1.1.1.1"}}, "1.1.1.1:0"},
|
||||
{"forwarded ignored with x-forwarded-for", "127.0.0.1:1234", 1, defaultProxies, http.Header{"X-Forwarded-For": {"1.1.1.1"}, "Forwarded": {"for=6.6.6.6"}}, "1.1.1.1:0"},
|
||||
{"forwarded hops skip quoted separators, empty elements and malformed prefix", "127.0.0.1:1234", 2, defaultProxies, http.Header{"Forwarded": {`for="x, For="[2001:db8::1]:4711";host="a,b;for=6.6.6.6"`, `, for=_hidden`}}, "[2001:db8::1]:4711"},
|
||||
{"forwarded quoted pair and obfuscated port", "127.0.0.1:1234", 1, defaultProxies, http.Header{"Forwarded": {`for="192.0.2\.43:_port"`}}, "192.0.2.43:0"},
|
||||
{"forwarded repeated for", "127.0.0.1:1234", 1, defaultProxies, http.Header{"Forwarded": {"for=1.1.1.1;for=2.2.2.2"}}, "127.0.0.1:1234"},
|
||||
{"untrusted peer", "8.8.8.8:1234", 1, defaultProxies, http.Header{"X-Forwarded-For": {"1.1.1.1"}}, "8.8.8.8:1234"},
|
||||
{"zoned peer", "[fe80::1%en1]:1234", 1, []string{"fe80::1%en0"}, http.Header{"X-Forwarded-For": {"1.1.1.1"}}, "[fe80::1%en1]:1234"},
|
||||
{"bare ip proxy after invalid one", "10.0.0.1:1234", 1, []string{"invalid", "10.0.0.1"}, http.Header{"X-Forwarded-For": {"1.1.1.1"}}, "1.1.1.1:0"},
|
||||
{"mapped cidr proxy", "10.0.0.1:1234", 1, []string{"::ffff:10.0.0.0/104"}, http.Header{"X-Forwarded-For": {"1.1.1.1"}}, "1.1.1.1:0"},
|
||||
{"mapped cidr spanning native ipv6", "[::1]:1234", 1, []string{"::ffff:10.0.0.0/80"}, http.Header{"X-Forwarded-For": {"1.1.1.1"}}, "1.1.1.1:0"},
|
||||
{"wildcard proxy with zoned peer", "[fe80::1%en0]:1234", 1, []string{"*"}, http.Header{"X-Forwarded-For": {"1.1.1.1"}}, "1.1.1.1:0"},
|
||||
{"unix socket", "@", 1, defaultProxies, http.Header{"X-Forwarded-For": {"1.1.1.1"}}, "1.1.1.1:0"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var got string
|
||||
handler := ForwardedHeadersHandler(c.limit, c.proxies)(http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) {
|
||||
got = req.RemoteAddr
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = c.remoteAddr
|
||||
req.Header = c.header
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
assert.Equal(t, c.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ package common
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/gtprof"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
"gitea.dev/modules/web/routing"
|
||||
"gitea.dev/services/context"
|
||||
|
||||
"github.com/chi-middleware/proxy"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -109,18 +107,6 @@ func ChiRoutePathHandler() func(h http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func ForwardedHeadersHandler(limit int, trustedProxies []string) func(h http.Handler) http.Handler {
|
||||
opt := proxy.NewForwardedHeadersOptions().WithForwardLimit(limit).ClearTrustedProxies()
|
||||
for _, n := range trustedProxies {
|
||||
if !strings.Contains(n, "/") {
|
||||
opt.AddTrustedProxy(n)
|
||||
} else {
|
||||
opt.AddTrustedNetwork(n)
|
||||
}
|
||||
}
|
||||
return proxy.ForwardedHeaders(opt)
|
||||
}
|
||||
|
||||
func MustInitSessioner() func(next http.Handler) http.Handler {
|
||||
middleware, err := session.Sessioner()
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user