Skip to content

fix: allow header propagation for router-plugins#2877

Open
Noroth wants to merge 1 commit into
mainfrom
ludwig/eng-9615-router-plugin-metadata-overwritten-in-grpc-plugin-middleware
Open

fix: allow header propagation for router-plugins#2877
Noroth wants to merge 1 commit into
mainfrom
ludwig/eng-9615-router-plugin-metadata-overwritten-in-grpc-plugin-middleware

Conversation

@Noroth
Copy link
Copy Markdown
Contributor

@Noroth Noroth commented May 20, 2026

This PR fixes an issue that header propagation was not working for router-plugins if telemetry was enabled. Instead the metadata was overwritten by the plugin-client invoke logic.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added configurable gRPC dial options for plugin connections, allowing customization of how the router connects to gRPC plugin subgraphs.
    • Implemented automatic HTTP header forwarding to plugin subgraph calls, with built-in filtering to exclude unsafe and reserved headers while preserving standard headers like authorization and trace context.
  • Tests

    • Added comprehensive test coverage for gRPC header propagation and filtering across both standard and OCI-based plugin configurations.

Review Change Stack

Checklist

  • I have discussed my proposed changes in an issue and have received approval to proceed.
  • I have followed the coding standards of the project.
  • Tests or benchmarks have been added or updated.
  • Documentation has been updated on https://github.com/wundergraph/docs-website.
  • I have read the Contributors Guide.

Open Source AI Manifesto

This project follows the principles of the Open Source AI Manifesto. Please ensure your contribution aligns with its principles.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

Walkthrough

This PR adds support for configurable gRPC dial options to router plugins and validates HTTP header propagation to subgraph metadata. The change enables header forwarding from HTTP requests to outgoing gRPC calls through dial option customization, with comprehensive integration tests covering both binary and OCI plugin modes.

Changes

gRPC Plugin Dial Options and Header Forwarding

Layer / File(s) Summary
Router configuration and option setup
router/core/router_config.go, router/core/router.go
Adds grpcPluginDialOptions field to router Config and introduces WithGRPCPluginDialOptions option function to allow configuration of gRPC dial behavior for plugins.
Plugin configuration wiring and initialization
router/core/graph_server.go, router/pkg/grpcconnector/grpcplugin/grpc_plugin.go, router/pkg/grpcconnector/grpcpluginoci/grpc_oci_plugin.go
Passes configured dial options from router config through both OCI-based and binary plugin constructors, extending GRPCPluginConfig structs to accept and store DialOptions.
Metadata context preservation
router/pkg/grpcconnector/grpccommon/grpc_plugin_client.go
Updates metadata initialization in GRPCPluginClient.Invoke to preserve existing outgoing gRPC metadata from context before injecting new metadata entries.
Binary plugin header forwarding tests
router-tests/protocol/router_plugin_test.go
Introduces TestRouterPluginWithHeaderForwarding with helper interceptor and subtests validating header propagation rules, multi-value handling, unsafe header filtering, grpc-prefix blocking, and safe standard headers in binary plugin mode.
OCI plugin header forwarding tests
router-tests/router_oci_plugin_test.go
Adds TestOCIPluginWithHeaderForwarding with interceptor helper and parallel subtests asserting header-to-metadata forwarding across propagation rules, unsafe header filtering, reserved header blocking, and safe header preservation in OCI plugin mode.

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'fix: allow header propagation for router-plugins' directly and specifically describes the main change: enabling header propagation functionality for router plugins.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 20, 2026

Router image scan passed

✅ No security vulnerabilities found in image:

ghcr.io/wundergraph/cosmo/router:sha-4992044d70e37370338639933d3a89867f42d2ba

@codecov
Copy link
Copy Markdown

codecov Bot commented May 20, 2026

Codecov Report

❌ Patch coverage is 81.81818% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.24%. Comparing base (be9d015) to head (a5765a1).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
router/core/graph_server.go 50.00% 1 Missing ⚠️
...pkg/grpcconnector/grpcpluginoci/grpc_oci_plugin.go 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2877      +/-   ##
==========================================
+ Coverage   64.47%   66.24%   +1.77%     
==========================================
  Files         319      258      -61     
  Lines       45359    27064   -18295     
  Branches     4927        0    -4927     
==========================================
- Hits        29245    17929   -11316     
+ Misses      16089     7723    -8366     
- Partials       25     1412    +1387     
Files with missing lines Coverage Δ
router/core/router.go 70.51% <100.00%> (ø)
router/core/router_config.go 93.78% <ø> (ø)
...pkg/grpcconnector/grpccommon/grpc_plugin_client.go 79.31% <100.00%> (ø)
router/pkg/grpcconnector/grpcplugin/grpc_plugin.go 75.43% <100.00%> (ø)
router/core/graph_server.go 85.51% <50.00%> (ø)
...pkg/grpcconnector/grpcpluginoci/grpc_oci_plugin.go 14.50% <50.00%> (ø)

... and 571 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@router-tests/protocol/router_plugin_test.go`:
- Around line 624-956: Enable telemetry for at least one subtest so the
telemetry-injection regression path is exercised: pick a subtest (e.g., the
"unsafe headers are absent from metadata" case) and modify the testenv.Config
passed to testenv.Run to turn telemetry on (for example by adding Telemetry:
testenv.TelemetryConfig{Enabled: true} or the equivalent flag in your test
harness), keeping the rest of the RouterOptions (captureInterceptor,
core.WithHeaderRules) unchanged so captureInterceptor, testenv.Run and
testenv.Config still validate that telemetry-injected outgoing metadata does not
overwrite/escape header propagation rules.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d7778b56-1b63-420e-bbd2-035501e41492

📥 Commits

Reviewing files that changed from the base of the PR and between 8acd38f and a5765a1.

📒 Files selected for processing (8)
  • router-tests/protocol/router_plugin_test.go
  • router-tests/router_oci_plugin_test.go
  • router/core/graph_server.go
  • router/core/router.go
  • router/core/router_config.go
  • router/pkg/grpcconnector/grpccommon/grpc_plugin_client.go
  • router/pkg/grpcconnector/grpcplugin/grpc_plugin.go
  • router/pkg/grpcconnector/grpcpluginoci/grpc_oci_plugin.go

Comment on lines +624 to +956
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"))
})
})
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Cover the telemetry-enabled regression path here.

The bug this PR fixes only reproduces when telemetry injects its own outgoing metadata, but every subtest in this new suite runs without telemetry enabled. As written, these assertions still pass against the pre-fix overwrite path. Please turn telemetry on in at least one case so the regression is actually exercised.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router-tests/protocol/router_plugin_test.go` around lines 624 - 956, Enable
telemetry for at least one subtest so the telemetry-injection regression path is
exercised: pick a subtest (e.g., the "unsafe headers are absent from metadata"
case) and modify the testenv.Config passed to testenv.Run to turn telemetry on
(for example by adding Telemetry: testenv.TelemetryConfig{Enabled: true} or the
equivalent flag in your test harness), keeping the rest of the RouterOptions
(captureInterceptor, core.WithHeaderRules) unchanged so captureInterceptor,
testenv.Run and testenv.Config still validate that telemetry-injected outgoing
metadata does not overwrite/escape header propagation rules.

Copy link
Copy Markdown
Contributor

@SkArchon SkArchon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one comment


}

func TestRouterPluginWithHeaderForwarding(t *testing.T) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a test where we pass in header metadata, but also want to do a distributed trace. I think I wrote the func TestVerifyTelemetryForRouterPluginRequests(t *testing.T) { test for this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants