diff --git a/demo/go.sum b/demo/go.sum index 0f9aba5f4e..91652cc355 100644 --- a/demo/go.sum +++ b/demo/go.sum @@ -96,8 +96,8 @@ github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec= -github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8= +github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= diff --git a/router-tests/header_propagation_test.go b/router-tests/header_propagation_test.go index fdf9ddcc02..c44000d31c 100644 --- a/router-tests/header_propagation_test.go +++ b/router-tests/header_propagation_test.go @@ -72,6 +72,128 @@ func TestCacheControl(t *testing.T) { assert.Equal(t, `{"errors":[{"message":"Failed to fetch from Subgraph 'employees'.","extensions":{"errors":[{"message":"error resolving RootFieldThrowsError for Employee 1","path":["employee","rootFieldThrowsError"],"extensions":{"code":"ERROR_CODE"}}],"statusCode":200}}],"data":{"employee":{"id":1,"rootFieldThrowsError":null}}}`, res.Body) }) }) + + t.Run("Ignored response headers from subgraphs are never propagated", func(t *testing.T) { + t.Parallel() + + // Test that subgraph response headers in the ignoredHeaders list are never propagated to client, + // even when propagation rules are configured. The router manages these headers itself. + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithHeaderRules(config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Response: []*config.ResponseHeaderRule{ + { + Operation: config.HeaderRuleOperationPropagate, + Named: "Content-Type", + Algorithm: config.ResponseHeaderRuleAlgorithmLastWrite, + }, + { + Operation: config.HeaderRuleOperationPropagate, + Named: "Content-Encoding", + Algorithm: config.ResponseHeaderRuleAlgorithmLastWrite, + }, + { + Operation: config.HeaderRuleOperationPropagate, + Named: "Connection", + Algorithm: config.ResponseHeaderRuleAlgorithmLastWrite, + }, + { + Operation: config.HeaderRuleOperationPropagate, + Named: "X-Custom-Header", + Algorithm: config.ResponseHeaderRuleAlgorithmLastWrite, + }, + }, + }, + }), + }, + Subgraphs: testenv.SubgraphsConfig{ + Employees: testenv.SubgraphConfig{ + Middleware: func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Attempt to set ignored headers - these should NOT be propagated + w.Header().Set("Content-Type", "application/custom-from-subgraph") + w.Header().Set("Content-Encoding", "gzip-from-subgraph") + w.Header().Set("Connection", "keep-alive-from-subgraph") + // This should be propagated + w.Header().Set("X-Custom-Header", "custom-value") + handler.ServeHTTP(w, r) + }) + }, + }, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employee(id: 1) { id } }`, + }) + + // Verify subgraph's ignored headers are NOT propagated to client + contentType := res.Response.Header.Get("Content-Type") + require.NotEqual(t, "application/custom-from-subgraph", contentType, "Subgraph Content-Type should not be propagated") + + contentEncoding := res.Response.Header.Get("Content-Encoding") + require.NotEqual(t, "gzip-from-subgraph", contentEncoding, "Subgraph Content-Encoding should not be propagated") + + connection := res.Response.Header.Get("Connection") + require.NotEqual(t, "keep-alive-from-subgraph", connection, "Subgraph Connection should not be propagated") + + // Verify custom header IS propagated (not in ignored list) + require.Equal(t, "custom-value", res.Response.Header.Get("X-Custom-Header")) + }) + }) + + t.Run("Ignored response headers with regex matching are never propagated from subgraphs", func(t *testing.T) { + t.Parallel() + + // Test that subgraph response headers in the ignoredHeaders list are not propagated + // even with regex matching rules + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithHeaderRules(config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Response: []*config.ResponseHeaderRule{ + { + Operation: config.HeaderRuleOperationPropagate, + Matching: "^Content-.*", // Should match Content-Type, Content-Encoding, Content-Length + Algorithm: config.ResponseHeaderRuleAlgorithmLastWrite, + }, + { + Operation: config.HeaderRuleOperationPropagate, + Matching: ".*", // Match all headers + Algorithm: config.ResponseHeaderRuleAlgorithmLastWrite, + }, + }, + }, + }), + }, + Subgraphs: testenv.SubgraphsConfig{ + Employees: testenv.SubgraphConfig{ + Middleware: func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/custom-from-subgraph") + w.Header().Set("Content-Encoding", "gzip-from-subgraph") + w.Header().Set("X-Custom-Header", "should-be-propagated") + handler.ServeHTTP(w, r) + }) + }, + }, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employee(id: 1) { id } }`, + }) + + // Content-* headers from subgraph should NOT be propagated to client (router manages these) + contentType := res.Response.Header.Get("Content-Type") + require.NotEqual(t, "application/custom-from-subgraph", contentType, "Subgraph Content-Type should not be propagated") + + contentEncoding := res.Response.Header.Get("Content-Encoding") + require.NotEqual(t, "gzip-from-subgraph", contentEncoding, "Subgraph Content-Encoding should not be propagated") + + // X-Custom-Header SHOULD be propagated (not in ignored list) + require.Equal(t, "should-be-propagated", res.Response.Header.Get("X-Custom-Header")) + }) + }) } func TestHeaderPropagation(t *testing.T) { diff --git a/router-tests/headers_test.go b/router-tests/headers_test.go index a25d6ccf2e..939644f8e5 100644 --- a/router-tests/headers_test.go +++ b/router-tests/headers_test.go @@ -405,6 +405,137 @@ func TestForwardHeaders(t *testing.T) { } }) }) + + t.Run("Ignored headers are never forwarded from client", func(t *testing.T) { + t.Parallel() + + // Test that client headers in the ignoredHeaders list are never forwarded to subgraphs, + // even when propagation rules are configured. The router manages these headers itself. + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithHeaderRules(config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Request: []*config.RequestHeaderRule{ + { + Operation: config.HeaderRuleOperationPropagate, + Named: "Content-Type", + }, + { + Operation: config.HeaderRuleOperationPropagate, + Named: "Content-Encoding", + }, + { + Operation: config.HeaderRuleOperationPropagate, + Named: "Connection", + }, + { + Operation: config.HeaderRuleOperationPropagate, + Named: "Transfer-Encoding", + }, + { + Operation: config.HeaderRuleOperationPropagate, + Named: "X-Custom-Header", + }, + }, + }, + }), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Header: http.Header{ + "Content-Type": []string{"application/custom-from-client"}, + "Content-Encoding": []string{"gzip-from-client"}, + "Connection": []string{"keep-alive-from-client"}, + "Transfer-Encoding": []string{"chunked-from-client"}, + "X-Custom-Header": []string{"should-be-forwarded"}, + }, + Query: `query { + contentType: headerValue(name:"Content-Type"), + contentEncoding: headerValue(name:"Content-Encoding"), + connection: headerValue(name:"Connection"), + transferEncoding: headerValue(name:"Transfer-Encoding"), + customHeader: headerValue(name:"X-Custom-Header") + }`, + }) + + // Parse the response to check individual values + var result struct { + Data struct { + ContentType string `json:"contentType"` + ContentEncoding string `json:"contentEncoding"` + Connection string `json:"connection"` + TransferEncoding string `json:"transferEncoding"` + CustomHeader string `json:"customHeader"` + } `json:"data"` + } + err := json.Unmarshal([]byte(res.Body), &result) + require.NoError(t, err) + + // Client's ignored headers should NOT be forwarded (empty or router-managed values) + require.NotEqual(t, "application/custom-from-client", result.Data.ContentType, "Client Content-Type should not be forwarded") + require.NotEqual(t, "gzip-from-client", result.Data.ContentEncoding, "Client Content-Encoding should not be forwarded") + require.NotEqual(t, "keep-alive-from-client", result.Data.Connection, "Client Connection should not be forwarded") + require.NotEqual(t, "chunked-from-client", result.Data.TransferEncoding, "Client Transfer-Encoding should not be forwarded") + + // Non-ignored headers SHOULD be forwarded + require.Equal(t, "should-be-forwarded", result.Data.CustomHeader, "Custom header should be forwarded") + }) + }) + + t.Run("Ignored headers with regex matching are never forwarded from client", func(t *testing.T) { + t.Parallel() + + // Test that client headers in the ignoredHeaders list are never forwarded even with regex matching + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithHeaderRules(config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Request: []*config.RequestHeaderRule{ + { + Operation: config.HeaderRuleOperationPropagate, + Matching: "^Content-.*", // Should match Content-Type, Content-Encoding, Content-Length + }, + { + Operation: config.HeaderRuleOperationPropagate, + Matching: ".*", // Match all headers + }, + }, + }, + }), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Header: http.Header{ + "Content-Type": []string{"application/custom-from-client"}, + "Content-Encoding": []string{"gzip-from-client"}, + "X-Custom-Header": []string{"should-be-forwarded"}, + }, + Query: `query { + contentType: headerValue(name:"Content-Type"), + contentEncoding: headerValue(name:"Content-Encoding"), + customHeader: headerValue(name:"X-Custom-Header") + }`, + }) + + // Parse the response + var result struct { + Data struct { + ContentType string `json:"contentType"` + ContentEncoding string `json:"contentEncoding"` + CustomHeader string `json:"customHeader"` + } `json:"data"` + } + err := json.Unmarshal([]byte(res.Body), &result) + require.NoError(t, err) + + // Client's Content-* headers should NOT be forwarded (router manages these) + require.NotEqual(t, "application/custom-from-client", result.Data.ContentType, "Client Content-Type should not be forwarded") + require.NotEqual(t, "gzip-from-client", result.Data.ContentEncoding, "Client Content-Encoding should not be forwarded") + + // X-Custom-Header SHOULD be forwarded (not in ignored list) + require.Equal(t, "should-be-forwarded", result.Data.CustomHeader, "Custom header should be forwarded") + }) + }) } func TestForwardRenamedHeaders(t *testing.T) { diff --git a/router/core/header_rule_engine.go b/router/core/header_rule_engine.go index 509fa2695f..51af54b39e 100644 --- a/router/core/header_rule_engine.go +++ b/router/core/header_rule_engine.go @@ -3,7 +3,6 @@ package core import ( "context" "fmt" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" "io" "net/http" "reflect" @@ -13,6 +12,8 @@ import ( "sync" "time" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" + "github.com/expr-lang/expr/vm" cachedirective "github.com/pquerna/cachecontrol/cacheobject" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" @@ -45,6 +46,8 @@ var ( // Content Negotiation. We must never propagate the client headers to the upstream // The router has to decide on its own what to send to the upstream "Content-Type", + "Content-Encoding", + "Content-Length", "Accept-Encoding", "Accept-Charset", "Accept",