mirror of
https://github.com/bolkedebruin/rdpgw.git
synced 2026-05-14 04:10:10 +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)
|
||||
|
||||
Reference in New Issue
Block a user