mirror of
https://github.com/bolkedebruin/rdpgw.git
synced 2026-05-12 19:30:04 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user