diff --git a/router-tests/protocol/router_plugin_test.go b/router-tests/protocol/router_plugin_test.go index d12b98bbb5..a45df74230 100644 --- a/router-tests/protocol/router_plugin_test.go +++ b/router-tests/protocol/router_plugin_test.go @@ -1,19 +1,13 @@ package integration import ( - "context" "fmt" - "net/http" "slices" "strings" "testing" "time" - "github.com/wundergraph/cosmo/router/core" - "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/otel" - "google.golang.org/grpc" - "google.golang.org/grpc/metadata" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -625,333 +619,12 @@ func TestRouterPluginWithHeaderForwarding(t *testing.T) { t.Run("Should send http headers as gRPC metadata to subgraphs", func(t *testing.T) { t.Parallel() - // captureInterceptor records the outgoing metadata of subgraph RPCs only. - // hashicorp/go-plugin issues lifecycle RPCs under /plugin.*; ignoring them - // keeps the capture aligned with the test request and avoids a data race - // between the GraphQL call and the plugin's shutdown RPC. - captureInterceptor := func(captured *metadata.MD) grpc.UnaryClientInterceptor { - return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { - if strings.HasPrefix(method, "/service.") { - md, _ := metadata.FromOutgoingContext(ctx) - *captured = md.Copy() - } - return invoker(ctx, method, req, reply, cc, opts...) - } - } - - t.Run("header arrives as metadata with correct value", func(t *testing.T) { - // Assert that headers included in propagation rules are part - // of gRPC metadata. - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - Plugins: testenv.PluginConfig{ - Enabled: true, - Path: "../../router/plugins", - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - { - Operation: config.HeaderRuleOperationPropagate, - Named: "X-Tenant-Id", - }, - { - Operation: config.HeaderRuleOperationPropagate, - Named: "X-Region-Name", - }, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - "X-Tenant-Id": []string{"acme"}, - "X-Region-Name": []string{"frankfurt"}, - }, - }) - - require.Equal(t, []string{"acme"}, captured.Get("x-tenant-id")) - require.Equal(t, []string{"frankfurt"}, captured.Get("x-region-name")) - }) - }) - - t.Run("header not in propagation rules is absent from metadata", func(t *testing.T) { - // Assert that headers not included in propagation rules are not part - // of gRPC metadata. - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - Plugins: testenv.PluginConfig{ - Enabled: true, - Path: "../../router/plugins", - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - { - Operation: config.HeaderRuleOperationPropagate, - Named: "X-Allowed", - }, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - "X-Allowed": []string{"yes"}, - "X-Not-Allowed": []string{"secret"}, - }, - }) - - require.Equal(t, []string{"yes"}, captured.Get("x-allowed")) - require.Empty(t, captured.Get("x-not-allowed")) - }) - }) - - t.Run("header with multiple values arrives as multiple metadata values", func(t *testing.T) { - // HTTP headers can be set with multiple values. They should appear with all values - // on the gRPC metadata as well. - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - Plugins: testenv.PluginConfig{ - Enabled: true, - Path: "../../router/plugins", - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - { - Operation: config.HeaderRuleOperationPropagate, - Named: "X-Role", - }, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - "X-Role": []string{"admin", "editor"}, - }, - }) - - require.Equal(t, []string{"admin", "editor"}, captured.Get("x-role")) - }) - }) - - t.Run("unsafe headers are absent from metadata", func(t *testing.T) { - // The router avoids passing certain headers to datasources, - // see router/core/header_rule_engine.go. - // This test ensures grpc datasources are covered by this as well. - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - Plugins: testenv.PluginConfig{ - Enabled: true, - Path: "../../router/plugins", - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - { - Operation: config.HeaderRuleOperationPropagate, - Matching: ".*", // mark all headers as forwardable - }, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - // safe header — must arrive - "X-Custom": []string{"value"}, - - // handled by HTTP stack, never in r.Header - "Host": []string{"evil.example.com"}, - - // hop-by-hop / connection headers - "Alt-Svc": []string{"h3=\":443\""}, - "Connection": []string{"keep-alive"}, - "Keep-Alive": []string{"timeout=5"}, - "Proxy-Authenticate": []string{"Basic"}, - "Proxy-Authorization": []string{"Basic dXNlcjpwYXNz"}, - "Proxy-Connection": []string{"keep-alive"}, - "Te": []string{"trailers"}, - "Trailer": []string{"Expires"}, - "Transfer-Encoding": []string{"chunked"}, - "Upgrade": []string{"websocket"}, - - // content negotiation - "Accept": []string{"application/json"}, - "Accept-Charset": []string{"utf-8"}, - "Accept-Encoding": []string{"gzip, deflate"}, - "Content-Encoding": []string{"gzip"}, - "Content-Length": []string{"42"}, - "Content-Type": []string{"application/json"}, - - // WebSocket upgrade - "Sec-Websocket-Extensions": []string{"permessage-deflate"}, - "Sec-Websocket-Key": []string{"dGhlIHNhbXBsZSBub25jZQ=="}, - "Sec-Websocket-Protocol": []string{"chat"}, - "Sec-Websocket-Version": []string{"13"}, - }, - }) - - // We only assert what the router writes to outgoing metadata before - // the gRPC transport runs. content-type, user-agent and HTTP/2 - // pseudo-headers (:authority, :method, :path, :scheme) are added - // later by the transport when framing HTTP/2 and are not visible - // to a client interceptor; verifying them would require a server - // interceptor inside the plugin binary. - - // custom header should arrive - require.Equal(t, []string{"value"}, captured.Get("x-custom")) - - // host is handled by the HTTP stack and never forwarded - require.Empty(t, captured.Get("host")) - - // hop-by-hop / connection headers - require.Empty(t, captured.Get("alt-svc")) - require.Empty(t, captured.Get("connection")) - require.Empty(t, captured.Get("keep-alive")) - require.Empty(t, captured.Get("proxy-authenticate")) - require.Empty(t, captured.Get("proxy-authorization")) - require.Empty(t, captured.Get("proxy-connection")) - require.Empty(t, captured.Get("te")) - require.Empty(t, captured.Get("trailer")) - require.Empty(t, captured.Get("transfer-encoding")) - require.Empty(t, captured.Get("upgrade")) - - // content negotiation - require.Empty(t, captured.Get("accept")) - require.Empty(t, captured.Get("accept-charset")) - require.Empty(t, captured.Get("accept-encoding")) - require.Empty(t, captured.Get("content-encoding")) - require.Empty(t, captured.Get("content-length")) - - // WebSocket upgrade - require.Empty(t, captured.Get("sec-websocket-extensions")) - require.Empty(t, captured.Get("sec-websocket-key")) - require.Empty(t, captured.Get("sec-websocket-protocol")) - require.Empty(t, captured.Get("sec-websocket-version")) - }) - }) - - t.Run("grpc-reserved headers never reach the subgraph", func(t *testing.T) { - // Headers prefixed with "grpc-" are reserved by the gRPC protocol spec. - // Even when wildcard propagation is configured, they must never appear - // on the subgraph. - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - Plugins: testenv.PluginConfig{ - Enabled: true, - Path: "../../router/plugins", - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - { - Operation: config.HeaderRuleOperationPropagate, - Matching: ".*", - }, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - "Grpc-ReservedHeader": []string{"should be ignored"}, - }, - }) - - require.Empty(t, captured.Get("grpc-reservedheader")) - }) - }) - - t.Run("safe headers are present in metadata", func(t *testing.T) { - // Ensure that http standard headers, which are safe and useful for subgraphs, - // are included in the metadata unmodified. - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - Plugins: testenv.PluginConfig{ - Enabled: true, - Path: "../../router/plugins", - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - // Propagate each header explicitly so the test is - // independent of any default propagation behaviour. - {Operation: config.HeaderRuleOperationPropagate, Named: "Authorization"}, - {Operation: config.HeaderRuleOperationPropagate, Named: "Cookie"}, - {Operation: config.HeaderRuleOperationPropagate, Named: "Traceparent"}, - {Operation: config.HeaderRuleOperationPropagate, Named: "Tracestate"}, - {Operation: config.HeaderRuleOperationPropagate, Named: "Accept-Language"}, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - "Authorization": []string{"Bearer eyJhbGciOiJSUzI1NiJ9"}, - "Cookie": []string{"session=abc123; theme=dark"}, - "Traceparent": []string{"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}, - "Tracestate": []string{"rojo=00f067aa0ba902b7"}, - "Accept-Language": []string{"de-DE,de;q=0.9,en;q=0.8"}, - }, - }) - - require.Equal(t, []string{"Bearer eyJhbGciOiJSUzI1NiJ9"}, captured.Get("authorization")) - require.Equal(t, []string{"session=abc123; theme=dark"}, captured.Get("cookie")) - require.Equal(t, []string{"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}, captured.Get("traceparent")) - require.Equal(t, []string{"rojo=00f067aa0ba902b7"}, captured.Get("tracestate")) - require.Equal(t, []string{"de-DE,de;q=0.9,en;q=0.8"}, captured.Get("accept-language")) - }) + testenv.RunGRPCPluginHeaderCases(t, testenv.Config{ + RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, + Plugins: testenv.PluginConfig{ + Enabled: true, + Path: "../../router/plugins", + }, }) }) } diff --git a/router-tests/router_oci_plugin_test.go b/router-tests/router_oci_plugin_test.go index 5085688f6a..f521a9f574 100644 --- a/router-tests/router_oci_plugin_test.go +++ b/router-tests/router_oci_plugin_test.go @@ -1,9 +1,7 @@ package integration import ( - "context" "fmt" - "net/http" "runtime" "slices" "strings" @@ -14,13 +12,9 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest/observer" - "google.golang.org/grpc" - "google.golang.org/grpc/metadata" "github.com/wundergraph/cosmo/router-tests/testenv" - "github.com/wundergraph/cosmo/router/core" nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" - "github.com/wundergraph/cosmo/router/pkg/config" ) func TestOCIPlugin_PullAndRun(t *testing.T) { @@ -128,295 +122,13 @@ func TestOCIPluginWithHeaderForwarding(t *testing.T) { buildAndPushPluginImage(t, registryHost, "test-org/projects", "v1", projectsBinary) buildAndPushPluginImage(t, registryHost, "test-org/courses", "v1", coursesBinary) - // captureInterceptor records the outgoing metadata of subgraph RPCs only. - // hashicorp/go-plugin issues lifecycle RPCs under /plugin.*; ignoring them - // keeps the capture aligned with the test request and avoids a data race - // between the GraphQL call and the plugin's shutdown RPC. - captureInterceptor := func(captured *metadata.MD) grpc.UnaryClientInterceptor { - return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { - if strings.HasPrefix(method, "/service.") { - md, _ := metadata.FromOutgoingContext(ctx) - *captured = md.Copy() - } - return invoker(ctx, method, req, reply, cc, opts...) - } - } - - t.Run("header arrives as metadata with correct value", func(t *testing.T) { - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - ModifyRouterConfig: addOCIImageReferences, - Plugins: testenv.PluginConfig{ - Enabled: true, - RegistryURL: registryHost, - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - {Operation: config.HeaderRuleOperationPropagate, Named: "X-Tenant-Id"}, - {Operation: config.HeaderRuleOperationPropagate, Named: "X-Region-Name"}, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - "X-Tenant-Id": []string{"acme"}, - "X-Region-Name": []string{"frankfurt"}, - }, - }) - - require.Equal(t, []string{"acme"}, captured.Get("x-tenant-id")) - require.Equal(t, []string{"frankfurt"}, captured.Get("x-region-name")) - }) - }) - - t.Run("header not in propagation rules is absent from metadata", func(t *testing.T) { - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - ModifyRouterConfig: addOCIImageReferences, - Plugins: testenv.PluginConfig{ - Enabled: true, - RegistryURL: registryHost, - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - {Operation: config.HeaderRuleOperationPropagate, Named: "X-Allowed"}, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - "X-Allowed": []string{"yes"}, - "X-Not-Allowed": []string{"secret"}, - }, - }) - - require.Equal(t, []string{"yes"}, captured.Get("x-allowed")) - require.Empty(t, captured.Get("x-not-allowed")) - }) - }) - - t.Run("header with multiple values arrives as multiple metadata values", func(t *testing.T) { - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - ModifyRouterConfig: addOCIImageReferences, - Plugins: testenv.PluginConfig{ - Enabled: true, - RegistryURL: registryHost, - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - {Operation: config.HeaderRuleOperationPropagate, Named: "X-Role"}, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - "X-Role": []string{"admin", "editor"}, - }, - }) - - require.Equal(t, []string{"admin", "editor"}, captured.Get("x-role")) - }) - }) - - t.Run("unsafe headers are absent from metadata", func(t *testing.T) { - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - ModifyRouterConfig: addOCIImageReferences, - Plugins: testenv.PluginConfig{ - Enabled: true, - RegistryURL: registryHost, - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - {Operation: config.HeaderRuleOperationPropagate, Matching: ".*"}, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - "X-Custom": []string{"value"}, - - "Host": []string{"evil.example.com"}, - - "Alt-Svc": []string{"h3=\":443\""}, - "Connection": []string{"keep-alive"}, - "Keep-Alive": []string{"timeout=5"}, - "Proxy-Authenticate": []string{"Basic"}, - "Proxy-Authorization": []string{"Basic dXNlcjpwYXNz"}, - "Proxy-Connection": []string{"keep-alive"}, - "Te": []string{"trailers"}, - "Trailer": []string{"Expires"}, - "Transfer-Encoding": []string{"chunked"}, - "Upgrade": []string{"websocket"}, - - "Accept": []string{"application/json"}, - "Accept-Charset": []string{"utf-8"}, - "Accept-Encoding": []string{"gzip, deflate"}, - "Content-Encoding": []string{"gzip"}, - "Content-Length": []string{"42"}, - "Content-Type": []string{"application/json"}, - - "Sec-Websocket-Extensions": []string{"permessage-deflate"}, - "Sec-Websocket-Key": []string{"dGhlIHNhbXBsZSBub25jZQ=="}, - "Sec-Websocket-Protocol": []string{"chat"}, - "Sec-Websocket-Version": []string{"13"}, - }, - }) - - // We only assert what the router writes to outgoing metadata before - // the gRPC transport runs. content-type, user-agent and HTTP/2 - // pseudo-headers (:authority, :method, :path, :scheme) are added - // later by the transport when framing HTTP/2 and are not visible - // to a client interceptor; verifying them would require a server - // interceptor inside the plugin binary. - - require.Equal(t, []string{"value"}, captured.Get("x-custom")) - - require.Empty(t, captured.Get("host")) - - require.Empty(t, captured.Get("alt-svc")) - require.Empty(t, captured.Get("connection")) - require.Empty(t, captured.Get("keep-alive")) - require.Empty(t, captured.Get("proxy-authenticate")) - require.Empty(t, captured.Get("proxy-authorization")) - require.Empty(t, captured.Get("proxy-connection")) - require.Empty(t, captured.Get("te")) - require.Empty(t, captured.Get("trailer")) - require.Empty(t, captured.Get("transfer-encoding")) - require.Empty(t, captured.Get("upgrade")) - - require.Empty(t, captured.Get("accept")) - require.Empty(t, captured.Get("accept-charset")) - require.Empty(t, captured.Get("accept-encoding")) - require.Empty(t, captured.Get("content-encoding")) - require.Empty(t, captured.Get("content-length")) - - require.Empty(t, captured.Get("sec-websocket-extensions")) - require.Empty(t, captured.Get("sec-websocket-key")) - require.Empty(t, captured.Get("sec-websocket-protocol")) - require.Empty(t, captured.Get("sec-websocket-version")) - }) - }) - - t.Run("grpc-reserved headers never reach the subgraph", func(t *testing.T) { - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - ModifyRouterConfig: addOCIImageReferences, - Plugins: testenv.PluginConfig{ - Enabled: true, - RegistryURL: registryHost, - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - {Operation: config.HeaderRuleOperationPropagate, Matching: ".*"}, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - "Grpc-ReservedHeader": []string{"should be ignored"}, - }, - }) - - require.Empty(t, captured.Get("grpc-reservedheader")) - }) - }) - - t.Run("safe headers are present in metadata", func(t *testing.T) { - t.Parallel() - - var captured metadata.MD - - testenv.Run(t, &testenv.Config{ - RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, - ModifyRouterConfig: addOCIImageReferences, - Plugins: testenv.PluginConfig{ - Enabled: true, - RegistryURL: registryHost, - }, - RouterOptions: []core.Option{ - core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(captureInterceptor(&captured))), - core.WithHeaderRules(config.HeaderRules{ - All: &config.GlobalHeaderRule{ - Request: []*config.RequestHeaderRule{ - {Operation: config.HeaderRuleOperationPropagate, Named: "Authorization"}, - {Operation: config.HeaderRuleOperationPropagate, Named: "Cookie"}, - {Operation: config.HeaderRuleOperationPropagate, Named: "Traceparent"}, - {Operation: config.HeaderRuleOperationPropagate, Named: "Tracestate"}, - {Operation: config.HeaderRuleOperationPropagate, Named: "Accept-Language"}, - }, - }, - }), - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ - Query: `query { projects { id name } }`, - Header: http.Header{ - "Authorization": []string{"Bearer eyJhbGciOiJSUzI1NiJ9"}, - "Cookie": []string{"session=abc123; theme=dark"}, - "Traceparent": []string{"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}, - "Tracestate": []string{"rojo=00f067aa0ba902b7"}, - "Accept-Language": []string{"de-DE,de;q=0.9,en;q=0.8"}, - }, - }) - - require.Equal(t, []string{"Bearer eyJhbGciOiJSUzI1NiJ9"}, captured.Get("authorization")) - require.Equal(t, []string{"session=abc123; theme=dark"}, captured.Get("cookie")) - require.Equal(t, []string{"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}, captured.Get("traceparent")) - require.Equal(t, []string{"rojo=00f067aa0ba902b7"}, captured.Get("tracestate")) - require.Equal(t, []string{"de-DE,de;q=0.9,en;q=0.8"}, captured.Get("accept-language")) - }) + testenv.RunGRPCPluginHeaderCases(t, testenv.Config{ + RouterConfigJSONTemplate: testenv.ConfigWithPluginsJSONTemplate, + ModifyRouterConfig: addOCIImageReferences, + Plugins: testenv.PluginConfig{ + Enabled: true, + RegistryURL: registryHost, + }, }) } diff --git a/router-tests/testenv/grpc_plugin_header_cases.go b/router-tests/testenv/grpc_plugin_header_cases.go new file mode 100644 index 0000000000..6477cb04ea --- /dev/null +++ b/router-tests/testenv/grpc_plugin_header_cases.go @@ -0,0 +1,223 @@ +package testenv + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + "github.com/wundergraph/cosmo/router/core" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +// CaptureSubgraphMetadataInterceptor returns a UnaryClientInterceptor that +// records the outgoing gRPC metadata of subgraph RPCs (methods under +// /service.*). Lifecycle RPCs (/plugin.*) issued by hashicorp/go-plugin are +// ignored to keep the capture aligned with the test request and to avoid a +// data race between the GraphQL call and the plugin's shutdown RPC. +func CaptureSubgraphMetadataInterceptor(captured *metadata.MD) grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + if strings.HasPrefix(method, "/service.") { + md, _ := metadata.FromOutgoingContext(ctx) + *captured = md.Copy() + } + return invoker(ctx, method, req, reply, cc, opts...) + } +} + +// GRPCPluginHeaderCase is a single header-forwarding scenario shared +// across the standard and OCI gRPC-plugin test suites. +type GRPCPluginHeaderCase struct { + Name string + HeaderRules config.HeaderRules + RequestHeaders http.Header + Assert func(t *testing.T, captured metadata.MD) +} + +func propagateNamed(names ...string) config.HeaderRules { + rules := make([]*config.RequestHeaderRule, 0, len(names)) + for _, n := range names { + rules = append(rules, &config.RequestHeaderRule{ + Operation: config.HeaderRuleOperationPropagate, + Named: n, + }) + } + return config.HeaderRules{All: &config.GlobalHeaderRule{Request: rules}} +} + +func propagateMatching(pattern string) config.HeaderRules { + return config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Request: []*config.RequestHeaderRule{ + {Operation: config.HeaderRuleOperationPropagate, Matching: pattern}, + }, + }, + } +} + +// GRPCPluginHeaderCases returns the header-forwarding matrix that must +// hold for any gRPC-plugin loading strategy (filesystem path or OCI +// registry pull). +func GRPCPluginHeaderCases() []GRPCPluginHeaderCase { + return []GRPCPluginHeaderCase{ + { + Name: "header arrives as metadata with correct value", + HeaderRules: propagateNamed("X-Tenant-Id", "X-Region-Name"), + RequestHeaders: http.Header{ + "X-Tenant-Id": []string{"acme"}, + "X-Region-Name": []string{"frankfurt"}, + }, + Assert: func(t *testing.T, captured metadata.MD) { + require.Equal(t, []string{"acme"}, captured.Get("x-tenant-id")) + require.Equal(t, []string{"frankfurt"}, captured.Get("x-region-name")) + }, + }, + { + Name: "header not in propagation rules is absent from metadata", + HeaderRules: propagateNamed("X-Allowed"), + RequestHeaders: http.Header{ + "X-Allowed": []string{"yes"}, + "X-Not-Allowed": []string{"secret"}, + }, + Assert: func(t *testing.T, captured metadata.MD) { + require.Equal(t, []string{"yes"}, captured.Get("x-allowed")) + require.Empty(t, captured.Get("x-not-allowed")) + }, + }, + { + Name: "header with multiple values arrives as multiple metadata values", + HeaderRules: propagateNamed("X-Role"), + RequestHeaders: http.Header{ + "X-Role": []string{"admin", "editor"}, + }, + Assert: func(t *testing.T, captured metadata.MD) { + require.Equal(t, []string{"admin", "editor"}, captured.Get("x-role")) + }, + }, + { + // The router avoids passing certain headers to datasources, + // see router/core/header_rule_engine.go. This case ensures + // gRPC datasources are covered by the same filtering. + Name: "unsafe headers are absent from metadata", + HeaderRules: propagateMatching(".*"), + RequestHeaders: http.Header{ + "X-Custom": []string{"value"}, + + // handled by HTTP stack, never in r.Header + "Host": []string{"evil.example.com"}, + + // hop-by-hop / connection headers + "Alt-Svc": []string{"h3=\":443\""}, + "Connection": []string{"keep-alive"}, + "Keep-Alive": []string{"timeout=5"}, + "Proxy-Authenticate": []string{"Basic"}, + "Proxy-Authorization": []string{"Basic dXNlcjpwYXNz"}, + "Proxy-Connection": []string{"keep-alive"}, + "Te": []string{"trailers"}, + "Trailer": []string{"Expires"}, + "Transfer-Encoding": []string{"chunked"}, + "Upgrade": []string{"websocket"}, + + // content negotiation + "Accept": []string{"application/json"}, + "Accept-Charset": []string{"utf-8"}, + "Accept-Encoding": []string{"gzip, deflate"}, + "Content-Encoding": []string{"gzip"}, + "Content-Length": []string{"42"}, + "Content-Type": []string{"application/json"}, + + // WebSocket upgrade + "Sec-Websocket-Extensions": []string{"permessage-deflate"}, + "Sec-Websocket-Key": []string{"dGhlIHNhbXBsZSBub25jZQ=="}, + "Sec-Websocket-Protocol": []string{"chat"}, + "Sec-Websocket-Version": []string{"13"}, + }, + Assert: func(t *testing.T, captured metadata.MD) { + // We only assert what the router writes to outgoing metadata + // before the gRPC transport runs. content-type, user-agent + // and HTTP/2 pseudo-headers (:authority, :method, :path, + // :scheme) are added later by the transport when framing + // HTTP/2 and are not visible to a client interceptor; + // verifying them would require a server interceptor inside + // the plugin binary. + require.Equal(t, []string{"value"}, captured.Get("x-custom")) + for _, h := range []string{ + "host", + "alt-svc", "connection", "keep-alive", + "proxy-authenticate", "proxy-authorization", "proxy-connection", + "te", "trailer", "transfer-encoding", "upgrade", + "accept", "accept-charset", "accept-encoding", + "content-encoding", "content-length", + "sec-websocket-extensions", "sec-websocket-key", + "sec-websocket-protocol", "sec-websocket-version", + } { + require.Empty(t, captured.Get(h), "expected %q to be stripped from outgoing metadata", h) + } + }, + }, + { + // Headers prefixed with "grpc-" are reserved by the gRPC + // protocol spec. Even with wildcard propagation, they must + // never appear on the subgraph. + Name: "grpc-reserved headers never reach the subgraph", + HeaderRules: propagateMatching(".*"), + RequestHeaders: http.Header{ + "Grpc-ReservedHeader": []string{"should be ignored"}, + }, + Assert: func(t *testing.T, captured metadata.MD) { + require.Empty(t, captured.Get("grpc-reservedheader")) + }, + }, + { + Name: "safe headers are present in metadata", + HeaderRules: propagateNamed("Authorization", "Cookie", "Traceparent", "Tracestate", "Accept-Language"), + RequestHeaders: http.Header{ + "Authorization": []string{"Bearer eyJhbGciOiJSUzI1NiJ9"}, + "Cookie": []string{"session=abc123; theme=dark"}, + "Traceparent": []string{"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}, + "Tracestate": []string{"rojo=00f067aa0ba902b7"}, + "Accept-Language": []string{"de-DE,de;q=0.9,en;q=0.8"}, + }, + Assert: func(t *testing.T, captured metadata.MD) { + require.Equal(t, []string{"Bearer eyJhbGciOiJSUzI1NiJ9"}, captured.Get("authorization")) + require.Equal(t, []string{"session=abc123; theme=dark"}, captured.Get("cookie")) + require.Equal(t, []string{"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}, captured.Get("traceparent")) + require.Equal(t, []string{"rojo=00f067aa0ba902b7"}, captured.Get("tracestate")) + require.Equal(t, []string{"de-DE,de;q=0.9,en;q=0.8"}, captured.Get("accept-language")) + }, + }, + } +} + +// RunGRPCPluginHeaderCases drives the gRPC-plugin header forwarding +// matrix against the supplied base testenv config. The caller fills in +// plugin-loading specifics (filesystem path vs OCI registry) and this +// helper layers the capture interceptor, header rules, and request on top. +func RunGRPCPluginHeaderCases(t *testing.T, base Config) { + t.Helper() + for _, tc := range GRPCPluginHeaderCases() { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + var captured metadata.MD + + cfg := base + cfg.RouterOptions = append([]core.Option{ + core.WithGRPCPluginDialOptions(grpc.WithUnaryInterceptor(CaptureSubgraphMetadataInterceptor(&captured))), + core.WithHeaderRules(tc.HeaderRules), + }, base.RouterOptions...) + + Run(t, &cfg, func(t *testing.T, xEnv *Environment) { + xEnv.MakeGraphQLRequestOK(GraphQLRequest{ + Query: `query { projects { id name } }`, + Header: tc.RequestHeaders, + }) + tc.Assert(t, captured) + }) + }) + } +}