stacklok / stacklok/toolhive

vMCP: passthroughHeaders blocks Authorization/Cookie even for backends whose outgoing-auth strategy sets nothing (unauthenticated)

Open
#6,394 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

auth bug p1 vmcp
Dominant language
Go
Stars
2.2k
Forks
300
Avg merge
1d 15h
Merged PRs (30d)
184

Description

Problem

Upgrading to v0.44.0 sent every VirtualMCPServer/MCPGroup in one of our clusters into CrashLoopBackOff on passthroughHeaders[0]: "Authorization" is a restricted header and cannot be forwarded.

pkg/vmcp/config/validator.go unconditionally rejects Authorization and Cookie in spec.passthroughHeaders, even for unauthenticated backends. unauthenticated.Authenticate() is a no-op — it sets no header — so nothing can ever collide with a passthrough entry there. Blocking it protects against nothing.

Use cases

  • External auth backends. A backend sits behind its own OAuth/API-key gateway, unrelated to any vMCP strategy. The client already holds a valid Authorization that just needs relaying untouched — the original ask behind passthroughHeaders (#5466, #3958).
  • Gateway-mints, backend-verifies (zero-trust chains). A frontend (e.g. LiteLLM as an MCP gateway) mints a short-lived, audience-scoped token per request; the backend verifies it itself. See LiteLLM's own MCP zero-trust docs for the shape.

In both, vMCP's resolved strategy for that backend is unauthenticated — it injects nothing, so there's no vMCP-set header for the passthrough value to collide with.

Security questions

Isn't forwarding Authorization/Cookie inherently risky?
No — the default is deny. Every header is stripped unless the operator does two explicit things: lists it in spec.passthroughHeaders, on a backend whose resolved strategy happens to be unauthenticated. There's no implicit path — nothing forwards by accident. This issue doesn't touch that default; it only fixes when the extra block fires, for the one case (unauthenticated) where it protects nothing.

Doesn't this weaken the existing collision protection?
No. The real risk — vMCP's own resolved strategy sets the same header the operator listed, silently overwriting it (token_exchange/upstream_inject/aws_sts/obo/xaa all set Authorization; header_injection sets its configured name) — stays blocked exactly as today (#5535 tracks making that check itself more precise). This issue only covers unauthenticated, where the collision can't structurally happen: nothing is set, so nothing gets overwritten.

Is there precedent?
Yes — the standalone (non-vMCP) header-forward middleware (pkg/transport/middleware/header_forward.go:23-46) already allows Authorization (warning-logged) and never restricted Cookie. The blanket vMCP block is the outlier, tightened in #6235 with no carve-out for the no-op case.

Proposed fix

Same restricted-name list, same content — only where and when it's checked moves.

Today's check runs at config-parse time, before any backend is discovered; in "discovered" mode each strategy resolves later, per MCPServer, so there's no backend yet to ask "are you unauthenticated?" The check can't just gain a conditional — it has to move to where a backend's strategy actually exists: backendDiscoverer.Discover (pkg/vmcp/aggregator/discoverer.go), the one function both static and dynamic discovery return through, where AuthConfig gets populated. There, once per backend:

  • AuthConfig == nil → skip, nothing to collide with.
  • AuthConfig.Type == unauthenticated → skip too. Distinct from nil: an operator can set it explicitly, or discovery can resolve to it. Toolhive's own backend-client code already treats both as equivalent (defaults to unauthenticated before checking nil), so the check must test both — nil alone would miss the explicit case.
  • Anything else → checked against the restricted list as today. Fails closed: any future strategy type is blocked by default unless it's unauthenticated.

With this per-backend check in place, validatePassthroughHeaders's unconditional reject is redundant and comes out — it can't be correct on its own once the answer depends on info it doesn't have yet. Host, hop-by-hop, and X-Forwarded-* are untouched — they don't depend on any backend's strategy.

Ask

Implements #5535's intent, extended to Cookie for the same reasoning — #6235 blocked both under one justification that doesn't hold once unauthenticated sets nothing and forwarding is already explicit opt-in. Happy to open the PR with the concrete diff.


Co-authored with Claude Code.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with pkg/vmcp/config/validator.go and backendDiscoverer.Discover in pkg/vmcp/aggregator/discoverer.go, tracing where AuthConfig is populated for static and discovered backends. Remove the unconditional restricted-header rejection and enforce it per backend, skipping nil or unauthenticated strategies while retaining the existing restrictions for other strategies. Done means Authorization and Cookie work for explicitly or implicitly unauthenticated backends while Host, hop-by-hop, X-Forwarded-*, and collision protection remain unchanged.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
backend, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.