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:
José M. Requena Plens
2026-09-17 19:48:46 +02:00
committed by GitHub
parent 18f7a4bbc0
commit 4bf70700f2
3 changed files with 273 additions and 0 deletions

View File

@@ -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")