Trust reverse proxies at private addresses by default (closes #198)
check / check (push) Canceled after 0s

With TRUSTED_PROXIES unset, the server now trusts reverse proxies in the
private ranges of RFC 1918. A proxy such as traefik on a Docker network is
then believed, so /ip/, the home page's curl card and the request log give
the client its X-Forwarded-For names instead of the proxy's address.

A list in TRUSTED_PROXIES replaces the default. TRUSTED_PROXIES set but
empty trusts no proxy, so the config package reads it with os.LookupEnv.
A direct client at a public address still cannot choose its address with
a forged header.

Model: opus-5-5
This commit is contained in:
2026-10-27 17:51:55 +00:00
parent 281d162e27
commit a78c1f5d49
5 changed files with 241 additions and 26 deletions
+25 -16
View File
@@ -469,19 +469,29 @@ Configuration is handled via environment variables and OS-specific paths:
| `XDG_DATA_HOME` | `/var/lib` (in the Docker image) | Base of the state directory; must be an absolute path | | `XDG_DATA_HOME` | `/var/lib` (in the Docker image) | Base of the state directory; must be an absolute path |
| `GOMEMLIMIT` | `2560MiB` (in the Docker image) | Go soft memory limit; see Memory | | `GOMEMLIMIT` | `2560MiB` (in the Docker image) | Go soft memory limit; see Memory |
| `MALLOC_ARENA_MAX` | `2` (in the Docker image) | glibc malloc arena cap, a positive whole number; see Memory | | `MALLOC_ARENA_MAX` | `2` (in the Docker image) | glibc malloc arena cap, a positive whole number; see Memory |
| `TRUSTED_PROXIES` | (empty) | Addresses and CIDR ranges of trusted proxies; see below | | `TRUSTED_PROXIES` | the RFC 1918 ranges | Addresses and CIDR ranges of trusted proxies; see below |
A variable that is set to an invalid value stops the start with an error and a A variable that is set to an invalid value stops the start with an error and a
non-zero exit. An empty variable counts as unset. non-zero exit. An empty variable counts as unset. `TRUSTED_PROXIES` is the one
exception: set but empty, it makes the server trust no proxy.
`TRUSTED_PROXIES` lists the reverse proxies whose `X-Forwarded-For` the server `TRUSTED_PROXIES` names the reverse proxies whose `X-Forwarded-For` the server
believes, comma-separated, such as `10.0.0.0/8,192.0.2.1`. For a request from believes:
one of them, the request log and `/ip/` use the last address in
`X-Forwarded-For` that is not a trusted proxy: each proxy adds the address it - When it is not set, the server trusts the private ranges of RFC 1918:
received the request from to the end, so the entries before it are whatever the - `10.0.0.0/8`
client sent. For a request from any other address they use the address the - `172.16.0.0/12`
request came from, whatever forwarding headers it carries. `X-Real-IP` and - `192.168.0.0/16`
`True-Client-IP` are not read. - When it lists addresses and CIDR ranges, comma-separated, such as
`10.0.0.0/8,192.0.2.1`, the server trusts those instead.
- When it is set but empty (`TRUSTED_PROXIES=`), the server trusts no proxy.
For a request from a trusted proxy, the request log and `/ip/` use the last
address in `X-Forwarded-For` that is not a trusted proxy. Each proxy adds the
address it received the request from to the end, so the entries before it are
whatever the client sent. For a request from any other address they use the
address the request came from, whatever forwarding headers it carries.
`X-Real-IP` and `True-Client-IP` are not read.
State directory (the database and the blocklist copies): State directory (the database and the blocklist copies):
@@ -575,12 +585,11 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app needs:
- Container port: `8080`. - Container port: `8080`.
- Volume: one, at container path `/var/lib/berlin.sneak.app.routewatch`. - Volume: one, at container path `/var/lib/berlin.sneak.app.routewatch`.
- Environment: `TRUSTED_PROXIES`, set to the address the reverse proxy in front - Environment: nothing. The server trusts a reverse proxy at a private address
of the app connects from, as the container sees it; without it the request log without `TRUSTED_PROXIES` (see Configuration). Set it only when the proxy in
and `/ip/` give the proxy's address for every request. Nothing else is needed: front of the app connects from a public address. Leave `XDG_DATA_HOME`,
leave `XDG_DATA_HOME`, `GOMEMLIMIT` and `MALLOC_ARENA_MAX` at the image's `GOMEMLIMIT` and `MALLOC_ARENA_MAX` at the image's values. `DEBUG=routewatch`
values. `DEBUG=routewatch` is optional and adds the `System stats` memory line is optional and adds the `System stats` memory line to the log.
to the log.
- Memory Limit: `5g`, the 5 GiB limit from Memory above. upaas sets no swap - Memory Limit: `5g`, the 5 GiB limit from Memory above. upaas sets no swap
limit, so on a host with swap Docker allows the same amount of swap again. limit, so on a host with swap Docker allows the same amount of swap again.
- Health check: the image's `HEALTHCHECK` requests - Health check: the image's `HEALTHCHECK` requests
+5
View File
@@ -35,6 +35,11 @@ tested.
# Completed Steps # Completed Steps
- 2026-10-27: with `TRUSTED_PROXIES` unset, the server trusts reverse proxies at
the private addresses of RFC 1918, so `/ip/`, the home page's `curl` card and
the request log give the client a proxy names in `X-Forwarded-For`. They used
to give the proxy's address. A list in `TRUSTED_PROXIES` replaces the default,
and `TRUSTED_PROXIES` set but empty trusts no proxy (closes #198)
- 2026-10-27: the home page has cards with figures of the routing table: the - 2026-10-27: the home page has cards with figures of the routing table: the
newest and oldest kept route, the newest and oldest updated AS, the AS with newest and oldest kept route, the newest and oldest updated AS, the AS with
the most prefixes, the ASes with a single prefix, and fifteen well-known the most prefixes, the ASes with a single prefix, and fifteen well-known
+22 -4
View File
@@ -30,6 +30,14 @@ const (
maxPort = 65535 maxPort = 65535
) )
// The private address ranges of RFC 1918. The HTTP server trusts a reverse
// proxy in any of them when TRUSTED_PROXIES is not set.
const (
privateRange10 = "10.0.0.0/8"
privateRange172 = "172.16.0.0/12"
privateRange192 = "192.168.0.0/16"
)
var ( var (
errRelativeXDGDataHome = errors.New("XDG_DATA_HOME must be an absolute path") errRelativeXDGDataHome = errors.New("XDG_DATA_HOME must be an absolute path")
errUnsupportedOS = errors.New("unsupported operating system") errUnsupportedOS = errors.New("unsupported operating system")
@@ -159,11 +167,21 @@ func getPort() (int, error) {
return int(port), nil return int(port), nil
} }
// getTrustedProxies returns the addresses and CIDR ranges listed in // getTrustedProxies returns the reverse proxies the HTTP server trusts. When
// TRUSTED_PROXIES, such as "10.0.0.0/8,192.0.2.1", or none when it is not set. // TRUSTED_PROXIES is not set, they are the private ranges of RFC 1918. When it
// An address is returned as a range holding only that address. // is set, they are the addresses and CIDR ranges it lists, such as
// "10.0.0.0/8,192.0.2.1", and none when it is empty. An address is returned as
// a range holding only that address.
func getTrustedProxies() ([]netip.Prefix, error) { func getTrustedProxies() ([]netip.Prefix, error) {
value := os.Getenv("TRUSTED_PROXIES") value, set := os.LookupEnv("TRUSTED_PROXIES")
if !set {
return []netip.Prefix{
netip.MustParsePrefix(privateRange10),
netip.MustParsePrefix(privateRange172),
netip.MustParsePrefix(privateRange192),
}, nil
}
if value == "" { if value == "" {
return nil, nil return nil, nil
} }
+40 -5
View File
@@ -2,6 +2,7 @@ package config
import ( import (
"net/netip" "net/netip"
"os"
"runtime" "runtime"
"slices" "slices"
"testing" "testing"
@@ -46,25 +47,47 @@ func TestNewRefusesInvalidPort(t *testing.T) {
} }
} }
func TestNewReadsTrustedProxies(t *testing.T) { // TestNewTrustsPrivateRangesByDefault checks that with TRUSTED_PROXIES unset
// the trusted proxies are the private ranges of RFC 1918. Before the fix no
// proxy was trusted, so a reverse proxy at a private address was taken for the
// client (https://git.eeqj.de/sneak/routewatch/issues/198).
func TestNewTrustsPrivateRangesByDefault(t *testing.T) {
t.Setenv("PORT", "") t.Setenv("PORT", "")
t.Setenv("XDG_DATA_HOME", "") t.Setenv("XDG_DATA_HOME", "")
// t.Setenv puts the variable back when the test ends.
t.Setenv("TRUSTED_PROXIES", "") t.Setenv("TRUSTED_PROXIES", "")
err := os.Unsetenv("TRUSTED_PROXIES")
if err != nil {
t.Fatal(err)
}
cfg, err := New() cfg, err := New()
if err != nil { if err != nil {
t.Fatalf("New() without TRUSTED_PROXIES: %v", err) t.Fatalf("New() without TRUSTED_PROXIES: %v", err)
} }
if len(cfg.TrustedProxies) != 0 { want := []netip.Prefix{
t.Errorf("New() without TRUSTED_PROXIES: TrustedProxies = %v, want none", netip.MustParsePrefix("10.0.0.0/8"),
cfg.TrustedProxies) netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"),
} }
if !slices.Equal(cfg.TrustedProxies, want) {
t.Errorf("New() without TRUSTED_PROXIES: TrustedProxies = %v, want %v",
cfg.TrustedProxies, want)
}
}
// TestNewReadsTrustedProxies checks that a list in TRUSTED_PROXIES replaces
// the default, and that TRUSTED_PROXIES set but empty trusts no proxy.
func TestNewReadsTrustedProxies(t *testing.T) {
t.Setenv("PORT", "")
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("TRUSTED_PROXIES", "10.0.0.0/8, 192.0.2.1,2001:db8::/32") t.Setenv("TRUSTED_PROXIES", "10.0.0.0/8, 192.0.2.1,2001:db8::/32")
cfg, err = New() cfg, err := New()
if err != nil { if err != nil {
t.Fatalf("New() with TRUSTED_PROXIES: %v", err) t.Fatalf("New() with TRUSTED_PROXIES: %v", err)
} }
@@ -77,6 +100,18 @@ func TestNewReadsTrustedProxies(t *testing.T) {
if !slices.Equal(cfg.TrustedProxies, want) { if !slices.Equal(cfg.TrustedProxies, want) {
t.Errorf("TrustedProxies = %v, want %v", cfg.TrustedProxies, want) t.Errorf("TrustedProxies = %v, want %v", cfg.TrustedProxies, want)
} }
t.Setenv("TRUSTED_PROXIES", "")
cfg, err = New()
if err != nil {
t.Fatalf("New() with TRUSTED_PROXIES set but empty: %v", err)
}
if len(cfg.TrustedProxies) != 0 {
t.Errorf("New() with TRUSTED_PROXIES set but empty: TrustedProxies = %v, "+
"want none", cfg.TrustedProxies)
}
} }
func TestNewRefusesInvalidTrustedProxies(t *testing.T) { func TestNewRefusesInvalidTrustedProxies(t *testing.T) {
+149 -1
View File
@@ -1,25 +1,38 @@
package server package server
import ( import (
"bytes"
"encoding/json" "encoding/json"
"io"
"log/slog"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"os"
"strings" "strings"
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/streamer"
) )
// The addresses the forwarding tests use: the client a proxy names in // The addresses the forwarding tests use: the client a proxy names in
// X-Forwarded-For, the range of trusted proxies, a peer inside it, and a peer // X-Forwarded-For, the range of trusted proxies, a peer inside it, and a peer
// outside it, which lies in testPrefixV4 so that an IP lookup of it finds a // outside it, which lies in testPrefixV4 so that an IP lookup of it finds a
// route. // route. reverseProxyPeer is a reverse proxy at a private address, as Docker
// gives one.
const ( const (
forwardedClient = "203.0.113.9" forwardedClient = "203.0.113.9"
trustedProxyRange = "10.0.0.0/8" trustedProxyRange = "10.0.0.0/8"
trustedPeer = "10.0.0.2:1234" trustedPeer = "10.0.0.2:1234"
untrustedPeerAddr = "192.0.2.1" untrustedPeerAddr = "192.0.2.1"
untrustedPeer = untrustedPeerAddr + ":1234" untrustedPeer = untrustedPeerAddr + ":1234"
reverseProxyPeer = "172.19.0.14:1234"
) )
// TestResponsesCarrySecurityHeaders checks that a page, a JSON route and a // TestResponsesCarrySecurityHeaders checks that a page, a JSON route and a
@@ -183,6 +196,141 @@ func TestForwardedForFromUntrustedPeerIsIgnored(t *testing.T) {
} }
} }
// TestDefaultTrustedProxiesGiveTheForwardedClient builds the server from the
// configuration with TRUSTED_PROXIES unset. A request from a reverse proxy at
// a private address must be looked up by /ip/, shown on the home page's card
// and logged as the client its X-Forwarded-For names. A direct request from a
// public address with a forged X-Forwarded-For keeps its own address. Before
// the fix no proxy was trusted unless TRUSTED_PROXIES named it, so /ip/ gave
// the proxy's address (https://git.eeqj.de/sneak/routewatch/issues/198).
func TestDefaultTrustedProxiesGiveTheForwardedClient(t *testing.T) {
t.Setenv("PORT", "")
t.Setenv("XDG_DATA_HOME", "")
// t.Setenv puts the variable back when the test ends.
t.Setenv("TRUSTED_PROXIES", "")
err := os.Unsetenv("TRUSTED_PROXIES")
if err != nil {
t.Fatal(err)
}
cfg, err := config.New()
if err != nil {
t.Fatalf("config.New: %v", err)
}
var requestLog bytes.Buffer
s := newServerWithConfig(t, cfg, &requestLog)
tests := []struct {
name string
peer string
want string
}{
{"through a reverse proxy", reverseProxyPeer, forwardedClient},
{"direct from a public address", untrustedPeer, untrustedPeerAddr},
}
for _, tt := range tests {
requestLog.Reset()
get := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
req.RemoteAddr = tt.peer
req.Header.Set("X-Forwarded-For", forwardedClient)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
return rec
}
var answer struct {
Data IPLookupResponse `json:"data"`
}
rec := get("/ip/")
err := json.Unmarshal(rec.Body.Bytes(), &answer)
if err != nil {
t.Fatalf("%s: decoding the answer %q: %v", tt.name, rec.Body, err)
}
if answer.Data.Query != tt.want {
t.Errorf("%s: /ip/ looked up %q, want %q", tt.name, answer.Data.Query, tt.want)
}
page := get("/").Body.String()
if !strings.Contains(page, `data-client-ip="`+tt.want+`"`) {
t.Errorf("%s: the home page's card does not hold %s", tt.name, tt.want)
}
logged := loggedAddresses(requestLog.String())
if len(logged) == 0 {
t.Errorf("%s: the request log names no address", tt.name)
}
for _, addr := range logged {
if addr != tt.want {
t.Errorf("%s: the request log gives %s, want %s", tt.name, addr, tt.want)
}
}
}
}
// newServerWithConfig returns a server built with cfg, on a new database, that
// reads the routes of newTestTable and logs its requests as JSON to
// requestLog.
func newServerWithConfig(
t *testing.T, cfg *config.Config, requestLog io.Writer,
) *Server {
t.Helper()
cfg.StateDir = t.TempDir()
db, err := database.New(cfg, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()),
&logger.Logger{Logger: slog.New(slog.NewJSONHandler(requestLog, nil))}, cfg)
s.resolver = &fakeResolver{}
return s
}
// loggedAddresses returns the address each line of the JSON request log
// names, without the port a direct request is logged with.
func loggedAddresses(requestLog string) []string {
var addrs []string
for line := range strings.Lines(requestLog) {
//nolint:tagliatelle // the field name is the key the request log uses
var entry struct {
RemoteAddr string `json:"remote_addr"`
}
err := json.Unmarshal([]byte(line), &entry)
if err != nil || entry.RemoteAddr == "" {
continue
}
addr, _, err := net.SplitHostPort(entry.RemoteAddr)
if err != nil {
addr = entry.RemoteAddr
}
addrs = append(addrs, addr)
}
return addrs
}
// TestRealIPMiddlewareTrustsOnlyConfiguredProxies checks which address // TestRealIPMiddlewareTrustsOnlyConfiguredProxies checks which address
// RealIPMiddleware gives a request: the TCP peer, unless the peer is a trusted // RealIPMiddleware gives a request: the TCP peer, unless the peer is a trusted
// proxy; then the last X-Forwarded-For entry that is not a trusted proxy, // proxy; then the last X-Forwarded-For entry that is not a trusted proxy,