Skip to content

Commit 7df7fc2

Browse files
committed
fix(proxy): strip client-supplied header-mapping target headers
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 6f6d916 commit 7df7fc2

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
@@ -88,6 +88,9 @@ func (p *ProxyRouter) handleProxy(c *gin.Context) {
8888
}
8989

9090
c.Request.Header.Del("Authorization")
91+
for _, headerName := range p.headerMapping {
92+
c.Request.Header.Del(headerName)
93+
}
9194
for key, values := range p.proxyHeaders {
9295
for _, value := range values {
9396
c.Request.Header.Add(key, value)

pkg/proxy/proxy_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,121 @@ func TestProxyRouter_HeaderMappingBase(t *testing.T) {
321321
}
322322
}
323323

324+
func TestProxyRouter_HeaderMappingStripsClientHeaders(t *testing.T) {
325+
privateKey, publicKey, err := generateRSAKeyPair()
326+
require.NoError(t, err)
327+
328+
cases := []struct {
329+
name string
330+
headerMapping map[string]string
331+
headerMappingBase string
332+
claims jwt.MapClaims
333+
clientHeaders map[string]string
334+
expectedHeaders map[string]string
335+
missingHeaders []string
336+
}{
337+
{
338+
name: "claim missing strips client header",
339+
headerMapping: map[string]string{"/groups": "X-Forwarded-Groups"},
340+
headerMappingBase: "/userinfo",
341+
claims: jwt.MapClaims{
342+
"sub": "test-user",
343+
"userinfo": map[string]any{"email": "[email protected]"},
344+
"exp": time.Now().Add(time.Hour).Unix(),
345+
"iat": time.Now().Unix(),
346+
},
347+
clientHeaders: map[string]string{"X-Forwarded-Groups": "admin"},
348+
missingHeaders: []string{"X-Forwarded-Groups"},
349+
},
350+
{
351+
name: "claim present overwrites client header",
352+
headerMapping: map[string]string{"/email": "X-Forwarded-Email"},
353+
headerMappingBase: "/userinfo",
354+
claims: jwt.MapClaims{
355+
"sub": "test-user",
356+
"userinfo": map[string]any{"email": "[email protected]"},
357+
"exp": time.Now().Add(time.Hour).Unix(),
358+
"iat": time.Now().Unix(),
359+
},
360+
clientHeaders: map[string]string{"X-Forwarded-Email": "[email protected]"},
361+
expectedHeaders: map[string]string{
362+
"X-Forwarded-Email": "[email protected]",
363+
},
364+
},
365+
{
366+
name: "base path absent strips client header",
367+
headerMapping: map[string]string{"/email": "X-Forwarded-Email"},
368+
headerMappingBase: "/userinfo",
369+
claims: jwt.MapClaims{
370+
"sub": "test-user",
371+
"exp": time.Now().Add(time.Hour).Unix(),
372+
"iat": time.Now().Unix(),
373+
},
374+
clientHeaders: map[string]string{"X-Forwarded-Email": "[email protected]"},
375+
missingHeaders: []string{"X-Forwarded-Email"},
376+
},
377+
{
378+
name: "mixed mappings only set claims that exist",
379+
headerMapping: map[string]string{"/email": "X-Forwarded-Email", "/groups": "X-Forwarded-Groups"},
380+
headerMappingBase: "/",
381+
claims: jwt.MapClaims{
382+
"sub": "test-user",
383+
"email": "[email protected]",
384+
"exp": time.Now().Add(time.Hour).Unix(),
385+
"iat": time.Now().Unix(),
386+
},
387+
clientHeaders: map[string]string{
388+
"X-Forwarded-Email": "[email protected]",
389+
"X-Forwarded-Groups": "admin",
390+
},
391+
expectedHeaders: map[string]string{
392+
"X-Forwarded-Email": "[email protected]",
393+
},
394+
missingHeaders: []string{"X-Forwarded-Groups"},
395+
},
396+
}
397+
398+
for _, tt := range cases {
399+
t.Run(tt.name, func(t *testing.T) {
400+
receivedHeaders := http.Header{}
401+
proxyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
402+
for k, v := range r.Header {
403+
receivedHeaders[k] = v
404+
}
405+
w.WriteHeader(http.StatusOK)
406+
})
407+
408+
proxyRouter, err := NewProxyRouter("https://example.com", proxyHandler, publicKey, http.Header{}, false, tt.headerMapping, tt.headerMappingBase)
409+
require.NoError(t, err)
410+
411+
gin.SetMode(gin.TestMode)
412+
router := gin.New()
413+
proxyRouter.SetupRoutes(router)
414+
415+
token, err := createJWT(privateKey, tt.claims)
416+
require.NoError(t, err)
417+
418+
req, err := http.NewRequest("GET", "/test", nil)
419+
require.NoError(t, err)
420+
req.Header.Set("Authorization", "Bearer "+token)
421+
for k, v := range tt.clientHeaders {
422+
req.Header.Set(k, v)
423+
}
424+
425+
w := httptest.NewRecorder()
426+
router.ServeHTTP(w, req)
427+
428+
assert.Equal(t, http.StatusOK, w.Code)
429+
for header, expected := range tt.expectedHeaders {
430+
assert.Equal(t, expected, receivedHeaders.Get(header), "header %s mismatch", header)
431+
}
432+
for _, header := range tt.missingHeaders {
433+
assert.Empty(t, receivedHeaders.Get(header), "header %s should be stripped", header)
434+
}
435+
})
436+
}
437+
}
438+
324439
func TestProxyRouter_ProtectedResourceTrailingSlash(t *testing.T) {
325440
_, publicKey, err := generateRSAKeyPair()
326441
require.NoError(t, err)

0 commit comments

Comments
 (0)