mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 00:49:57 +00:00
Warn at startup when limits are on without trusted_proxies, and once when a request carries forwarding headers Beacon ignores. Reject short or whitespace admin keys and negative durations. Key default-window stats by hour, return 500 for scope lookup failures, and validate IATA path params.
111 lines
4.5 KiB
Go
111 lines
4.5 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestTrustedProxyIP(t *testing.T) {
|
|
trusted := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("2001:db8:1::/48")}
|
|
for _, tc := range []struct {
|
|
name, peer string
|
|
proxies []netip.Prefix
|
|
realIP []string
|
|
want string
|
|
}{
|
|
{"empty allowlist", "192.0.2.10:1234", nil, []string{"198.51.100.1"}, "192.0.2.10:1234"},
|
|
{"untrusted peer", "192.0.3.10:1234", trusted, []string{"198.51.100.1"}, "192.0.3.10:1234"},
|
|
{"trusted IPv4", "192.0.2.10:1234", trusted, []string{"198.51.100.1"}, "198.51.100.1"},
|
|
{"trusted IPv6", "[2001:db8:1::10]:1234", trusted, []string{"2001:db8:2::1"}, "2001:db8:2::1"},
|
|
{"mapped peer", "[::ffff:192.0.2.10]:1234", trusted, []string{"198.51.100.1"}, "198.51.100.1"},
|
|
{"mapped client", "192.0.2.10:1234", trusted, []string{"::ffff:198.51.100.1"}, "198.51.100.1"},
|
|
{"missing header", "192.0.2.10:1234", trusted, nil, "192.0.2.10:1234"},
|
|
{"empty header", "192.0.2.10:1234", trusted, []string{""}, "192.0.2.10:1234"},
|
|
{"invalid IP", "192.0.2.10:1234", trusted, []string{"not-an-ip"}, "192.0.2.10:1234"},
|
|
{"IP with port", "192.0.2.10:1234", trusted, []string{"198.51.100.1:8080"}, "192.0.2.10:1234"},
|
|
{"IP with zone", "192.0.2.10:1234", trusted, []string{"fe80::1%eth0"}, "192.0.2.10:1234"},
|
|
{"comma list", "192.0.2.10:1234", trusted, []string{"198.51.100.1, 198.51.100.2"}, "192.0.2.10:1234"},
|
|
{"repeated header", "192.0.2.10:1234", trusted, []string{"198.51.100.1", "198.51.100.2"}, "192.0.2.10:1234"},
|
|
{"invalid peer", "unknown", trusted, []string{"198.51.100.1"}, "unknown"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.RemoteAddr = tc.peer
|
|
for _, ip := range tc.realIP {
|
|
r.Header.Add("X-Real-IP", ip)
|
|
}
|
|
// These must never override the selected address, even for trusted peers.
|
|
r.Header.Set("True-Client-IP", "203.0.113.1")
|
|
r.Header.Set("X-Forwarded-For", "203.0.113.2")
|
|
r.Header.Set("X-Request-ID", "keep-me")
|
|
TrustedProxyIP(tc.proxies)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.RemoteAddr != tc.want {
|
|
t.Errorf("RemoteAddr = %q, want %q", r.RemoteAddr, tc.want)
|
|
}
|
|
for _, header := range []string{"True-Client-IP", "X-Forwarded-For", "X-Real-IP"} {
|
|
if len(r.Header.Values(header)) != 0 {
|
|
t.Errorf("downstream middleware can still read %s", header)
|
|
}
|
|
}
|
|
if r.Header.Get("X-Request-ID") != "keep-me" {
|
|
t.Error("unrelated header was changed")
|
|
}
|
|
})).ServeHTTP(httptest.NewRecorder(), r)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTrustedProxyIPWarnsOnce(t *testing.T) {
|
|
trusted := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}
|
|
for _, tc := range []struct {
|
|
name, peer, header string
|
|
proxies []netip.Prefix
|
|
want string // in the single warning; "" means none
|
|
}{
|
|
{"untrusted XFF", "198.51.100.7:1234", "X-Forwarded-For", nil, "198.51.100.7"},
|
|
{"untrusted X-Real-IP", "198.51.100.7:1234", "X-Real-IP", trusted, "198.51.100.7"},
|
|
{"untrusted True-Client-IP", "198.51.100.7:1234", "True-Client-IP", nil, "198.51.100.7"},
|
|
{"trusted XFF only", "192.0.2.10:1234", "X-Forwarded-For", trusted, "X-Real-IP"},
|
|
{"trusted with X-Real-IP", "192.0.2.10:1234", "X-Real-IP", trusted, ""},
|
|
{"direct client", "198.51.100.7:1234", "", nil, ""},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var out bytes.Buffer
|
|
previous := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&out, nil)))
|
|
t.Cleanup(func() { slog.SetDefault(previous) })
|
|
handler := TrustedProxyIP(tc.proxies)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
for range 3 {
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.RemoteAddr = tc.peer
|
|
if tc.header != "" {
|
|
r.Header.Set(tc.header, "203.0.113.9")
|
|
}
|
|
handler.ServeHTTP(httptest.NewRecorder(), r)
|
|
}
|
|
logs := out.String()
|
|
warnings := strings.Count(logs, `"level":"WARN"`)
|
|
if tc.want == "" {
|
|
if warnings != 0 {
|
|
t.Fatalf("unexpected warning: %s", logs)
|
|
}
|
|
return
|
|
}
|
|
if warnings != 1 || !strings.Contains(logs, tc.want) || !strings.Contains(logs, "trusted_proxies") {
|
|
t.Fatalf("want one warning mentioning %q and trusted_proxies, got: %s", tc.want, logs)
|
|
}
|
|
if strings.Contains(logs, "203.0.113.9") {
|
|
t.Fatal("warning echoes a client-supplied header value")
|
|
}
|
|
})
|
|
}
|
|
}
|