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)

View File

@@ -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
```