Skip to content

Add Origin header validation for DNS-rebind protection#4908

Open
JAORMX wants to merge 1 commit into
mainfrom
origin-header-validation
Open

Add Origin header validation for DNS-rebind protection#4908
JAORMX wants to merge 1 commit into
mainfrom
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
@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 88.61789% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.82%. Comparing base (6f63ac0) to head (322b09f).

Files with missing lines Patch % Lines
pkg/runner/config_builder.go 0.00% 4 Missing ⚠️
pkg/runner/middleware.go 78.94% 2 Missing and 2 partials ⚠️
pkg/transport/middleware/origin/origin.go 95.83% 2 Missing and 2 partials ⚠️
pkg/runner/runner.go 50.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4908      +/-   ##
==========================================
+ Coverage   68.77%   68.82%   +0.04%     
==========================================
  Files         629      630       +1     
  Lines       63914    64032     +118     
==========================================
+ Hits        43955    44068     +113     
- Misses      16703    16705       +2     
- Partials     3256     3259       +3     

☔ 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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — and the fix needed to go deeper than moving the call into WithMiddlewareFromFlags. The builder resolves the effective port in validateConfig (via WithPorts), which runs after all options, so b.config.Port is still 0 during WithMiddlewareFromFlagsResolveAllowedOrigins would have returned nil and silently disabled loopback defaults for thv run. Fixed by wiring Origin validation centrally in runner.Run (new prependOriginMiddleware), after both population paths, where Host/Port/AllowedOrigins are fully resolved. Removed it from PopulateMiddlewareConfigs so there's one wiring site, and prepended it so it still runs first (rejects before auth). Added TestPrependOriginMiddleware covering the thv run path.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deferring CRD wiring to a follow-up to keep this PR focused on CLI/proxyrunner. Documented explicitly in the commit message and in the prependOriginMiddleware doc comment: operator non-loopback pods log the WARN rather than enforcing until the CRD field lands. Tracked in a follow-up issue: "Add allowedOrigins to MCPServer/MCPRemoteProxy/VirtualMCPServer CRDs + operator wiring."

Comment thread pkg/runner/middleware.go Outdated
// 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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correct — vMCP composes its own chain and doesn't reference this package. Dropped the "vMCP" claim from the commit message and called out vMCP wiring as a follow-up. Tracked in a follow-up issue: "Wire Origin validation into the vMCP middleware chain."

// 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).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Switched to net/url.Parse. It now rejects userinfo per RFC 6454 §6 (closing an Origin-smuggling vector like http://localhost:8080@evil.example), handles IPv6 brackets and host/port via Hostname()/Port(), and lowercases scheme+host per §4. Unparseable / scheme-less / host-less / userinfo inputs map to a sentinel that can never match (fail closed). Added tests for these cases. IDN/punycode normalization is intentionally not applied — matching stays on the literal serialized form; can add x/net/idna in a follow-up if we want host equivalence.

// 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)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Collapsed into a single exported entry point NewHandler, used by both the factory (CreateMiddleware) and cmd/thv/app/proxy.go. Removed the duplicate forwarder.

rdimitrov
rdimitrov previously approved these changes May 29, 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. It is
wired centrally in runner.Run for both the factory-based chain
(thv run / thv-proxyrunner) and inline in the `thv proxy` chain.

Wiring is done in runner.Run rather than in the builder because that is
the only point where the effective Host/Port/AllowedOrigins are fully
resolved: the CLI builder (WithMiddlewareFromFlags) defers port
resolution to validateConfig, so an earlier hook would see port 0 and
silently disable loopback-default protection for `thv run`. The
middleware is prepended so Origin validation runs first in the chain.

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 parses each value with net/url and compares scheme://host
  [:port] with scheme and host lowercased per RFC 6454 §4. Values
  carrying userinfo (RFC 6454 §6) or that fail to parse never match,
  closing an Origin-smuggling vector. 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.

Operator CRDs (MCPServer/MCPRemoteProxy/VirtualMCPServer) do not yet
expose an allowedOrigins field, and vMCP composes its own middleware
chain that does not reference this package. Both are deferred to
follow-ups; until then operator/vMCP non-loopback deployments log the
WARN above rather than enforcing.

Closes audit row 5 (Origin validation absent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Juan Antonio Osorio <ozz@stacklok.com>
@JAORMX JAORMX force-pushed the origin-header-validation branch from f2aa438 to 322b09f Compare May 29, 2026 11:16
@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 May 29, 2026
@JAORMX
Copy link
Copy Markdown
Collaborator Author

JAORMX commented May 29, 2026

Filed the two deferred follow-ups referenced in the threads above:

@JAORMX JAORMX requested a review from rdimitrov May 29, 2026 11:49
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.

3 participants