Skip to content
Open
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
66 changes: 49 additions & 17 deletions cmd/thv/app/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,10 @@ func init() {
proxyCmd.Flags().StringVar(&proxyHost, "host", transport.LocalhostIPv4, "Host for the HTTP proxy to listen on (IP or hostname)")
proxyCmd.Flags().IntVar(&proxyPort, "port", 0, "Port for the HTTP proxy to listen on (host port)")
proxyCmd.Flags().StringArrayVar(&proxyAllowedOrigins, "allowed-origins", nil,
"Exact-match allowlist for the HTTP Origin header (repeatable). Recommended when binding publicly; "+
"loopback binds derive a default allowlist automatically, non-loopback binds log a warning when "+
"no value is supplied. Example: https://my-mcp.example.com")
"Exact-match allowlist for the HTTP Origin header (repeatable). When set, also enables CORS for exactly "+
"these origins on the MCP proxy endpoint. CORS is disabled by default; omit this flag when CORS is "+
"handled by an upstream gateway. Loopback binds derive a default origin allowlist automatically but "+
"never enable CORS. Example: https://my-mcp.example.com")
proxyCmd.Flags().StringVar(
&proxyTargetURI,
"target-uri",
Expand Down Expand Up @@ -244,18 +245,7 @@ func proxyCmdFunc(cmd *cobra.Command, args []string) error {
// Origin-header validation (DNS-rebinding protection per MCP 2025-11-25
// §"Security Warning"). Added after body-limit so disallowed Origins are
// rejected before authentication or any outbound token acquisition runs.
if allowed := origin.ResolveAllowedOrigins(proxyHost, port, proxyAllowedOrigins); len(allowed) > 0 {
middlewares = append(middlewares, types.NamedMiddleware{
Name: origin.MiddlewareType,
Function: origin.NewHandler(allowed),
})
} else {
slog.Warn("Origin validation disabled — no allowlist configured for non-loopback bind",
"host", proxyHost,
"port", port,
"hint", "pass --allowed-origins=https://your-client.example to enable DNS-rebind protection",
)
}
addOriginMiddleware(&middlewares, proxyHost, port)

// Get OIDC configuration if enabled (for protecting the proxy endpoint)
oidcConfig := getProxyOIDCConfig(cmd)
Expand Down Expand Up @@ -286,8 +276,14 @@ func proxyCmdFunc(cmd *cobra.Command, args []string) error {
slog.Debug(fmt.Sprintf("Setting up transparent proxy to forward from host port %d to %s",
port, proxyTargetURI))

// Build optional functional options (e.g. CORS), only when configured.
proxyOptions, err := buildCORSProxyOptions(proxyAllowedOrigins)
if err != nil {
return fmt.Errorf("invalid --allowed-origins: %w", err)
}

// Create the transparent proxy with middlewares
proxy := transparent.NewTransparentProxy(
proxy := transparent.NewTransparentProxyWithOptions(
proxyHost,
port,
proxyTargetURI,
Expand All @@ -301,7 +297,8 @@ func proxyCmdFunc(cmd *cobra.Command, args []string) error {
nil, // onUnauthorizedResponse - not needed for local proxies
"", // endpointPrefix - not configured for proxy command
false, // trustProxyHeaders - not configured for proxy command
middlewares...)
middlewares,
proxyOptions...)
if err := proxy.Start(ctx); err != nil {
return fmt.Errorf("failed to start proxy: %w", err)
}
Expand Down Expand Up @@ -503,6 +500,25 @@ func addExternalTokenMiddleware(middlewares *[]types.NamedMiddleware, tokenSourc
return nil
}

// addOriginMiddleware appends the Origin-header validation middleware
// (DNS-rebinding protection per MCP 2025-11-25 §"Security Warning"), built
// from the effective allowlist for the given host/port. It logs a warning when
// no allowlist could be resolved (non-loopback bind with no explicit origins).
func addOriginMiddleware(middlewares *[]types.NamedMiddleware, host string, port int) {
if allowed := origin.ResolveAllowedOrigins(host, port, proxyAllowedOrigins); len(allowed) > 0 {
*middlewares = append(*middlewares, types.NamedMiddleware{
Name: origin.MiddlewareType,
Function: origin.NewHandler(allowed),
})
} else {
slog.Warn("Origin validation disabled — no allowlist configured for non-loopback bind",
"host", host,
"port", port,
"hint", "pass --allowed-origins=https://your-client.example to enable DNS-rebind protection",
)
}
}

// addHeaderForwardMiddleware adds header forward middleware to the middleware chain if headers are configured.
// Secret references are resolved immediately via the secrets manager.
func addHeaderForwardMiddleware(
Expand Down Expand Up @@ -562,3 +578,19 @@ func validateProxyTargetURI(targetURI string) error {

return nil
}

// buildCORSProxyOptions validates the explicit --allowed-origins list and
// builds the transparent-proxy option that enables CORS for those origins.
// It returns no options when the list is empty so default behaviour is
// unchanged. Invalid entries (missing scheme, wildcard, garbage) fail loudly
// at startup instead of silently never matching a browser Origin.
func buildCORSProxyOptions(allowedOrigins []string) ([]transparent.Option, error) {
if len(allowedOrigins) == 0 {
return nil, nil
}
validated, err := middleware.ValidateAllowedOrigins(allowedOrigins)
if err != nil {
return nil, err
}
return []transparent.Option{transparent.WithAllowedOrigins(validated)}, nil
}
7 changes: 4 additions & 3 deletions cmd/thv/app/run_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,10 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) {
cmd.Flags().StringVar(&config.Group, "group", "default", "Name of the group this workload should belong to")
cmd.Flags().StringVar(&config.Host, "host", transport.LocalhostIPv4, "Host for the HTTP proxy to listen on (IP or hostname)")
cmd.Flags().StringArrayVar(&config.AllowedOrigins, "allowed-origins", nil,
"Exact-match allowlist for the HTTP Origin header (repeatable). Recommended when binding publicly; "+
"loopback binds derive a default allowlist automatically, non-loopback binds log a warning when "+
"no value is supplied. Example: https://my-mcp.example.com")
"Exact-match allowlist for the HTTP Origin header (repeatable). When set, also enables CORS for exactly "+
"these origins on the MCP proxy endpoint. CORS is disabled by default; omit this flag when CORS is "+
"handled by an upstream gateway. Loopback binds derive a default origin allowlist automatically but "+
"never enable CORS. Example: https://my-mcp.example.com")
cmd.Flags().IntVar(&config.ProxyPort, "proxy-port", 0, "Port for the HTTP proxy to listen on (host port)")
cmd.Flags().IntVar(&config.TargetPort, "target-port", 0,
"Port for the container to expose (only applicable to SSE or Streamable HTTP transport)")
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/thv_proxy.md

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

2 changes: 1 addition & 1 deletion docs/cli/thv_run.md

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

4 changes: 4 additions & 0 deletions pkg/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ func (r *Runner) Run(ctx context.Context) error {
StrictProtocolValidation: r.Config.StrictProtocolValidation,
EndpointPrefix: r.Config.EndpointPrefix,
SessionTTL: effectiveSessionTTL,
// CORS is enabled only from the EXPLICIT --allowed-origins list. The
// loopback-derived defaults resolved in prependOriginMiddleware must
// never reach CORS, so only the raw config value is passed here.
AllowedCORSOrigins: r.Config.AllowedOrigins,
}

// Set proxy mode for stdio transport
Expand Down
2 changes: 2 additions & 0 deletions pkg/transport/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ func (*Factory) Create(config types.Config, opts ...Option) (types.Transport, er
)
httpTransport.sessionStorage = config.SessionStorage
httpTransport.sessionTTL = config.SessionTTL
httpTransport.corsOrigins = config.AllowedCORSOrigins
tr = httpTransport
case types.TransportTypeStreamableHTTP:
httpTransport := NewHTTPTransport(
Expand All @@ -101,6 +102,7 @@ func (*Factory) Create(config types.Config, opts ...Option) (types.Transport, er
)
httpTransport.sessionStorage = config.SessionStorage
httpTransport.sessionTTL = config.SessionTTL
httpTransport.corsOrigins = config.AllowedCORSOrigins
tr = httpTransport
case types.TransportTypeInspector:
// HTTP transport is not implemented yet
Expand Down
7 changes: 7 additions & 0 deletions pkg/transport/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ type HTTPTransport struct {
// underlying proxy. Zero uses the proxy's default.
sessionTTL time.Duration

// corsOrigins is the explicit Origin allowlist that enables CORS on the
// transparent MCP proxy. Empty (default) leaves CORS disabled.
corsOrigins []string

// Transparent proxy
proxy types.Proxy

Expand Down Expand Up @@ -443,6 +447,9 @@ func (t *HTTPTransport) buildProxyOptions(remoteBasePath, remoteRawQuery string)
if t.sessionStorage != nil {
opts = append(opts, transparent.WithSessionStorage(t.sessionStorage))
}
if len(t.corsOrigins) > 0 {
opts = append(opts, transparent.WithAllowedOrigins(t.corsOrigins))
}
return opts
}

Expand Down
135 changes: 135 additions & 0 deletions pkg/transport/middleware/cors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package middleware

import (
"fmt"
"log/slog"
"net/http"
"slices"
"strings"

"github.com/stacklok/toolhive/pkg/transport/middleware/origin"
"github.com/stacklok/toolhive/pkg/transport/types"
)

const (
// corsAllowedHeaders lists request headers MCP clients may send. The
// CORS-unsafelisted MCP-Protocol-Version must be allow-listed: ToolHive
// reads and validates it on the request path, so a browser MCP client
// cannot send it through CORS unless it is listed here. Last-Event-ID is
// needed for SSE stream resumption (also not CORS-safelisted).
corsAllowedHeaders = "Content-Type, Accept, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID"

// corsExposedHeaders lists response headers that browsers may read.
// MCP-Protocol-Version is exposed so a browser client can read the
// negotiated protocol version back.
corsExposedHeaders = "Mcp-Session-Id, MCP-Protocol-Version"

// corsMaxAge is the preflight cache lifetime in seconds (24 hours).
corsMaxAge = "86400"
)

// CORS returns a middleware that handles CORS preflight (OPTIONS) requests and
// injects Access-Control-Allow-* response headers on responses whose Origin
// header matches an allowed entry. When allowedOrigins is empty the middleware
// is a no-op, preserving the default security posture.
//
// Origin matching is exact and uses the same canonicalization as the origin
// middleware (origin.CanonicalizeOrigin): scheme and host are lowercased per
// RFC 6454 §4, and there is no wildcard and no scheme+host prefix matching.
// The allowlist is canonicalized once at construction; the request Origin is
// canonicalized per request. The Access-Control-Allow-Origin value echoes the
// matched canonicalized origin, which is always what the browser compares
// against (browsers serialize Origin with a lowercase scheme+host).
//
// All OPTIONS requests are handled directly (returning 204) when this
// middleware is active so that CORS preflights never reach the backend, which
// previously returned 405 Method Not Allowed. An unmatched origin gets 204
// without CORS headers — the browser will reject the follow-up request, which
// is the correct fail-closed outcome.
//
// Preflight is intentionally unauthenticated: it runs outermost (before the
// method gate, auth middleware, and the backend) and its response leaks only
// static allowlist headers.
//
// allowedMethods is the value advertised in Access-Control-Allow-Methods. It
// should reflect the methods the backend actually accepts so a preflight never
// succeeds for a method the real request would reject.
func CORS(allowedOrigins []string, allowedMethods string) types.MiddlewareFunction {
origins := slices.Clone(allowedOrigins)
origins = slices.DeleteFunc(origins, func(o string) bool { return strings.TrimSpace(o) == "" })
if len(origins) == 0 {
return func(next http.Handler) http.Handler { return next }
}

allowedSet := make(map[string]struct{}, len(origins))
for _, o := range origins {
allowedSet[origin.CanonicalizeOrigin(o)] = struct{}{}
}
slog.Debug("CORS middleware configured",
"allowed_origin_count", len(allowedSet), "allowed_methods", allowedMethods)

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var matched string
if rawOrigin := r.Header.Get("Origin"); rawOrigin != "" {
canonical := origin.CanonicalizeOrigin(rawOrigin)
if _, ok := allowedSet[canonical]; ok {
matched = canonical
}
}

if matched != "" {
h := w.Header()
h.Set("Access-Control-Allow-Origin", matched)
h.Set("Access-Control-Allow-Methods", allowedMethods)
h.Set("Access-Control-Allow-Headers", corsAllowedHeaders)
h.Set("Access-Control-Expose-Headers", corsExposedHeaders)
h.Add("Vary", "Origin")
}

if r.Method == http.MethodOptions {
if matched != "" {
w.Header().Set("Access-Control-Max-Age", corsMaxAge)
}
w.WriteHeader(http.StatusNoContent)
return
}

next.ServeHTTP(w, r)
})
}
}

// ValidateAllowedOrigins validates configured CORS origins and returns a
// canonicalized copy. It surfaces misconfiguration at startup instead of
// letting an origin silently never match (which produces a broken browser
// experience with no signal):
//
// - Empty entries (after trimming whitespace) are dropped.
// - Entries that cannot parse as a scheme://host[:port] origin — e.g. a
// missing scheme ("localhost:6274"), a wildcard ("*"), or garbage — are
// rejected with an error.
// - A trailing slash (e.g. "http://localhost:6274/") is normalized away, as
// a browser Origin header never carries one.
//
// Canonicalization matches origin.CanonicalizeOrigin, so the returned entries
// behave identically in both the origin validator and the CORS middleware.
func ValidateAllowedOrigins(origins []string) ([]string, error) {
validated := make([]string, 0, len(origins))
for _, raw := range origins {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
canonical := origin.CanonicalizeOrigin(entry)
if strings.HasPrefix(canonical, "\x00") {
return nil, fmt.Errorf(
"invalid CORS origin %q: must be a scheme://host[:port] URL (e.g. %q)", raw, "http://localhost:6274")
}
validated = append(validated, canonical)
}
return validated, nil
}
Loading