Require trusted-proxy CIDR allow-list for header authentication (#184)

Header auth previously trusted any request that carried the configured
user header, with no check that the request came from a known upstream
proxy. Anyone reaching rdpgw directly could mint an authenticated
session as any user by setting the header.

Add `Header.TrustedProxies` (CIDR list) checked against `RemoteAddr`
before reading the user header. Refuse the request with 401 when the
remote is outside the allow-list. Refuse to start when header
authentication is enabled but `Header.TrustedProxies` is empty.

The CIDR allow-list gates the immediate upstream only; operators must
still configure their proxy to strip duplicate inbound copies of the
user header so a client cannot smuggle one through the trusted hop.
Documented in docs/header-authentication.md.

TestHeaderAuthRequiresTrustedProxy is a 3-case table covering: no
allow-list (refused), outside allow-list (refused), inside allow-list
(allowed). Existing TestHeaderAuthenticated cases updated to declare
trust for httptest.NewRequest's default RemoteAddr (192.0.2.1).
This commit is contained in:
bolkedebruin
2026-04-30 13:47:01 +02:00
committed by GitHub
parent cbb1e5feb3
commit 75ef8ce289
5 changed files with 168 additions and 12 deletions

View File

@@ -74,6 +74,9 @@ type HeaderConfig struct {
UserIdHeader string `koanf:"useridheader"`
EmailHeader string `koanf:"emailheader"`
DisplayNameHeader string `koanf:"displaynameheader"`
// TrustedProxies is the CIDR allow-list of upstream proxies allowed to
// stamp UserHeader (and friends). Empty disables header auth at runtime.
TrustedProxies []string `koanf:"trustedproxies"`
}
type RDGCapsConfig struct {

View File

@@ -247,12 +247,16 @@ func main() {
// header auth (configurable proxy)
if conf.Server.HeaderEnabled() {
log.Printf("enabling header authentication with user header: %s", conf.Header.UserHeader)
if len(conf.Header.TrustedProxies) == 0 {
log.Fatalf("header authentication is enabled but `header.trustedproxies` is empty; refusing to start in an exploitable configuration")
}
log.Printf("enabling header authentication with user header: %s (trusted proxies: %v)", conf.Header.UserHeader, conf.Header.TrustedProxies)
headerConfig := &web.HeaderConfig{
UserHeader: conf.Header.UserHeader,
UserIdHeader: conf.Header.UserIdHeader,
EmailHeader: conf.Header.EmailHeader,
DisplayNameHeader: conf.Header.DisplayNameHeader,
TrustedProxies: conf.Header.TrustedProxies,
}
headerAuth := headerConfig.New()
r.Handle("/connect", headerAuth.Authenticated(http.HandlerFunc(h.HandleDownload)))

View File

@@ -1,6 +1,8 @@
package web
import (
"log"
"net"
"net/http"
"time"
@@ -12,6 +14,7 @@ type Header struct {
userIdHeader string
emailHeader string
displayNameHeader string
trustedProxies []*net.IPNet
}
type HeaderConfig struct {
@@ -19,17 +22,55 @@ type HeaderConfig struct {
UserIdHeader string
EmailHeader string
DisplayNameHeader string
// TrustedProxies is the CIDR allow-list of upstream proxies that may
// stamp the configured user header. The check is applied to the
// immediate RemoteAddr of the request — operators must configure their
// proxy to strip duplicate inbound copies of the user header.
// Empty disables header auth entirely (every request is refused).
TrustedProxies []string
}
func (c *HeaderConfig) New() *Header {
nets := make([]*net.IPNet, 0, len(c.TrustedProxies))
for _, raw := range c.TrustedProxies {
_, n, err := net.ParseCIDR(raw)
if err != nil {
log.Fatalf("header auth: invalid TrustedProxies entry %q: %s", raw, err)
}
nets = append(nets, n)
}
if len(nets) == 0 {
log.Printf("header auth: no TrustedProxies configured; every request will be refused")
}
return &Header{
userHeader: c.UserHeader,
userIdHeader: c.UserIdHeader,
emailHeader: c.EmailHeader,
displayNameHeader: c.DisplayNameHeader,
trustedProxies: nets,
}
}
func (h *Header) remoteIPTrusted(remoteAddr string) bool {
if len(h.trustedProxies) == 0 {
return false
}
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, n := range h.trustedProxies {
if n.Contains(ip) {
return true
}
}
return false
}
// Authenticated middleware that extracts user identity from configurable proxy headers
func (h *Header) Authenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -41,6 +82,15 @@ func (h *Header) Authenticated(next http.Handler) http.Handler {
return
}
// The user header is only meaningful when stamped by a trusted
// upstream. Without that gate any caller on the network can mint
// an authenticated session.
if !h.remoteIPTrusted(r.RemoteAddr) {
log.Printf("header auth: rejecting request from untrusted remote %s", r.RemoteAddr)
http.Error(w, "Untrusted upstream", http.StatusUnauthorized)
return
}
// Extract username from configured user header
userName := r.Header.Get(h.userHeader)
if userName == "" {
@@ -80,4 +130,4 @@ func (h *Header) Authenticated(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
}

View File

@@ -119,6 +119,10 @@ func TestHeaderAuthenticated(t *testing.T) {
w.WriteHeader(http.StatusOK)
})
// httptest.NewRequest sets RemoteAddr to 192.0.2.1:1234, so trust
// the surrounding TEST-NET-1 range for header-auth scenarios.
trusted := []string{"192.0.2.0/24"}
// Determine header config based on test case
var headerConfig *HeaderConfig
switch tc.name {
@@ -128,28 +132,33 @@ func TestHeaderAuthenticated(t *testing.T) {
UserIdHeader: "X-MS-CLIENT-PRINCIPAL-ID",
EmailHeader: "X-MS-CLIENT-PRINCIPAL-EMAIL",
DisplayNameHeader: "",
TrustedProxies: trusted,
}
case "google_iap_headers":
headerConfig = &HeaderConfig{
UserHeader: "X-Goog-Authenticated-User-Email",
UserIdHeader: "X-Goog-Authenticated-User-ID",
EmailHeader: "X-Goog-Authenticated-User-Email",
UserHeader: "X-Goog-Authenticated-User-Email",
UserIdHeader: "X-Goog-Authenticated-User-ID",
EmailHeader: "X-Goog-Authenticated-User-Email",
TrustedProxies: trusted,
}
case "aws_alb_headers":
headerConfig = &HeaderConfig{
UserHeader: "X-Amzn-Oidc-Subject",
EmailHeader: "X-Amzn-Oidc-Email",
DisplayNameHeader: "X-Amzn-Oidc-Name",
TrustedProxies: trusted,
}
case "custom_headers":
headerConfig = &HeaderConfig{
UserHeader: "X-Forwarded-User",
EmailHeader: "X-Forwarded-Email",
DisplayNameHeader: "X-Forwarded-Name",
TrustedProxies: trusted,
}
default:
headerConfig = &HeaderConfig{
UserHeader: "X-Forwarded-User",
UserHeader: "X-Forwarded-User",
TrustedProxies: trusted,
}
}
@@ -273,6 +282,64 @@ func TestHeaderConfig(t *testing.T) {
}
}
// TestHeaderAuthRequiresTrustedProxy asserts that the user header is honored
// only when the request arrives from an operator-declared trusted proxy.
// Without that gate the header is mintable by any caller on the network — an
// authentication bypass.
func TestHeaderAuthRequiresTrustedProxy(t *testing.T) {
cases := []struct {
name string
trusted []string
remoteAddr string
wantStatus int
}{
{
name: "untrusted remote with no allow-list",
trusted: nil,
remoteAddr: "198.51.100.7:5678",
wantStatus: http.StatusUnauthorized,
},
{
name: "untrusted remote outside allow-list",
trusted: []string{"10.0.0.0/8"},
remoteAddr: "198.51.100.7:5678",
wantStatus: http.StatusUnauthorized,
},
{
name: "trusted remote inside allow-list",
trusted: []string{"10.0.0.0/8"},
remoteAddr: "10.1.2.3:5678",
wantStatus: http.StatusOK,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &HeaderConfig{
UserHeader: "X-Forwarded-User",
TrustedProxies: tc.trusted,
}
h := cfg.New()
auth := h.Authenticated(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/test", nil)
req.RemoteAddr = tc.remoteAddr
req.Header.Set("X-Forwarded-User", "administrator")
req = identity.AddToRequestCtx(identity.NewUser(), req)
rr := httptest.NewRecorder()
auth.ServeHTTP(rr, req)
if rr.Code != tc.wantStatus {
t.Fatalf("status: got %d, want %d (RemoteAddr=%q, trusted=%v)",
rr.Code, tc.wantStatus, tc.remoteAddr, tc.trusted)
}
})
}
}
// Test that the authentication flow sets the correct attributes
func TestHeaderAttributesSetting(t *testing.T) {
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -296,8 +363,9 @@ func TestHeaderAttributesSetting(t *testing.T) {
})
headerConfig := &HeaderConfig{
UserHeader: "X-Forwarded-User",
UserIdHeader: "X-Forwarded-User-Id",
UserHeader: "X-Forwarded-User",
UserIdHeader: "X-Forwarded-User-Id",
TrustedProxies: []string{"192.0.2.0/24"},
}
headerAuth := headerConfig.New()
authHandler := headerAuth.Authenticated(testHandler)