Honour forwarded headers only from trusted proxies (closes #259)
check / check (push) Failing after 7s
check / check (push) Failing after 7s
A new ClientIP middleware sets request.RemoteAddr to the client's address before the request is logged. X-Forwarded-For and X-Real-IP count only when the peer is in TRUSTED_PROXIES, which defaults to the RFC 1918 ranges. The login rate limit, the session's IP address and hostname, and the request log all use that address. Model: opus-5-5
This commit is contained in:
1 parent
bc8aa6bb96
commit
f5dd70047e
10 files changed
+599
-44
No files matched your search
@@ -5,6 +5,9 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
@@ -15,6 +18,7 @@ import (
|
||||
"github.com/slok/go-http-metrics/middleware/std"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/fx"
|
||||
"go4.org/netipx"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
@@ -62,6 +66,77 @@ func ipFromHostPort(hostPort string) string {
|
||||
return host
|
||||
}
|
||||
|
||||
// ClientIP returns middleware that replaces request.RemoteAddr with the
|
||||
// client's address and keeps the port of the TCP connection. The
|
||||
// X-Forwarded-For and X-Real-IP headers count only when the connection
|
||||
// comes from an address in TRUSTED_PROXIES.
|
||||
func (mware *Middleware) ClientIP() func(http.Handler) http.Handler {
|
||||
trusted := mware.params.Config.TrustedProxies
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
peer, err := netip.ParseAddrPort(request.RemoteAddr)
|
||||
if err == nil {
|
||||
client := clientAddr(
|
||||
peer.Addr().Unmap(), request.Header, trusted,
|
||||
)
|
||||
request.RemoteAddr = netip.AddrPortFrom(
|
||||
client, peer.Port(),
|
||||
).String()
|
||||
}
|
||||
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// clientAddr returns the client's address for a request from peer. A
|
||||
// peer outside trusted is the client, whatever its headers say. A
|
||||
// trusted peer is a proxy. Its X-Forwarded-For list is read from the
|
||||
// right, past the trusted addresses, to the first untrusted one; when
|
||||
// all are trusted, the client is the leftmost. Without X-Forwarded-For,
|
||||
// the client is X-Real-IP. An entry that is not an IP address gives
|
||||
// peer.
|
||||
func clientAddr(
|
||||
peer netip.Addr, header http.Header, trusted *netipx.IPSet,
|
||||
) netip.Addr {
|
||||
if !trusted.Contains(peer) {
|
||||
return peer
|
||||
}
|
||||
|
||||
forwarded := strings.Join(header.Values("X-Forwarded-For"), ",")
|
||||
if forwarded == "" {
|
||||
realIP, err := netip.ParseAddr(
|
||||
strings.TrimSpace(header.Get("X-Real-IP")),
|
||||
)
|
||||
if err != nil {
|
||||
return peer
|
||||
}
|
||||
|
||||
return realIP.Unmap()
|
||||
}
|
||||
|
||||
client := peer
|
||||
|
||||
for _, entry := range slices.Backward(strings.Split(forwarded, ",")) {
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(entry))
|
||||
if err != nil {
|
||||
return peer
|
||||
}
|
||||
|
||||
client = addr.Unmap()
|
||||
if !trusted.Contains(client) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go4.org/netipx"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/internal/middleware"
|
||||
)
|
||||
|
||||
// The tests trust 10.0.0.0/8. Clients use the documentation ranges
|
||||
// 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24.
|
||||
const (
|
||||
trustedNetwork = "10.0.0.0/8"
|
||||
proxyPeer = "10.0.0.1:4000"
|
||||
directPeer = "192.0.2.1:4000"
|
||||
client = "203.0.113.7"
|
||||
clientWithPort = "203.0.113.7:4000"
|
||||
realIPClient = "203.0.113.8"
|
||||
)
|
||||
|
||||
type clientIPTest struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
forwarded []string
|
||||
realIP string
|
||||
want string
|
||||
}
|
||||
|
||||
func clientIPTests() []clientIPTest {
|
||||
return []clientIPTest{
|
||||
{
|
||||
name: "untrusted peer ignores forged headers",
|
||||
remoteAddr: directPeer,
|
||||
forwarded: []string{client},
|
||||
realIP: realIPClient,
|
||||
want: directPeer,
|
||||
},
|
||||
{name: "trusted peer without headers", remoteAddr: proxyPeer, want: proxyPeer},
|
||||
{
|
||||
name: "trusted peer gives rightmost untrusted entry",
|
||||
remoteAddr: proxyPeer,
|
||||
forwarded: []string{"198.51.100.9, 203.0.113.7, 10.0.0.2"},
|
||||
want: clientWithPort,
|
||||
},
|
||||
{
|
||||
name: "entries split over several headers",
|
||||
remoteAddr: proxyPeer,
|
||||
forwarded: []string{"198.51.100.9", client, "10.0.0.2"},
|
||||
want: clientWithPort,
|
||||
},
|
||||
{
|
||||
name: "all-trusted chain gives leftmost entry",
|
||||
remoteAddr: proxyPeer,
|
||||
forwarded: []string{"10.0.0.3, 10.0.0.2"},
|
||||
want: "10.0.0.3:4000",
|
||||
},
|
||||
{
|
||||
name: "X-Real-IP without X-Forwarded-For",
|
||||
remoteAddr: proxyPeer,
|
||||
realIP: realIPClient,
|
||||
want: "203.0.113.8:4000",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-For wins over X-Real-IP",
|
||||
remoteAddr: proxyPeer,
|
||||
forwarded: []string{client},
|
||||
realIP: realIPClient,
|
||||
want: clientWithPort,
|
||||
},
|
||||
{
|
||||
name: "unparseable X-Forwarded-For entry gives peer",
|
||||
remoteAddr: proxyPeer,
|
||||
forwarded: []string{"203.0.113.7, not-an-ip, 10.0.0.2"},
|
||||
want: proxyPeer,
|
||||
},
|
||||
{
|
||||
name: "unparseable entry past the client is not read",
|
||||
remoteAddr: proxyPeer,
|
||||
forwarded: []string{"not-an-ip, 203.0.113.7"},
|
||||
want: clientWithPort,
|
||||
},
|
||||
{
|
||||
name: "unparseable X-Real-IP gives peer",
|
||||
remoteAddr: proxyPeer,
|
||||
realIP: "not-an-ip",
|
||||
want: proxyPeer,
|
||||
},
|
||||
{
|
||||
name: "mapped IPv6 peer is unmapped and trusted",
|
||||
remoteAddr: "[::ffff:10.0.0.1]:4000",
|
||||
forwarded: []string{client},
|
||||
want: clientWithPort,
|
||||
},
|
||||
{
|
||||
name: "mapped IPv6 peer outside the set",
|
||||
remoteAddr: "[::ffff:192.0.2.1]:4000",
|
||||
forwarded: []string{client},
|
||||
want: directPeer,
|
||||
},
|
||||
{
|
||||
name: "IPv6 client",
|
||||
remoteAddr: proxyPeer,
|
||||
forwarded: []string{"2001:db8::1"},
|
||||
want: "[2001:db8::1]:4000",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mware := newMiddleware(t, trustedNetwork)
|
||||
|
||||
for _, test := range clientIPTests() {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got string
|
||||
|
||||
handler := mware.ClientIP()(http.HandlerFunc(func(
|
||||
_ http.ResponseWriter, request *http.Request,
|
||||
) {
|
||||
got = request.RemoteAddr
|
||||
}))
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
request.RemoteAddr = test.remoteAddr
|
||||
|
||||
for _, value := range test.forwarded {
|
||||
request.Header.Add("X-Forwarded-For", value)
|
||||
}
|
||||
|
||||
if test.realIP != "" {
|
||||
request.Header.Set("X-Real-IP", test.realIP)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), request)
|
||||
|
||||
if got != test.want {
|
||||
t.Errorf("RemoteAddr = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newMiddleware returns a Middleware that trusts the network trusted.
|
||||
func newMiddleware(t *testing.T, trusted string) *middleware.Middleware {
|
||||
t.Helper()
|
||||
|
||||
var builder netipx.IPSetBuilder
|
||||
|
||||
builder.AddPrefix(netip.MustParsePrefix(trusted))
|
||||
|
||||
trustedSet, err := builder.IPSet()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
globs := &globals.Globals{
|
||||
Appname: "neoirc-test",
|
||||
Version: "test",
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
log, err := logger.New(nil, logger.Params{Globals: globs})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mware, err := middleware.New(nil, middleware.Params{
|
||||
Logger: log,
|
||||
Globals: globs,
|
||||
Config: &config.Config{TrustedProxies: trustedSet},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return mware
|
||||
}
|
||||
Reference in new issue
Block a user