Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ type VirtualMCPServerSpec struct {
// +optional
OutgoingAuth *OutgoingAuthConfig `json:"outgoingAuth,omitempty"`

// PassthroughHeaders is an allowlist of incoming client request header names
// forwarded verbatim to all backends (e.g. an API key the backend resolves to
// a user). Takes precedence over config.PassthroughHeaders. Names must not be
// restricted headers (Host, hop-by-hop, X-Forwarded-*). Forwarded headers are
// attacker-influenceable unless a trusted upstream sets them.
// +optional
// +listType=atomic
PassthroughHeaders []string `json:"passthroughHeaders,omitempty"`

// ServiceType specifies the Kubernetes service type for the Virtual MCP server
// +kubebuilder:validation:Enum=ClusterIP;NodePort;LoadBalancer
// +kubebuilder:default=ClusterIP
Expand Down
5 changes: 5 additions & 0 deletions cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions cmd/thv-operator/pkg/vmcpconfig/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ func (c *Converter) Convert(
// without requiring explicit mapping in this converter.
config := vmcp.Spec.Config.DeepCopy()

// Promoted top-level field takes precedence over spec.config.passthroughHeaders.
if len(vmcp.Spec.PassthroughHeaders) > 0 {
config.PassthroughHeaders = vmcp.Spec.PassthroughHeaders
}

// Override name with the CR name (authoritative source)
config.Name = vmcp.Name

Expand Down
44 changes: 44 additions & 0 deletions cmd/thv-operator/pkg/vmcpconfig/converter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2196,3 +2196,47 @@ func TestConvertIncomingAuth_PrimaryUpstreamProvider(t *testing.T) {
})
}
}

// TestConverter_PassthroughHeaders verifies that spec.passthroughHeaders is promoted
// correctly, takes precedence over spec.config.passthroughHeaders, and that the
// auto-passthrough path (only spec.config.passthroughHeaders set) is preserved.
func TestConverter_PassthroughHeaders(t *testing.T) {
t.Parallel()

// newVMCP builds a minimal VirtualMCPServer with the given passthrough header slices.
newVMCP := func(topLevel, configLevel []string) *mcpv1beta1.VirtualMCPServer {
return &mcpv1beta1.VirtualMCPServer{
ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"},
Spec: mcpv1beta1.VirtualMCPServerSpec{
GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"},
IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"},
PassthroughHeaders: topLevel,
Config: vmcpconfig.Config{PassthroughHeaders: configLevel},
},
}
}

tests := []struct {
name string
topLevel []string // spec.passthroughHeaders
config []string // spec.config.passthroughHeaders
want []string
}{
{name: "top-level only sets headers", topLevel: []string{"x-env"}, want: []string{"x-env"}},
{name: "top-level wins over config when both set", topLevel: []string{"x-api-key"}, config: []string{"x-tenant"}, want: []string{"x-api-key"}},
{name: "auto-passthrough: only config-level preserves value", config: []string{"x-tenant"}, want: []string{"x-tenant"}},
{name: "neither set produces nil"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
converter := newTestConverter(t, newNoOpMockResolver(t))
ctx := log.IntoContext(context.Background(), logr.Discard())
config, _, err := converter.Convert(ctx, newVMCP(tt.topLevel, tt.config), nil)
require.NoError(t, err)
require.NotNil(t, config)
assert.Equal(t, tt.want, config.PassthroughHeaders)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2130,6 +2130,17 @@ spec:
required:
- source
type: object
passthroughHeaders:
description: |-
PassthroughHeaders is an allowlist of incoming client request header names
forwarded verbatim to all backends. Captured at the vMCP incoming edge by
headerforward.CaptureMiddleware and consumed once at session creation
when the per-session backend client's HeaderForwardConfig is built. Names
must not be in the restricted set (Host, hop-by-hop, X-Forwarded-*, etc.).
items:
type: string
type: array
x-kubernetes-list-type: atomic
rateLimiting:
description: |-
RateLimiting defines rate limiting configuration for the Virtual MCP server.
Expand Down Expand Up @@ -2723,6 +2734,17 @@ spec:
- inline
type: string
type: object
passthroughHeaders:
description: |-
PassthroughHeaders is an allowlist of incoming client request header names
forwarded verbatim to all backends (e.g. an API key the backend resolves to
a user). Takes precedence over config.PassthroughHeaders. Names must not be
restricted headers (Host, hop-by-hop, X-Forwarded-*). Forwarded headers are
attacker-influenceable unless a trusted upstream sets them.
items:
type: string
type: array
x-kubernetes-list-type: atomic
podTemplateSpec:
description: |-
PodTemplateSpec defines the pod template to use for the Virtual MCP server
Expand Down Expand Up @@ -5125,6 +5147,17 @@ spec:
required:
- source
type: object
passthroughHeaders:
description: |-
PassthroughHeaders is an allowlist of incoming client request header names
forwarded verbatim to all backends. Captured at the vMCP incoming edge by
headerforward.CaptureMiddleware and consumed once at session creation
when the per-session backend client's HeaderForwardConfig is built. Names
must not be in the restricted set (Host, hop-by-hop, X-Forwarded-*, etc.).
items:
type: string
type: array
x-kubernetes-list-type: atomic
rateLimiting:
description: |-
RateLimiting defines rate limiting configuration for the Virtual MCP server.
Expand Down Expand Up @@ -5718,6 +5751,17 @@ spec:
- inline
type: string
type: object
passthroughHeaders:
description: |-
PassthroughHeaders is an allowlist of incoming client request header names
forwarded verbatim to all backends (e.g. an API key the backend resolves to
a user). Takes precedence over config.PassthroughHeaders. Names must not be
restricted headers (Host, hop-by-hop, X-Forwarded-*). Forwarded headers are
attacker-influenceable unless a trusted upstream sets them.
items:
type: string
type: array
x-kubernetes-list-type: atomic
podTemplateSpec:
description: |-
PodTemplateSpec defines the pod template to use for the Virtual MCP server
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2133,6 +2133,17 @@ spec:
required:
- source
type: object
passthroughHeaders:
description: |-
PassthroughHeaders is an allowlist of incoming client request header names
forwarded verbatim to all backends. Captured at the vMCP incoming edge by
headerforward.CaptureMiddleware and consumed once at session creation
when the per-session backend client's HeaderForwardConfig is built. Names
must not be in the restricted set (Host, hop-by-hop, X-Forwarded-*, etc.).
items:
type: string
type: array
x-kubernetes-list-type: atomic
rateLimiting:
description: |-
RateLimiting defines rate limiting configuration for the Virtual MCP server.
Expand Down Expand Up @@ -2726,6 +2737,17 @@ spec:
- inline
type: string
type: object
passthroughHeaders:
description: |-
PassthroughHeaders is an allowlist of incoming client request header names
forwarded verbatim to all backends (e.g. an API key the backend resolves to
a user). Takes precedence over config.PassthroughHeaders. Names must not be
restricted headers (Host, hop-by-hop, X-Forwarded-*). Forwarded headers are
attacker-influenceable unless a trusted upstream sets them.
items:
type: string
type: array
x-kubernetes-list-type: atomic
podTemplateSpec:
description: |-
PodTemplateSpec defines the pod template to use for the Virtual MCP server
Expand Down Expand Up @@ -5128,6 +5150,17 @@ spec:
required:
- source
type: object
passthroughHeaders:
description: |-
PassthroughHeaders is an allowlist of incoming client request header names
forwarded verbatim to all backends. Captured at the vMCP incoming edge by
headerforward.CaptureMiddleware and consumed once at session creation
when the per-session backend client's HeaderForwardConfig is built. Names
must not be in the restricted set (Host, hop-by-hop, X-Forwarded-*, etc.).
items:
type: string
type: array
x-kubernetes-list-type: atomic
rateLimiting:
description: |-
RateLimiting defines rate limiting configuration for the Virtual MCP server.
Expand Down Expand Up @@ -5721,6 +5754,17 @@ spec:
- inline
type: string
type: object
passthroughHeaders:
description: |-
PassthroughHeaders is an allowlist of incoming client request header names
forwarded verbatim to all backends (e.g. an API key the backend resolves to
a user). Takes precedence over config.PassthroughHeaders. Names must not be
restricted headers (Host, hop-by-hop, X-Forwarded-*). Forwarded headers are
attacker-influenceable unless a trusted upstream sets them.
items:
type: string
type: array
x-kubernetes-list-type: atomic
podTemplateSpec:
description: |-
PodTemplateSpec defines the pod template to use for the Virtual MCP server
Expand Down
2 changes: 2 additions & 0 deletions docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions docs/operator/virtualmcpserver-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,30 @@ spec:
- `externalAuthConfigRef`: Reference an MCPExternalAuthConfig resource
- `externalAuthConfigRef` (ExternalAuthConfigRef, optional): Auth config reference (when type=externalAuthConfigRef)

### `.spec.passthroughHeaders` (optional)

Allowlist of incoming client request headers forwarded verbatim to every
backend. The value is captured at the auth boundary and injected into the
session's outgoing backend requests. **Type**: `[]string`. Names are matched
case-insensitively; restricted headers (`Host`, `Authorization`,
`X-Forwarded-*`, hop-by-hop) are rejected at startup. Takes precedence over
`spec.config.passthroughHeaders`.

```yaml
spec:
passthroughHeaders:
- x-litellm-api-key # forwarded to all backends; absent on a request → not forwarded
```

> **Security:** forwarded headers are caller-controlled. Use only behind a
> trusted upstream (gateway/mesh) that sets or strips them; pair with
> `incomingAuth: anonymous` only in that case.
>
> **Horizontal scaling:** forwarded headers are not persisted across replicas
> (only the `(issuer, subject)` identity tuple is). A session restored on
> another replica re-captures them from the next request — keep clients pinned
> with `sessionAffinity: ClientIP`.

### `.spec.config.aggregation` (optional)

Defines tool aggregation and conflict resolution strategies.
Expand Down
1 change: 1 addition & 0 deletions pkg/vmcp/cli/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
AuthMiddleware: authMiddleware,
AuthzMiddleware: authzMiddleware,
AuthInfoHandler: authInfoHandler,
PassthroughHeaders: vmcpCfg.PassthroughHeaders,
RateLimitMiddleware: rateLimitMiddleware,
AuthServer: embeddedAuthServer,
TelemetryProvider: telemetryProvider,
Expand Down
9 changes: 9 additions & 0 deletions pkg/vmcp/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ type Config struct {
// Requires Redis session storage to be configured for distributed rate limiting.
// +optional
RateLimiting *ratelimittypes.RateLimitConfig `json:"rateLimiting,omitempty" yaml:"rateLimiting,omitempty"`

// PassthroughHeaders is an allowlist of incoming client request header names
// forwarded verbatim to all backends. Captured at the vMCP incoming edge by
// headerforward.CaptureMiddleware and consumed once at session creation
// when the per-session backend client's HeaderForwardConfig is built. Names
// must not be in the restricted set (Host, hop-by-hop, X-Forwarded-*, etc.).
// +optional
// +listType=atomic
PassthroughHeaders []string `json:"passthroughHeaders,omitempty" yaml:"passthroughHeaders,omitempty"`
}

// IncomingAuthConfig configures client authentication to the virtual MCP server.
Expand Down
27 changes: 27 additions & 0 deletions pkg/vmcp/config/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ package config

import (
"fmt"
"net/http"
"path/filepath"
"slices"
"strings"
"time"

httpval "github.com/stacklok/toolhive-core/validation/http"
"github.com/stacklok/toolhive/pkg/authserver"
"github.com/stacklok/toolhive/pkg/transport/middleware"
"github.com/stacklok/toolhive/pkg/vmcp"
authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types"
)
Expand Down Expand Up @@ -84,6 +87,11 @@ func (v *DefaultValidator) Validate(cfg *Config) error {
errors = append(errors, err.Error())
}

// Validate passthrough headers allowlist
if err := v.validatePassthroughHeaders(cfg); err != nil {
errors = append(errors, err.Error())
}

// Note: Optimizer validation is handled by optimizer.GetAndValidateConfig
// in pkg/vmcp/optimizer/optimizer.go when the optimizer is constructed.

Expand Down Expand Up @@ -487,6 +495,25 @@ func (*DefaultValidator) validateCompositeToolRefs(refs []CompositeToolRef) erro
return nil
}

func (*DefaultValidator) validatePassthroughHeaders(cfg *Config) error {
for i, name := range cfg.PassthroughHeaders {
if name == "" {
return fmt.Errorf("passthroughHeaders[%d]: header name must not be empty", i)
}

canonical := http.CanonicalHeaderKey(name)

if middleware.RestrictedHeaders[canonical] {
Comment thread
juancarlosm marked this conversation as resolved.
return fmt.Errorf("passthroughHeaders[%d]: %q is a restricted header and cannot be forwarded", i, canonical)
}

if err := httpval.ValidateHeaderName(name); err != nil {
return fmt.Errorf("passthroughHeaders[%d]: invalid header name %q: %w", i, name, err)
}
}
return nil
}

// Note: Workflow step validation is now handled by the shared ValidateWorkflowSteps function
// in composite_validation.go, which is called by ValidateCompositeToolConfig.

Expand Down
Loading
Loading