Skip to content

Add Origin header validation for DNS-rebind protection#4908

Open
JAORMX wants to merge 1 commit intomainfrom
origin-header-validation
Open

Add Origin header validation for DNS-rebind protection#4908
JAORMX wants to merge 1 commit intomainfrom
origin-header-validation

Conversation

@JAORMX
Copy link
Copy Markdown
Collaborator

@JAORMX JAORMX commented Apr 17, 2026

Summary

ToolHive's proxy layer had no Origin header validation, and the legacy HTTP+SSE transport emitted Access-Control-Allow-Origin: *, leaving both modes open to DNS-rebinding attacks from browser clients. MCP 2025-11-25 §"Security Warning" requires servers to validate Origin on all connections and respond with 403 when the value is invalid.

  • Add pkg/transport/middleware/origin/ with a factory (for the RunConfig-driven chain used by thv run / thv-proxyrunner / vMCP) and a bare constructor (for the inline chain used by thv proxy)
  • New --allowed-origins flag on both thv run and thv proxy; when empty and the bind is loopback, a default loopback-only allowlist is derived automatically. Non-loopback binds without an explicit list log a warning (bind opt-in hardening follows in a separate PR)
  • Case-insensitive scheme+host match per RFC 6454 §4; requests with multiple Origin headers are rejected outright; 403 responses carry a canonical JSON-RPC error body
  • Removed Access-Control-Allow-Origin: * from the httpsse SSE handler — the wildcard would have neutered the enforcement via preflight response inheritance

Closes audit row 5 (Origin validation absent) from the MCP 2025-11-25 spec-compliance audit.

Type of change

  • Bug fix

Test plan

  • Unit tests (task test) — pkg/transport/middleware/origin/..., pkg/runner/..., pkg/transport/proxy/httpsse/... all green
  • Linting (task lint-fix) — 0 issues
  • Manual testing — verified thv run and thv proxy with --host 127.0.0.1 auto-derive the loopback allowlist; --host 0.0.0.0 without --allowed-origins logs the expected warning; explicit --allowed-origins takes precedence over the auto-derivation. Verified the 403 JSON-RPC error body shape with curl + crafted Origin headers.

Does this introduce a user-facing change?

Yes. Two related changes:

  1. New flag: --allowed-origins on thv run and thv proxy accepts a repeatable exact-match list for the HTTP Origin header. Default behavior for loopback binds preserves existing browser-client flows (same-origin from localhost / 127.0.0.1 / [::1] is auto-allowed). Non-loopback binds continue to work as before but now log a warning recommending explicit origins.
  2. Browser CORS behavior: the * wildcard on the httpsse /sse endpoint is gone. Any browser client that relied on cross-origin requests to the legacy SSE transport will need to migrate to the streamable transport or be added to --allowed-origins.

Implementation plan

Approved implementation plan

PR-1 from the MCP proxy spec-compliance Phase 2 plan (~/.claude/plans/yes-let-s-plan-phase-abundant-dragonfly.md, personal / not in repo).

Scope: implement and wire the Origin-header validation middleware, remove the httpsse Access-Control-Allow-Origin: * reflector. Uses the bare-middleware + factory pattern modelled on pkg/transport/middleware/header_forward.go. Shared default-allowlist derivation (origin.ResolveAllowedOrigins) keeps the runner and thv proxy call sites from drifting.

Deferred to follow-up PRs:

  • PR-12 (audit row 22): warn on non-loopback bind + --allow-public-bind opt-in — this PR only adds the warn path through the middleware absence
  • Operator CRD wiring: MCPServer CRD does not yet expose allowedOrigins for the operator-reconciled proxyrunner pods; a follow-up PR will add the CRD field and serialize it into runconfig
  • Recovery middleware scope: the pre-existing Recovery middleware wraps only the backend handler rather than the full middleware chain; untouched here

Special notes for reviewers

  • The loopback-default derivation uses net.ParseIP(host).IsLoopback() so 127.0.0.2 and similar variants are handled; the earlier literal-switch approach missed them.
  • origin.ResolveAllowedOrigins is exported specifically so cmd/thv/app/proxy.go (the inline chain) can share logic with pkg/runner/middleware.go. Doc comment explains the contract.
  • The empty-allowlist path registers a pass-through middleware (not a no-op at the factory) so the middleware slot stays occupied; a WARN log makes the disabled state visible.
  • Part of a broader Phase 2 plan that fixes 16 audit findings across 15 PRs. Subsequent PRs (event-IDs + primer, protocol-version allowlist, session-identity binding, etc.) will follow once this lands.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Apr 17, 2026
ToolHive's proxy layer had no Origin-header validation, and the legacy
HTTP+SSE transport sent `Access-Control-Allow-Origin: *`, leaving both
modes open to DNS-rebinding attacks from browser clients. MCP 2025-11-25
§"Security Warning" requires servers to validate Origin on all
connections and respond 403 when the value is invalid.

This change introduces a dedicated middleware at
pkg/transport/middleware/origin/ that rejects requests whose Origin
header is present and not in an operator-configured allowlist, and
wires it into both the factory-based chain (thv run / thv-proxyrunner
/ vMCP) and the inline chain (thv proxy).

Behavior:
- New --allowed-origins flag on `thv run` and `thv proxy` accepts a
  repeatable exact-match list. When empty and the bind host is
  loopback, a default loopback-only allowlist is derived automatically
  (http://localhost:PORT + 127.0.0.1 + [::1]). When empty and the
  bind is non-loopback, the middleware is skipped and a warning is
  logged — the bind-opt-in hardening lands in a follow-up.
- Matching is byte-exact except that scheme and host are lowercased
  per RFC 6454 §4. Requests with multiple Origin headers are rejected
  outright.
- 403 responses carry a JSON-RPC error body (id: null, code -32600,
  message "Origin not allowed").
- `Access-Control-Allow-Origin: *` removed from the httpsse SSE
  handler; the wildcard would have neutered any Origin enforcement
  via preflight response inheritance.

Closes audit row 5 (Origin validation absent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Juan Antonio Osorio <ozz@stacklok.com>
@JAORMX JAORMX force-pushed the origin-header-validation branch from 70df577 to f2aa438 Compare April 17, 2026 06:13
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Apr 17, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 17, 2026

Codecov Report

❌ Patch coverage is 84.03361% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.30%. Comparing base (d96922d) to head (f2aa438).
⚠️ Report is 134 commits behind head on main.

Files with missing lines Patch % Lines
pkg/runner/middleware.go 57.14% 7 Missing and 2 partials ⚠️
pkg/transport/middleware/origin/origin.go 93.61% 3 Missing and 3 partials ⚠️
pkg/runner/config_builder.go 0.00% 4 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main    #4908    +/-   ##
========================================
  Coverage   69.29%   69.30%            
========================================
  Files         535      536     +1     
  Lines       55368    55483   +115     
========================================
+ Hits        38367    38451    +84     
- Misses      14058    14082    +24     
- Partials     2943     2950     +7     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor

@jhrozek jhrozek left a comment

Choose a reason for hiding this comment

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

A few clarifying questions on wiring/coverage and a couple of style questions. Nothing blocking — just want to make sure the integration paths and API shape are intentional. Implementation itself looks solid; the spec compliance is correct.

// the middleware disabled.
func WithAllowedOrigins(origins []string) RunConfigBuilderOption {
return func(b *runConfigBuilder) error {
b.config.AllowedOrigins = origins
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.

The new addOriginMiddleware is added to PopulateMiddlewareConfigs, but WithMiddlewareFromFlags / addCoreMiddlewares (the path used by thv run) doesn't include it. Since runner.Run skips PopulateMiddlewareConfigs when MiddlewareConfigs is pre-populated (runner.go:232), thv run --allowed-origins=... plumbs the flag into RunConfig.AllowedOrigins — which is exactly what this builder option does — but the middleware never registers at runtime.

Is this intentional, or an omission? If intentional, what's the rationale for excluding thv run from the protection?

Comment thread pkg/runner/config.go
// loopback-only allowlist is derived at middleware-wiring time.
// When empty and Host is non-loopback, the middleware is disabled — operators
// exposing the proxy publicly must configure an explicit allowlist.
AllowedOrigins []string `json:"allowed_origins,omitempty" yaml:"allowed_origins,omitempty"`
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.

The operator path uses PopulateMiddlewareConfigs so the factory side is wired, but MCPServerSpec / MCPRemoteProxySpec / VirtualMCPServerSpec have no AllowedOrigins field. Combined with operator-deployed pods binding to non-loopback addresses, ResolveAllowedOrigins returns nil and addOriginMiddleware skips registration with a WARN — so K8s deployments ship with Origin validation disabled.

Is this expected, planned for a follow-up PR, or considered out of scope for the CRDs? If a follow-up, would it be worth a // TODO or a note in the PR description so it's tracked?

Comment thread pkg/runner/middleware.go
// middleware is skipped entirely and a WARN is logged so the security-disabled
// state is visible in operator logs. A follow-up PR hardens the non-loopback
// path by requiring an explicit opt-in flag (see audit row 22).
func addOriginMiddleware(middlewares []types.MiddlewareConfig, config *RunConfig) ([]types.MiddlewareConfig, error) {
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.

The commit message says the middleware is wired into "thv run / thv-proxyrunner / vMCP", but vMCP composes its own chain via factory.NewIncomingAuthMiddleware and pkg/vmcp/server/server.go Handler — neither references the new origin package. Grep across pkg/vmcp/ and cmd/vmcp/ finds no usage.

Should vMCP be wired here too, or should the commit message be corrected to drop the vMCP claim and tracked as a follow-up?

// host ASCII-case-insensitive; the port is a decimal integer and has no case.
// Malformed inputs (no "://" separator) are returned lowercased in full on the
// assumption that they will simply not match any legitimate allowlist entry.
func canonicalizeOrigin(raw string) string {
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.

Was net/url.Parse considered for this function, and if so, what made hand-rolled parsing the better choice?

Asking because the current implementation has a few RFC-6454 edge cases that url.Parse would handle for free: userinfo (https://user:pass@host) is preserved rather than rejected per §6, IPv6 bracket detection is hand-rolled, and IDN/punycode normalization isn't applied. Curious if these are deliberate tradeoffs (e.g., avoiding a dep on golang.org/x/net/idna, or the cost of full URL parsing on the hot path).

// and host portions of the Origin value are lowercased (RFC 6454 §4: scheme
// and host are ASCII-case-insensitive). Configured allowlist entries are
// lowercased once at construction time.
func CreateOriginMiddleware(allowedOrigins []string) types.MiddlewareFunction {
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.

What's the purpose of having both CreateOriginMiddleware (exported, single-line forwarder) and createOriginHandler (unexported, the actual implementation)? The factory path bypasses the public API and calls the unexported one directly, and tests also use the unexported one — so the only caller of CreateOriginMiddleware is cmd/thv/app/proxy.go.

Could one of them be removed, or is there a reason to keep both as separate entry points (e.g., a planned divergence in behavior)?

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

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants