Skip to content

Commit 5ec1fa1

Browse files
authored
fix: harden header sanitization and handling logic (#2025)
This change improves the robustness of header manipulation in the ext_proc server. It implements strict sanitization for "system-owned" headers (such as Content-Length and internal routing metadata) in both the request and response paths. Previously, these headers were passed through transparently from the input. This change ensures the extension maintains authoritative control over protocol and routing headers, preventing potential ambiguity in downstream processing.
1 parent ceddea7 commit 5ec1fa1

5 files changed

Lines changed: 100 additions & 27 deletions

File tree

pkg/epp/handlers/request.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,10 @@ func (s *StreamingServer) HandleRequestHeaders(reqCtx *RequestContext, req *extP
6060
switch header.Key {
6161
case metadata.FlowFairnessIDKey:
6262
reqCtx.FairnessID = reqCtx.Request.Headers[header.Key]
63-
// remove the fairness ID header from the request headers,
64-
// this is not data that should be manipulated or sent to the backend.
65-
// It is only used for flow control.
66-
delete(reqCtx.Request.Headers, header.Key)
6763
case metadata.ObjectiveKey:
6864
reqCtx.ObjectiveKey = reqCtx.Request.Headers[header.Key]
69-
// remove the objective header from the request headers,
70-
// this is not data that should be manipulated or sent to the backend.
71-
delete(reqCtx.Request.Headers, header.Key)
7265
case metadata.ModelNameRewriteKey:
7366
reqCtx.TargetModelName = reqCtx.Request.Headers[header.Key]
74-
// remove the rewrite header from the request headers,
75-
// this is not data that should be manipulated or sent to the backend.
76-
delete(reqCtx.Request.Headers, header.Key)
7767
}
7868
}
7969

@@ -140,8 +130,11 @@ func (s *StreamingServer) generateHeaders(reqCtx *RequestContext) []*configPb.He
140130
})
141131
}
142132

143-
// include all headers
133+
// Include any non-system-owned headers.
144134
for key, value := range reqCtx.Request.Headers {
135+
if request.IsSystemOwnedHeader(key) {
136+
continue
137+
}
145138
headers = append(headers, &configPb.HeaderValueOption{
146139
Header: &configPb.HeaderValue{
147140
Key: key,

pkg/epp/handlers/request_test.go

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,29 +29,26 @@ func TestHandleRequestHeaders(t *testing.T) {
2929
t.Parallel()
3030

3131
tests := []struct {
32-
name string
33-
headers []*configPb.HeaderValue
34-
wantHeaders map[string]string
35-
wantFairnessID string
36-
wantDeletedKeys []string
32+
name string
33+
headers []*configPb.HeaderValue
34+
wantHeaders map[string]string
35+
wantFairnessID string
3736
}{
3837
{
3938
name: "Extracts Fairness ID and Removes Header",
4039
headers: []*configPb.HeaderValue{
4140
{Key: "x-test", Value: "val"},
4241
{Key: metadata.FlowFairnessIDKey, Value: "user-123"},
4342
},
44-
wantHeaders: map[string]string{"x-test": "val"},
45-
wantFairnessID: "user-123",
46-
wantDeletedKeys: []string{metadata.FlowFairnessIDKey},
43+
wantHeaders: map[string]string{"x-test": "val"},
44+
wantFairnessID: "user-123",
4745
},
4846
{
4947
name: "Prefers RawValue over Value",
5048
headers: []*configPb.HeaderValue{
5149
{Key: metadata.FlowFairnessIDKey, RawValue: []byte("binary-id"), Value: "wrong-id"},
5250
},
53-
wantFairnessID: "binary-id",
54-
wantDeletedKeys: []string{metadata.FlowFairnessIDKey},
51+
wantFairnessID: "binary-id",
5552
},
5653
}
5754

@@ -77,11 +74,34 @@ func TestHandleRequestHeaders(t *testing.T) {
7774
assert.Equal(t, v, reqCtx.Request.Headers[k], "Header %q should match expected value", k)
7875
}
7976
}
80-
81-
for _, key := range tc.wantDeletedKeys {
82-
_, exists := reqCtx.Request.Headers[key]
83-
assert.False(t, exists, "Expected header %q to be removed from map", key)
84-
}
8577
})
8678
}
8779
}
80+
81+
func TestGenerateHeaders_Sanitization(t *testing.T) {
82+
server := &StreamingServer{}
83+
reqCtx := &RequestContext{
84+
TargetEndpoint: "1.2.3.4:8080",
85+
RequestSize: 123,
86+
Request: &Request{
87+
Headers: map[string]string{
88+
"x-user-data": "important", // should passthrough
89+
metadata.ObjectiveKey: "sensitive-objective-id", // should be stripped
90+
metadata.DestinationEndpointKey: "1.1.1.1:666", // should be stripped
91+
"content-length": "99999", // should be stripped (re-added by logic)
92+
},
93+
},
94+
}
95+
96+
results := server.generateHeaders(reqCtx)
97+
98+
gotHeaders := make(map[string]string)
99+
for _, h := range results {
100+
gotHeaders[h.Header.Key] = string(h.Header.RawValue)
101+
}
102+
103+
assert.Contains(t, gotHeaders, "x-user-data")
104+
assert.NotContains(t, gotHeaders, metadata.ObjectiveKey)
105+
assert.Equal(t, "1.2.3.4:8080", gotHeaders[metadata.DestinationEndpointKey])
106+
assert.Equal(t, "123", gotHeaders["Content-Length"])
107+
}

pkg/epp/handlers/response.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,11 @@ func (s *StreamingServer) generateResponseHeaders(reqCtx *RequestContext) []*con
155155
},
156156
}
157157

158-
// include all headers
158+
// Include any non-system-owned headers.
159159
for key, value := range reqCtx.Response.Headers {
160+
if request.IsSystemOwnedHeader(key) {
161+
continue
162+
}
160163
headers = append(headers, &configPb.HeaderValueOption{
161164
Header: &configPb.HeaderValue{
162165
Key: key,

pkg/epp/handlers/response_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/stretchr/testify/assert"
2626

2727
"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend"
28+
"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metadata"
2829
logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging"
2930
)
3031

@@ -298,3 +299,30 @@ func TestHandleResponseBodyModelStreaming_TokenAccumulation(t *testing.T) {
298299
})
299300
}
300301
}
302+
303+
func TestGenerateResponseHeaders_Sanitization(t *testing.T) {
304+
server := &StreamingServer{}
305+
reqCtx := &RequestContext{
306+
Response: &Response{
307+
Headers: map[string]string{
308+
"x-backend-server": "vllm-v0.6.3", // should passthrough
309+
metadata.ObjectiveKey: "sensitive-objective-id", // should be stripped
310+
metadata.DestinationEndpointKey: "10.2.0.5:8080", // should be stripped
311+
"content-length": "500", // hould be stripped
312+
},
313+
},
314+
}
315+
316+
results := server.generateResponseHeaders(reqCtx)
317+
318+
gotHeaders := make(map[string]string)
319+
for _, h := range results {
320+
gotHeaders[h.Header.Key] = string(h.Header.RawValue)
321+
}
322+
323+
assert.Contains(t, gotHeaders, "x-backend-server")
324+
assert.Contains(t, gotHeaders, "x-went-into-resp-headers")
325+
assert.NotContains(t, gotHeaders, metadata.ObjectiveKey)
326+
assert.NotContains(t, gotHeaders, metadata.DestinationEndpointKey)
327+
assert.NotContains(t, gotHeaders, "content-length")
328+
}

pkg/epp/util/request/headers.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,41 @@ import (
2121

2222
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
2323
extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
24+
"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metadata"
2425
)
2526

2627
const (
2728
RequestIdHeaderKey = "x-request-id"
2829
)
2930

31+
var (
32+
// InputControlHeaders are sent by the Gateway/User to control EPP behavior.
33+
// We must extract these, then strip them so they don't leak to the backend.
34+
InputControlHeaders = map[string]bool{
35+
strings.ToLower(metadata.FlowFairnessIDKey): true,
36+
strings.ToLower(metadata.ObjectiveKey): true,
37+
strings.ToLower(metadata.ModelNameRewriteKey): true,
38+
strings.ToLower(metadata.SubsetFilterKey): true,
39+
}
40+
41+
// OutputInjectionHeaders are headers EPP injects for the backend.
42+
// If the user sends these, they must be stripped to prevent ambiguity.
43+
OutputInjectionHeaders = map[string]bool{
44+
strings.ToLower(metadata.DestinationEndpointKey): true,
45+
strings.ToLower(metadata.DestinationEndpointServedKey): true,
46+
}
47+
48+
// ProtocolHeaders are managed by the proxy layer (Envoy/EPP).
49+
ProtocolHeaders = map[string]bool{
50+
"content-length": true,
51+
}
52+
)
53+
54+
func IsSystemOwnedHeader(key string) bool {
55+
k := strings.ToLower(key)
56+
return InputControlHeaders[k] || OutputInjectionHeaders[k] || ProtocolHeaders[k]
57+
}
58+
3059
// GetHeaderValue safely extracts the string value from an Envoy HeaderValue field.
3160
func GetHeaderValue(header *corev3.HeaderValue) string {
3261
if len(header.RawValue) > 0 {

0 commit comments

Comments
 (0)