mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-19 06:24:59 +00:00
feat(hub): add TRUSTED_PROXY_IPS allowlist for TRUSTED_AUTH_HEADER (#2327)
With TRUSTED_AUTH_HEADER set, the hub authenticates a request from the header alone, whichever address it comes from. That is right when every request passes through the reverse proxy, and not when the hub can also be reached directly: anyone who can reach it sets the header themselves. TRUSTED_PROXY_IPS takes a comma-separated list of IPs or CIDR ranges. When set, the header is only honored on requests whose peer address is in the list; other requests fall through to the normal authentication. When unset, nothing changes. The check uses the connection's RemoteAddr, not a forwarded header, so the list names the proxy itself. IPv4-mapped IPv6 entries are treated as IPv4. Entries that do not parse are skipped with a warning on the console; a list with no valid entry trusts nobody, so a typo narrows the allowlist instead of widening it.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user