diff --git a/cmd/rdpgw/config/configuration.go b/cmd/rdpgw/config/configuration.go index d7c461d..0bff08f 100644 --- a/cmd/rdpgw/config/configuration.go +++ b/cmd/rdpgw/config/configuration.go @@ -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 { diff --git a/cmd/rdpgw/main.go b/cmd/rdpgw/main.go index 25384c8..023f794 100644 --- a/cmd/rdpgw/main.go +++ b/cmd/rdpgw/main.go @@ -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))) diff --git a/cmd/rdpgw/web/header.go b/cmd/rdpgw/web/header.go index 38fef20..f230898 100644 --- a/cmd/rdpgw/web/header.go +++ b/cmd/rdpgw/web/header.go @@ -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) }) -} \ No newline at end of file +} diff --git a/cmd/rdpgw/web/header_test.go b/cmd/rdpgw/web/header_test.go index 266edb7..5277520 100644 --- a/cmd/rdpgw/web/header_test.go +++ b/cmd/rdpgw/web/header_test.go @@ -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) diff --git a/docs/header-authentication.md b/docs/header-authentication.md index 2867df2..42f81b3 100644 --- a/docs/header-authentication.md +++ b/docs/header-authentication.md @@ -15,6 +15,12 @@ Header: UserIdHeader: "X-Forwarded-User-Id" # Optional: User ID header EmailHeader: "X-Forwarded-Email" # Optional: Email header DisplayNameHeader: "X-Forwarded-Name" # Optional: Display name header + # Required: CIDR allow-list of upstream proxies that may stamp the + # headers above. Requests arriving from any other RemoteAddr are + # refused with 401, so the user header cannot be minted by callers + # that bypass the proxy. RDPGW refuses to start when this is empty. + TrustedProxies: + - "10.0.0.0/8" Caps: TokenAuth: true @@ -37,6 +43,12 @@ Header: UserHeader: "X-MS-CLIENT-PRINCIPAL-NAME" UserIdHeader: "X-MS-CLIENT-PRINCIPAL-ID" EmailHeader: "X-MS-CLIENT-PRINCIPAL-EMAIL" + TrustedProxies: + # Reach RDPGW only via Azure Private Link / a private VNet peering, then + # list the connector subnet here. Do not expose RDPGW publicly when using + # App Proxy: the App Proxy egress is not a fixed IP range, so a public + # listener cannot be safely gated by CIDR. + - "10.0.0.0/8" Security: VerifyClientIp: false # Required for App Proxy @@ -105,6 +117,9 @@ Header: UserHeader: "X-Goog-Authenticated-User-Email" UserIdHeader: "X-Goog-Authenticated-User-ID" EmailHeader: "X-Goog-Authenticated-User-Email" + TrustedProxies: + - "35.191.0.0/16" # Google IAP / load balancer health checkers + - "130.211.0.0/22" # Google Cloud Load Balancing ``` **Setup**: Enable IAP on your Cloud Load Balancer pointing to RDPGW. Configure OAuth consent screen and authorized users/groups. @@ -116,6 +131,10 @@ Header: UserHeader: "X-Amzn-Oidc-Subject" EmailHeader: "X-Amzn-Oidc-Email" DisplayNameHeader: "X-Amzn-Oidc-Name" + TrustedProxies: + # Place RDPGW in a private subnet whose only ingress is the ALB, then + # list the ALB-facing subnet here. + - "10.0.0.0/16" ``` **Setup**: Configure ALB with Cognito User Pool authentication. Enable OIDC headers forwarding to RDPGW target group. @@ -127,6 +146,8 @@ Header: UserHeader: "X-Forwarded-User" EmailHeader: "X-Forwarded-Email" DisplayNameHeader: "X-Forwarded-Name" + TrustedProxies: + - "172.16.0.0/12" # Docker network or whatever subnet Traefik runs on ``` **Setup**: Use Traefik ForwardAuth middleware with external auth service (e.g., OAuth2 Proxy, Authelia) that sets headers. @@ -137,6 +158,8 @@ Header: Header: UserHeader: "X-Auth-User" EmailHeader: "X-Auth-Email" + TrustedProxies: + - "127.0.0.0/8" # nginx on the same host as RDPGW ``` **nginx config**: @@ -207,15 +230,23 @@ map $http_upgrade $connection_upgrade { ## Security Considerations -- **Trust Boundary**: RDPGW trusts headers set by the proxy. Ensure the proxy cannot be bypassed. -- **Header Validation**: Configure proxy to strip/override user headers from client requests. -- **Network Security**: Deploy RDPGW in private network accessible only via the proxy. +- **TrustedProxies is mandatory**: RDPGW refuses to start when header authentication is enabled but `Header.TrustedProxies` is empty. Any request with a `RemoteAddr` outside the configured CIDRs is rejected with 401 before the user header is read. Without this gate any caller on the network could mint an authenticated session by setting the header. +- **Header Validation**: Even with a CIDR allow-list, configure the proxy to strip duplicate inbound copies of `UserHeader` (and the optional id/email/display-name headers) so a client cannot smuggle one through the trusted proxy. +- **Network Security**: Deploy RDPGW in a private network accessible only via the proxy. The CIDR allow-list is a second line of defense, not a replacement for network segmentation. - **TLS**: Enable TLS between proxy and RDPGW in production environments. ## Validation -Test header authentication: +Test header authentication via your proxy (the request must reach RDPGW from a `TrustedProxies` CIDR): ```bash curl -H "X-Forwarded-User: testuser@domain.com" \ https://your-proxy/connect ``` + +A direct request to RDPGW from outside the trusted-proxy range must return `401 Unauthorized` even when the user header is set: +```bash +curl -H "X-Forwarded-User: testuser@domain.com" \ + https://rdpgw-internal/connect +# HTTP/1.1 401 Unauthorized +# Untrusted upstream +```