diff --git a/internal/hub/api.go b/internal/hub/api.go index 599c4eff..a97819cb 100644 --- a/internal/hub/api.go +++ b/internal/hub/api.go @@ -2,7 +2,11 @@ package hub import ( "context" + "fmt" + "log/slog" + "net" "net/http" + "net/netip" "regexp" "strings" "time" @@ -78,12 +82,81 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) { } // authenticate with trusted header if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" { + // only honor the header from these peers, if set + trustedProxies, restricted := parseTrustedProxies() se.Router.BindFunc(func(e *core.RequestEvent) error { + if restricted && !isTrustedProxy(trustedProxies, e.Request.RemoteAddr) { + return e.Next() + } return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader)) }) } } +// parseTrustedProxies reads TRUSTED_PROXY_IPS (comma-separated IPs or CIDRs). +// restricted is false when the variable is unset or empty, meaning the trusted +// header is accepted from any peer. Invalid entries are skipped with a warning, +// so a typo narrows the allowlist rather than widening it. +func parseTrustedProxies() (prefixes []netip.Prefix, restricted bool) { + value, _ := utils.GetEnv("TRUSTED_PROXY_IPS") + if value == "" { + return nil, false + } + for entry := range strings.SplitSeq(value, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if prefix, err := parseProxyPrefix(entry); err == nil { + prefixes = append(prefixes, prefix) + } else { + slog.Warn("Ignoring invalid TRUSTED_PROXY_IPS entry", "entry", entry) + } + } + return prefixes, true +} + +// parseProxyPrefix parses an IP or CIDR into a masked prefix. IPv4-mapped IPv6 +// entries are converted to IPv4 so they match IPv4 peers. +func parseProxyPrefix(entry string) (netip.Prefix, error) { + prefix, err := netip.ParsePrefix(entry) + if err != nil { + addr, err := netip.ParseAddr(entry) + if err != nil { + return netip.Prefix{}, err + } + addr = addr.Unmap() + return netip.PrefixFrom(addr, addr.BitLen()), nil + } + if prefix.Addr().Is4In6() { + if prefix.Bits() < 96 { + return netip.Prefix{}, fmt.Errorf("%s covers more than the IPv4-mapped range", entry) + } + prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96) + } + return prefix.Masked(), nil +} + +// isTrustedProxy reports whether the peer address of a request (host:port) is +// within one of the prefixes. +func isTrustedProxy(prefixes []netip.Prefix, remoteAddr string) bool { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + addr, err := netip.ParseAddr(host) + if err != nil { + return false + } + addr = addr.Unmap().WithZone("") + for _, prefix := range prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + // registerApiRoutes registers custom API routes func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { // auth protected routes diff --git a/internal/hub/api_test.go b/internal/hub/api_test.go index a49ed676..6130d380 100644 --- a/internal/hub/api_test.go +++ b/internal/hub/api_test.go @@ -1107,6 +1107,79 @@ func TestTrustedHeaderMiddleware(t *testing.T) { } } +func TestTrustedHeaderProxyAllowlist(t *testing.T) { + var hubs []*beszelTests.TestHub + + defer func() { + for _, hub := range hubs { + hub.Cleanup() + } + }() + + testAppFactory := func(t testing.TB) *pbTests.TestApp { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + hubs = append(hubs, hub) + hub.StartHub() + return hub.TestApp + } + + // httptest requests arrive from 192.0.2.1:1234 + testCases := []struct { + name string + proxies string + expectedStatus int + expectedContent []string + }{ + { + name: "peer inside an allowed range", + proxies: "10.0.0.0/8, 192.0.2.0/24", + expectedStatus: 200, + expectedContent: []string{"\"key\":", "\"v\":"}, + }, + { + name: "peer is the listed address", + proxies: "192.0.2.1", + expectedStatus: 200, + expectedContent: []string{"\"key\":", "\"v\":"}, + }, + { + name: "peer outside the allowlist", + proxies: "10.0.0.0/8", + expectedStatus: 401, + expectedContent: []string{"requires valid"}, + }, + { + name: "allowlist with no valid entry", + proxies: "proxy.internal", + expectedStatus: 401, + expectedContent: []string{"requires valid"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted") + t.Setenv("TRUSTED_PROXY_IPS", tc.proxies) + + scenario := beszelTests.ApiScenario{ + Name: "GET /getkey - with trusted header", + Method: http.MethodGet, + URL: "/api/beszel/getkey", + Headers: map[string]string{ + "X-Beszel-Trusted": "user@test.com", + }, + ExpectedStatus: tc.expectedStatus, + ExpectedContent: tc.expectedContent, + TestAppFactory: testAppFactory, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + beszelTests.CreateUser(app, "user@test.com", "password123") + }, + } + scenario.Test(t) + }) + } +} + func TestUpdateEndpoint(t *testing.T) { t.Setenv("CHECK_UPDATES", "true") diff --git a/internal/hub/trusted_proxy_test.go b/internal/hub/trusted_proxy_test.go new file mode 100644 index 00000000..32140330 --- /dev/null +++ b/internal/hub/trusted_proxy_test.go @@ -0,0 +1,127 @@ +//go:build testing + +package hub + +import ( + "net/netip" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTrustedProxies(t *testing.T) { + testCases := []struct { + name string + value string + prefixes []string + restricted bool + }{ + { + name: "empty", + value: "", + restricted: false, + }, + { + name: "blank", + value: " , ", + prefixes: nil, + restricted: true, + }, + { + name: "single addresses become host prefixes", + value: "10.0.0.5, 2001:db8::1", + prefixes: []string{"10.0.0.5/32", "2001:db8::1/128"}, + restricted: true, + }, + { + name: "cidrs are masked", + value: "172.16.5.9/12,fd00::1/64", + prefixes: []string{"172.16.0.0/12", "fd00::/64"}, + restricted: true, + }, + { + name: "ipv4-mapped entries become ipv4", + value: "::ffff:10.0.0.5, ::ffff:10.0.0.0/104", + prefixes: []string{"10.0.0.5/32", "10.0.0.0/8"}, + restricted: true, + }, + { + name: "invalid entries are skipped, valid ones kept", + value: "proxy.internal, 10.0.0.0/8, 300.1.1.1, ::ffff:0.0.0.0/64", + prefixes: []string{"10.0.0.0/8"}, + restricted: true, + }, + { + name: "only invalid entries trust nobody", + value: "proxy.internal", + prefixes: nil, + restricted: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("TRUSTED_PROXY_IPS", tc.value) + prefixes, restricted := parseTrustedProxies() + assert.Equal(t, tc.restricted, restricted) + var got []string + for _, p := range prefixes { + got = append(got, p.String()) + } + assert.Equal(t, tc.prefixes, got) + }) + } + + t.Run("unset", func(t *testing.T) { + t.Setenv("TRUSTED_PROXY_IPS", "") + os.Unsetenv("TRUSTED_PROXY_IPS") + prefixes, restricted := parseTrustedProxies() + assert.False(t, restricted) + assert.Nil(t, prefixes) + }) + + t.Run("prefixed env var takes precedence", func(t *testing.T) { + t.Setenv("TRUSTED_PROXY_IPS", "10.0.0.0/8") + t.Setenv("BESZEL_HUB_TRUSTED_PROXY_IPS", "192.168.0.0/16") + prefixes, restricted := parseTrustedProxies() + assert.True(t, restricted) + require.Len(t, prefixes, 1) + assert.Equal(t, "192.168.0.0/16", prefixes[0].String()) + }) +} + +func TestIsTrustedProxy(t *testing.T) { + prefixes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("fe80::/10"), + } + + testCases := []struct { + name string + remoteAddr string + trusted bool + }{ + {"ipv4 in prefix", "10.20.30.40:51234", true}, + {"ipv4 outside prefix", "11.0.0.1:51234", false}, + {"ipv6 in prefix", "[2001:db8:1::2]:443", true}, + {"ipv6 outside prefix", "[2001:db9::1]:443", false}, + {"ipv4-mapped ipv6 matches ipv4 prefix", "[::ffff:10.1.2.3]:80", true}, + {"zone is ignored", "[fe80::1%eth0]:80", true}, + {"no port", "10.1.2.3", true}, + {"empty", "", false}, + {"garbage", "not-an-address:80", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.trusted, isTrustedProxy(prefixes, tc.remoteAddr)) + }) + } + + t.Run("empty allowlist trusts nobody", func(t *testing.T) { + assert.False(t, isTrustedProxy(nil, "10.0.0.1:1")) + }) +}