Honour forwarded headers only from trusted proxies (closes #259)
check / check (push) Failing after 6s

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:
sneak committed 2026-11-10 19:57:27 +00:00
1 parent bc8aa6bb96
commit 51d9d774fd
10 files changed
+519 -42

No files matched your search

+75 -6
View File
@@ -6,12 +6,15 @@ import (
"fmt"
"log/slog"
"math"
"net/netip"
"os"
"strconv"
"strings"
"time"
"github.com/spf13/viper"
"go.uber.org/fx"
"go4.org/netipx"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
@@ -32,6 +35,10 @@ Type /help for available commands.`
const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
// defaultTrustedProxies is TRUSTED_PROXIES when it is not set: the
// private address ranges of RFC 1918.
const defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
var errOutOfRange = errors.New("setting out of range")
// Params defines the dependencies for creating a Config.
@@ -64,13 +71,14 @@ type Config struct {
LoginRateLimit float64
LoginRateBurst int
IRCListenAddr string
TrustedProxies *netipx.IPSet
params *Params
log *slog.Logger
}
// New creates a new Config by reading from files and environment variables.
// It returns an error when a number, true/false or duration setting does
// not parse or is out of range.
// It returns an error when a number, true/false, duration or address
// setting does not parse or is out of range.
func New(
_ fx.Lifecycle, params Params,
) (*Config, error) {
@@ -103,6 +111,7 @@ func New(
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
viper.SetDefault("LOGIN_RATE_BURST", "5")
viper.SetDefault("IRC_LISTEN_ADDR", ":6667")
viper.SetDefault("TRUSTED_PROXIES", defaultTrustedProxies)
err := viper.ReadInConfig()
if err != nil {
@@ -151,10 +160,10 @@ func New(
return cfg, nil
}
// parseSettings reads the number and true/false settings into cfg and
// checks the duration settings. Each number and duration must be above
// zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets the
// system pick a port, and 0 bits turns proof of work off. The error
// parseSettings reads the number, true/false and address settings into
// cfg and checks the duration settings. Each number and duration must be
// above zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets
// the system pick a port, and 0 bits turns proof of work off. The error
// names each setting that does not parse or is out of range.
func parseSettings(cfg *Config) error {
return errors.Join(
@@ -168,9 +177,69 @@ func parseSettings(cfg *Config) error {
checkDuration("MESSAGE_MAX_AGE", cfg.MessageMaxAge),
checkDuration("QUEUE_MAX_AGE", cfg.QueueMaxAge),
checkDuration("SESSION_IDLE_TIMEOUT", cfg.SessionIdleTimeout),
parseTrustedProxies(&cfg.TrustedProxies),
)
}
// parseTrustedProxies sets *value to the addresses in TRUSTED_PROXIES, a
// comma-separated list of networks such as 10.0.0.0/8 and single
// addresses such as 192.0.2.1. The empty string trusts nothing. Viper
// counts an empty environment variable as unset, so the environment is
// read directly as well.
func parseTrustedProxies(value **netipx.IPSet) error {
raw := viper.GetString("TRUSTED_PROXIES")
fromEnv, set := os.LookupEnv("TRUSTED_PROXIES")
if set {
raw = fromEnv
}
var builder netipx.IPSetBuilder
if strings.TrimSpace(raw) != "" {
for entry := range strings.SplitSeq(raw, ",") {
err := addTrustedProxy(&builder, strings.TrimSpace(entry))
if err != nil {
return fmt.Errorf("cannot parse TRUSTED_PROXIES: %w", err)
}
}
}
ipSet, err := builder.IPSet()
if err != nil {
return fmt.Errorf("cannot parse TRUSTED_PROXIES: %w", err)
}
*value = ipSet
return nil
}
// addTrustedProxy adds entry, a network with a slash or a single
// address, to builder. An IPv4 address written in IPv6 form, such as
// ::ffff:192.0.2.1, is added as IPv4.
func addTrustedProxy(builder *netipx.IPSetBuilder, entry string) error {
if strings.Contains(entry, "/") {
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return err //nolint:wrapcheck // the caller names the setting
}
builder.AddPrefix(prefix)
return nil
}
addr, err := netip.ParseAddr(entry)
if err != nil {
return err //nolint:wrapcheck // the caller names the setting
}
builder.Add(addr.Unmap())
return nil
}
// parseInt sets *value to the setting key read as a whole number. It
// returns an error when the value does not parse or is below minimum.
func parseInt(key string, minimum int, value *int) error {
+78
View File
@@ -1,6 +1,7 @@
package config_test
import (
"net/netip"
"os"
"strings"
"testing"
@@ -158,6 +159,9 @@ func TestBadSettingStopsStartup(t *testing.T) {
{key: "MESSAGE_MAX_AGE", values: []string{"30d", "0", "-1h"}},
{key: "QUEUE_MAX_AGE", values: []string{"720", "0s", "-24h"}},
{key: "SESSION_IDLE_TIMEOUT", values: []string{"a month", "0", "-1m"}},
{key: "TRUSTED_PROXIES", values: []string{
"proxy.example.com", "10.0.0.0/33", "192.0.2.1:8080",
}},
}
for _, test := range tests {
@@ -206,3 +210,77 @@ func TestIRCListenAddrEmptyTurnsListenerOff(t *testing.T) {
t.Errorf("IRCListenAddr = %q, want empty", cfg.IRCListenAddr)
}
}
func TestTrustedProxies(t *testing.T) {
// Each case lists addresses that must be in the trusted set and
// addresses that must not.
tests := []struct {
name string
set bool
value string
trusted []string
untrusted []string
}{
{
name: "unset trusts the RFC 1918 ranges",
set: false,
value: "",
trusted: []string{
"10.1.2.3", "172.16.0.1", "172.31.255.254", "192.168.1.1",
},
untrusted: []string{
"127.0.0.1", "172.32.0.1", "192.0.2.1", "2001:db8::1",
},
},
{
name: "a value replaces the default",
set: true,
value: "198.51.100.0/24, 192.0.2.7,2001:db8::/32",
trusted: []string{"198.51.100.200", "192.0.2.7", "2001:db8::5"},
untrusted: []string{"10.1.2.3", "192.168.1.1", "192.0.2.8"},
},
{
name: "a mapped IPv6 address is trusted as IPv4",
set: true,
value: "::ffff:192.0.2.7",
trusted: []string{"192.0.2.7"},
untrusted: []string{"10.1.2.3"},
},
{
name: "the empty string trusts nothing",
set: true,
value: "",
trusted: nil,
untrusted: []string{"10.1.2.3", "192.168.1.1", "127.0.0.1"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// t.Setenv puts back whatever value was there after the
// test, including when Unsetenv removes it.
t.Setenv("TRUSTED_PROXIES", test.value)
if !test.set {
err := os.Unsetenv("TRUSTED_PROXIES")
if err != nil {
t.Fatal(err)
}
}
proxies := newConfig(t).TrustedProxies
for _, addr := range test.trusted {
if !proxies.Contains(netip.MustParseAddr(addr)) {
t.Errorf("%s is not trusted", addr)
}
}
for _, addr := range test.untrusted {
if proxies.Contains(netip.MustParseAddr(addr)) {
t.Errorf("%s is trusted", addr)
}
}
})
}
}
+2 -18
View File
@@ -42,25 +42,9 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize
}
// clientIP extracts the connecting client's IP address
// from the request, checking X-Forwarded-For and
// X-Real-IP headers before falling back to RemoteAddr.
// clientIP returns the IP address in request.RemoteAddr, which the
// ClientIP middleware has set to the client's address.
func clientIP(request *http.Request) string {
if forwarded := request.Header.Get("X-Forwarded-For"); forwarded != "" {
// X-Forwarded-For can contain a comma-separated list;
// the first entry is the original client.
parts := strings.SplitN(forwarded, ",", 2) //nolint:mnd
ip := strings.TrimSpace(parts[0])
if ip != "" {
return ip
}
}
if realIP := request.Header.Get("X-Real-IP"); realIP != "" {
return strings.TrimSpace(realIP)
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
+61
View File
@@ -4281,6 +4281,67 @@ func TestLoginRateLimitExceeded(t *testing.T) {
}
}
func TestLoginRateLimitIgnoresForwardedHeaders(t *testing.T) {
t.Parallel()
// The test client connects from 127.0.0.1, which is outside the
// default trusted proxies, so the server must ignore these headers.
tests := []struct {
name string
header string
}{
{name: "X-Forwarded-For", header: "X-Forwarded-For"},
{name: "X-Real-IP", header: "X-Real-IP"},
}
loginBody, err := json.Marshal(map[string]string{
nickKey: "nosuchuser", passwordKey: "doesnotmatter",
})
if err != nil {
t.Fatal(err)
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
// The default burst is 5 attempts, so the sixth gets 429
// although each claims a new address.
var status int
for attempt := range 6 {
request, rErr := http.NewRequestWithContext(
t.Context(), http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if rErr != nil {
t.Fatal(rErr)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set(
test.header, fmt.Sprintf("203.0.113.%d", attempt+1),
)
resp, dErr := http.DefaultClient.Do(request)
if dErr != nil {
t.Fatal(dErr)
}
status = resp.StatusCode
_ = resp.Body.Close()
}
if status != http.StatusTooManyRequests {
t.Fatalf("sixth attempt got %d, want 429", status)
}
})
}
}
func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
t.Parallel()
+75
View File
@@ -5,6 +5,8 @@ import (
"log/slog"
"net"
"net/http"
"net/netip"
"strings"
"time"
basicauth "github.com/99designs/basicauth-go"
@@ -15,6 +17,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 +65,78 @@ 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()
}
entries := strings.Split(forwarded, ",")
client := peer
for i := len(entries) - 1; i >= 0; i-- {
addr, err := netip.ParseAddr(strings.TrimSpace(entries[i]))
if err != nil {
return peer
}
client = addr.Unmap()
if !trusted.Contains(client) {
break
}
}
return client
}
type loggingResponseWriter struct {
http.ResponseWriter
+180
View File
@@ -0,0 +1,180 @@
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"
)
func TestClientIP(t *testing.T) {
t.Parallel()
// The proxies are in 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.
tests := []struct {
name string
remoteAddr string
forwarded []string
realIP string
want string
}{
{
name: "untrusted peer ignores forged headers",
remoteAddr: "192.0.2.1:4000",
forwarded: []string{"203.0.113.7"},
realIP: "203.0.113.8",
want: "192.0.2.1:4000",
},
{
name: "trusted peer without headers",
remoteAddr: "10.0.0.1:4000",
want: "10.0.0.1:4000",
},
{
name: "trusted peer gives rightmost untrusted entry",
remoteAddr: "10.0.0.1:4000",
forwarded: []string{"198.51.100.9, 203.0.113.7, 10.0.0.2"},
want: "203.0.113.7:4000",
},
{
name: "entries split over several headers",
remoteAddr: "10.0.0.1:4000",
forwarded: []string{"198.51.100.9", "203.0.113.7", "10.0.0.2"},
want: "203.0.113.7:4000",
},
{
name: "all-trusted chain gives leftmost entry",
remoteAddr: "10.0.0.1:4000",
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: "10.0.0.1:4000",
realIP: "203.0.113.8",
want: "203.0.113.8:4000",
},
{
name: "X-Forwarded-For wins over X-Real-IP",
remoteAddr: "10.0.0.1:4000",
forwarded: []string{"203.0.113.7"},
realIP: "203.0.113.8",
want: "203.0.113.7:4000",
},
{
name: "unparseable X-Forwarded-For entry gives peer",
remoteAddr: "10.0.0.1:4000",
forwarded: []string{"203.0.113.7, not-an-ip, 10.0.0.2"},
want: "10.0.0.1:4000",
},
{
name: "unparseable entry past the client is not read",
remoteAddr: "10.0.0.1:4000",
forwarded: []string{"not-an-ip, 203.0.113.7"},
want: "203.0.113.7:4000",
},
{
name: "unparseable X-Real-IP gives peer",
remoteAddr: "10.0.0.1:4000",
realIP: "not-an-ip",
want: "10.0.0.1:4000",
},
{
name: "mapped IPv6 peer is unmapped and trusted",
remoteAddr: "[::ffff:10.0.0.1]:4000",
forwarded: []string{"203.0.113.7"},
want: "203.0.113.7:4000",
},
{
name: "mapped IPv6 peer outside the set",
remoteAddr: "[::ffff:192.0.2.1]:4000",
forwarded: []string{"203.0.113.7"},
want: "192.0.2.1:4000",
},
{
name: "IPv6 client",
remoteAddr: "10.0.0.1:4000",
forwarded: []string{"2001:db8::1"},
want: "[2001:db8::1]:4000",
},
}
handler := newMiddleware(t, "10.0.0.0/8").ClientIP()(
http.HandlerFunc(func(
writer http.ResponseWriter, request *http.Request,
) {
_, _ = writer.Write([]byte(request.RemoteAddr))
}),
)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
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)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
got := recorder.Body.String()
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
}
+1
View File
@@ -23,6 +23,7 @@ func (srv *Server) SetupRoutes() {
srv.router.Use(middleware.Recoverer)
srv.router.Use(middleware.RequestID)
srv.router.Use(srv.mw.ClientIP())
srv.router.Use(srv.mw.Logging())
if viper.GetString("METRICS_USERNAME") != "" {