Skip to content

Commit 3d6e35c

Browse files
authored
fix(proxy): strip client-supplied header-mapping target headers (#148)
Client-supplied values for header-mapping target headers were forwarded to the backend when the corresponding JWT claim was missing, allowing clients to spoof identity headers the backend may trust. Always strip header-mapping target headers from the incoming request before injecting claim-derived values.
1 parent d043de5 commit 3d6e35c

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

pkg/proxy/proxy.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ func (p *ProxyRouter) handleProxy(c *gin.Context) {
9393
if !p.forwardAuthorizationHeader {
9494
c.Request.Header.Del("Authorization")
9595
}
96+
for _, headerName := range p.headerMapping {
97+
c.Request.Header.Del(headerName)
98+
}
9699
for key, values := range p.proxyHeaders {
97100
if strings.EqualFold(key, "Authorization") {
98101
c.Request.Header.Del("Authorization")

pkg/proxy/proxy_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,121 @@ func TestProxyRouter_AuthorizationHeaderDefaultBehavior(t *testing.T) {
390390
})
391391
}
392392

393+
func TestProxyRouter_HeaderMappingStripsClientHeaders(t *testing.T) {
394+
privateKey, publicKey, err := generateRSAKeyPair()
395+
require.NoError(t, err)
396+
397+
cases := []struct {
398+
name string
399+
headerMapping map[string]string
400+
headerMappingBase string
401+
claims jwt.MapClaims
402+
clientHeaders map[string]string
403+
expectedHeaders map[string]string
404+
missingHeaders []string
405+
}{
406+
{
407+
name: "claim missing strips client header",
408+
headerMapping: map[string]string{"/groups": "X-Forwarded-Groups"},
409+
headerMappingBase: "/userinfo",
410+
claims: jwt.MapClaims{
411+
"sub": "test-user",
412+
"userinfo": map[string]any{"email": "[email protected]"},
413+
"exp": time.Now().Add(time.Hour).Unix(),
414+
"iat": time.Now().Unix(),
415+
},
416+
clientHeaders: map[string]string{"X-Forwarded-Groups": "admin"},
417+
missingHeaders: []string{"X-Forwarded-Groups"},
418+
},
419+
{
420+
name: "claim present overwrites client header",
421+
headerMapping: map[string]string{"/email": "X-Forwarded-Email"},
422+
headerMappingBase: "/userinfo",
423+
claims: jwt.MapClaims{
424+
"sub": "test-user",
425+
"userinfo": map[string]any{"email": "[email protected]"},
426+
"exp": time.Now().Add(time.Hour).Unix(),
427+
"iat": time.Now().Unix(),
428+
},
429+
clientHeaders: map[string]string{"X-Forwarded-Email": "[email protected]"},
430+
expectedHeaders: map[string]string{
431+
"X-Forwarded-Email": "[email protected]",
432+
},
433+
},
434+
{
435+
name: "base path absent strips client header",
436+
headerMapping: map[string]string{"/email": "X-Forwarded-Email"},
437+
headerMappingBase: "/userinfo",
438+
claims: jwt.MapClaims{
439+
"sub": "test-user",
440+
"exp": time.Now().Add(time.Hour).Unix(),
441+
"iat": time.Now().Unix(),
442+
},
443+
clientHeaders: map[string]string{"X-Forwarded-Email": "[email protected]"},
444+
missingHeaders: []string{"X-Forwarded-Email"},
445+
},
446+
{
447+
name: "mixed mappings only set claims that exist",
448+
headerMapping: map[string]string{"/email": "X-Forwarded-Email", "/groups": "X-Forwarded-Groups"},
449+
headerMappingBase: "/",
450+
claims: jwt.MapClaims{
451+
"sub": "test-user",
452+
"email": "[email protected]",
453+
"exp": time.Now().Add(time.Hour).Unix(),
454+
"iat": time.Now().Unix(),
455+
},
456+
clientHeaders: map[string]string{
457+
"X-Forwarded-Email": "[email protected]",
458+
"X-Forwarded-Groups": "admin",
459+
},
460+
expectedHeaders: map[string]string{
461+
"X-Forwarded-Email": "[email protected]",
462+
},
463+
missingHeaders: []string{"X-Forwarded-Groups"},
464+
},
465+
}
466+
467+
for _, tt := range cases {
468+
t.Run(tt.name, func(t *testing.T) {
469+
receivedHeaders := http.Header{}
470+
proxyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
471+
for k, v := range r.Header {
472+
receivedHeaders[k] = v
473+
}
474+
w.WriteHeader(http.StatusOK)
475+
})
476+
477+
proxyRouter, err := NewProxyRouter("https://example.com", proxyHandler, publicKey, http.Header{}, false, false, tt.headerMapping, tt.headerMappingBase)
478+
require.NoError(t, err)
479+
480+
gin.SetMode(gin.TestMode)
481+
router := gin.New()
482+
proxyRouter.SetupRoutes(router)
483+
484+
token, err := createJWT(privateKey, tt.claims)
485+
require.NoError(t, err)
486+
487+
req, err := http.NewRequest("GET", "/test", nil)
488+
require.NoError(t, err)
489+
req.Header.Set("Authorization", "Bearer "+token)
490+
for k, v := range tt.clientHeaders {
491+
req.Header.Set(k, v)
492+
}
493+
494+
w := httptest.NewRecorder()
495+
router.ServeHTTP(w, req)
496+
497+
assert.Equal(t, http.StatusOK, w.Code)
498+
for header, expected := range tt.expectedHeaders {
499+
assert.Equal(t, expected, receivedHeaders.Get(header), "header %s mismatch", header)
500+
}
501+
for _, header := range tt.missingHeaders {
502+
assert.Empty(t, receivedHeaders.Get(header), "header %s should be stripped", header)
503+
}
504+
})
505+
}
506+
}
507+
393508
func TestProxyRouter_ProtectedResourceTrailingSlash(t *testing.T) {
394509
_, publicKey, err := generateRSAKeyPair()
395510
require.NoError(t, err)

0 commit comments

Comments
 (0)