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
33 changes: 33 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,39 @@ See [Docker Recovery Documentation](docker-recovery-phase3.md) for complete deta
- Locale: all `LC_*` variables (e.g., `LC_ALL`, `LC_CTYPE`, …)
- Custom additions: `custom_vars` merged on top

> **Proxy variables are never inherited by default.** `HTTP_PROXY`, `HTTPS_PROXY`,
> `NO_PROXY`, `ALL_PROXY`, and `FTP_PROXY` are deliberately excluded from the
> default allow-list because proxy URLs commonly embed credentials
> (`http://user:pass@proxy`). To forward them to upstream stdio servers, opt in
> with `forward_proxy_env` (see below).

### Proxy Environment Forwarding

```json
{
"forward_proxy_env": true
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `forward_proxy_env` | boolean | `false` | Forward ambient proxy environment variables to spawned stdio upstream servers, with credentials redacted |

When `forward_proxy_env` is `true`, mcpproxy forwards the proxy variables present
in its own environment (`HTTP_PROXY`/`http_proxy`, `HTTPS_PROXY`/`https_proxy`,
`NO_PROXY`/`no_proxy`, `ALL_PROXY`/`all_proxy`, `FTP_PROXY`/`ftp_proxy` — both
spellings are recognized) to each spawned stdio upstream. Any userinfo
(`user:password@`) is **stripped** from the value before forwarding, so
credentials never reach upstream servers while the proxy host/port is preserved.
An explicitly configured proxy value (via `custom_vars` or a server's `env`)
always takes precedence and suppresses forwarding of the ambient value, including
the other-cased alias.

> **macOS GUI/launchd note:** when launched from the Dock/Launchpad or the login
> item, mcpproxy inherits a minimal environment that may not contain your proxy
> variables. In that case set the proxy explicitly under `custom_vars` or a
> server's `env` block.

---

## Routing Mode
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ type Config struct {
// Environment configuration for secure variable filtering
Environment *secureenv.EnvConfig `json:"environment,omitempty" mapstructure:"environment"`

// ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy
// environment variables to spawned stdio upstream servers (MCP-2769). OFF by
// default: proxy URLs commonly embed credentials (http://user:pass@proxy), so
// forwarding them to every upstream is a credential-leak risk. When enabled,
// values are forwarded with their userinfo (credentials) redacted.
ForwardProxyEnv bool `json:"forward_proxy_env,omitempty" mapstructure:"forward-proxy-env"`

// Logging configuration
Logging *LogConfig `json:"logging,omitempty" mapstructure:"logging"`

Expand Down
92 changes: 92 additions & 0 deletions internal/secureenv/manager.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package secureenv

import (
"net/url"
"os"
"runtime"
"strings"
Expand All @@ -24,6 +25,13 @@ type EnvConfig struct {
AllowedSystemVars []string `json:"allowed_system_vars"`
CustomVars map[string]string `json:"custom_vars"`
EnhancePath bool `json:"enhance_path"` // Enable PATH enhancement for Launchd scenarios
// ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy
// environment variables to spawned upstream servers (MCP-2769). It is OFF by
// default and deliberately kept out of the AllowedSystemVars default list:
// proxy URLs frequently carry credentials (http://user:pass@proxy), so
// forwarding them to every stdio upstream is a credential-leak risk. When
// enabled, values are forwarded with their userinfo (credentials) redacted.
ForwardProxyEnv bool `json:"forward_proxy_env,omitempty"`
}

// PathDiscovery contains auto-discovered paths for common tools
Expand Down Expand Up @@ -272,9 +280,93 @@ func (m *Manager) BuildSecureEnvironment() []string {
envVars = m.ensureComprehensivePath(envVars)
}

// Forward ambient proxy variables only when explicitly opted in (MCP-2769),
// with credentials redacted. Done last so an explicitly-configured proxy
// value (custom/server env) always wins over the ambient one.
if m.config.ForwardProxyEnv {
envVars = appendForwardedProxyEnv(envVars)
}

return envVars
}

// proxyEnvGroups lists the proxy environment variables that ForwardProxyEnv
// forwards, grouped by logical variable. HTTP clients treat the upper- and
// lower-case spellings as aliases, so each group is handled atomically: if
// either spelling is already set (via custom/server env), neither is forwarded
// from the ambient environment.
var proxyEnvGroups = [][]string{
{"HTTP_PROXY", "http_proxy"},
{"HTTPS_PROXY", "https_proxy"},
{"ALL_PROXY", "all_proxy"},
{"NO_PROXY", "no_proxy"},
{"FTP_PROXY", "ftp_proxy"},
}

// appendForwardedProxyEnv appends the ambient proxy variables to envVars with
// credentials redacted. An already-present spelling (case-insensitive within a
// group) suppresses forwarding of the whole group so explicit configuration is
// never overridden.
func appendForwardedProxyEnv(envVars []string) []string {
present := make(map[string]struct{}, len(envVars))
for _, ev := range envVars {
if i := strings.IndexByte(ev, '='); i > 0 {
present[ev[:i]] = struct{}{}
}
}

for _, group := range proxyEnvGroups {
if anyProxyKeyPresent(present, group) {
continue
}
for _, key := range group {
if val, ok := os.LookupEnv(key); ok && val != "" {
envVars = append(envVars, key+"="+redactProxyCredentials(val))
}
}
}
return envVars
}

func anyProxyKeyPresent(present map[string]struct{}, group []string) bool {
for _, key := range group {
if _, ok := present[key]; ok {
return true
}
}
return false
}

// redactProxyCredentials strips any userinfo (user:password) from a proxy URL
// so credentials are never forwarded to upstream servers, while preserving the
// proxy host/port so the proxy remains functional. Non-URL values (e.g. the
// host list in NO_PROXY) and unparseable values are returned unchanged.
func redactProxyCredentials(value string) string {
if value == "" {
return value
}
// Scheme-qualified values (http://user:pass@host) parse with Userinfo set.
if u, err := url.Parse(value); err == nil && u.User != nil {
u.User = nil
return u.String()
}
// Schemeless values (user:pass@host:8080) are misread by url.Parse — it
// treats "user" as the scheme, leaving Userinfo nil, so the value would be
// forwarded with credentials intact. Re-parse with a dummy scheme so the
// userinfo is recognized as part of the authority, then strip the scheme we
// added. The "://" guard avoids touching already-schemed values, and
// requiring an "@" keeps non-URL values (e.g. NO_PROXY host lists) untouched.
// An "@" that lives in a path rather than the authority (e.g. host/path@x)
// leaves Userinfo nil under url.Parse, so it is correctly left intact.
if !strings.Contains(value, "://") && strings.Contains(value, "@") {
if u, err := url.Parse("http://" + value); err == nil && u.User != nil {
u.User = nil
return strings.TrimPrefix(u.String(), "http://")
}
}
return value
}

// ensureComprehensivePath ensures PATH includes all discovered tool paths
func (m *Manager) ensureComprehensivePath(envVars []string) []string {
// Find existing PATH in environment
Expand Down
124 changes: 124 additions & 0 deletions internal/secureenv/proxy_forward_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package secureenv

import (
"strings"
"testing"
)

// envValue returns the value of key in a KEY=VALUE slice, and whether it was present.
func envValue(env []string, key string) (string, bool) {
prefix := key + "="
for _, e := range env {
if strings.HasPrefix(e, prefix) {
return strings.TrimPrefix(e, prefix), true
}
}
return "", false
}

func TestRedactProxyCredentials(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"no userinfo http", "http://proxy.example.com:8080", "http://proxy.example.com:8080"},
{"user and pass", "http://user:pass@proxy.example.com:8080", "http://proxy.example.com:8080"},
{"user only", "https://user@proxy.example.com:3128", "https://proxy.example.com:3128"},
{"socks scheme", "socks5://user:secret@127.0.0.1:1080", "socks5://127.0.0.1:1080"},
{"no scheme host list", "localhost,127.0.0.1,.internal", "localhost,127.0.0.1,.internal"},
{"empty", "", ""},
{"unparseable kept", "://bad url with spaces", "://bad url with spaces"},
// Schemeless proxy values: url.Parse misreads "user" as the scheme and
// leaves Userinfo nil, so naive redaction would leak the credentials
// (Codex PR #704 review). Must still be stripped.
{"schemeless user and pass", "user:pass@proxy.example.com:8080", "proxy.example.com:8080"},
{"schemeless user only", "user@proxy.example.com:3128", "proxy.example.com:3128"},
{"schemeless no userinfo", "proxy.example.com:8080", "proxy.example.com:8080"},
{"schemeless at-in-path not stripped", "proxy.example.com:8080/path@x", "proxy.example.com:8080/path@x"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := redactProxyCredentials(tc.in)
if got != tc.want {
t.Fatalf("redactProxyCredentials(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}

// By default (no opt-in) proxy vars in the process environment MUST NOT be
// forwarded to upstream spawns — this is the credential-leak guard (MCP-2769).
func TestBuildSecureEnvironment_ProxyForwardingDisabledByDefault(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://user:pass@proxy.example.com:8080")
t.Setenv("HTTP_PROXY", "http://user:pass@proxy.example.com:8080")
t.Setenv("NO_PROXY", "localhost")

m := NewManager(&EnvConfig{InheritSystemSafe: true, AllowedSystemVars: DefaultEnvConfig().AllowedSystemVars})
env := m.BuildSecureEnvironment()

for _, key := range []string{"HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY"} {
if _, ok := envValue(env, key); ok {
t.Errorf("proxy var %s leaked to upstream env without opt-in", key)
}
}
}

// When opted in, proxy vars are forwarded with credentials redacted.
func TestBuildSecureEnvironment_ForwardsProxyWhenOptedIn(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://user:pass@proxy.example.com:8080")
t.Setenv("NO_PROXY", "localhost,127.0.0.1")

// InheritSystemSafe:false proves forwarding is an independent explicit opt-in,
// not a side effect of the system-env allow-list.
m := NewManager(&EnvConfig{InheritSystemSafe: false, ForwardProxyEnv: true})
env := m.BuildSecureEnvironment()

got, ok := envValue(env, "HTTPS_PROXY")
if !ok {
t.Fatalf("HTTPS_PROXY not forwarded when opted in; env=%v", env)
}
if want := "http://proxy.example.com:8080"; got != want {
t.Errorf("HTTPS_PROXY = %q, want redacted %q", got, want)
}
if got, ok := envValue(env, "NO_PROXY"); !ok || got != "localhost,127.0.0.1" {
t.Errorf("NO_PROXY = %q (present=%v), want %q unchanged", got, ok, "localhost,127.0.0.1")
}
}

// A schemeless proxy value carrying credentials must not be forwarded verbatim.
func TestBuildSecureEnvironment_ForwardsSchemelessProxyRedacted(t *testing.T) {
t.Setenv("HTTP_PROXY", "user:pass@proxy.example.com:8080")

m := NewManager(&EnvConfig{InheritSystemSafe: false, ForwardProxyEnv: true})
got, ok := envValue(m.BuildSecureEnvironment(), "HTTP_PROXY")
if !ok {
t.Fatal("HTTP_PROXY not forwarded when opted in")
}
if strings.Contains(got, "pass") || strings.Contains(got, "@") {
t.Errorf("schemeless proxy credentials leaked to upstream: %q", got)
}
if want := "proxy.example.com:8080"; got != want {
t.Errorf("HTTP_PROXY = %q, want redacted %q", got, want)
}
}

// Case-alias awareness: an explicitly-configured spelling must win and suppress
// forwarding of the other-cased spelling from the ambient environment.
func TestBuildSecureEnvironment_ProxyExplicitValueWinsOverAlias(t *testing.T) {
t.Setenv("http_proxy", "http://ambient:leak@proxy.example.com:8080")

m := NewManager(&EnvConfig{
InheritSystemSafe: false,
ForwardProxyEnv: true,
CustomVars: map[string]string{"HTTP_PROXY": "http://explicit.example.com:3128"},
})
env := m.BuildSecureEnvironment()

if got, ok := envValue(env, "HTTP_PROXY"); !ok || got != "http://explicit.example.com:3128" {
t.Errorf("HTTP_PROXY = %q (present=%v), want explicit custom value", got, ok)
}
if _, ok := envValue(env, "http_proxy"); ok {
t.Error("lowercase http_proxy alias was forwarded despite explicit HTTP_PROXY being set")
}
}
4 changes: 4 additions & 0 deletions internal/upstream/core/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ func NewClientWithOptions(id string, serverConfig *config.ServerConfig, logger *
// Create a copy of the config to avoid modifying the original
envConfigCopy := *envConfig
envConfigCopy.EnhancePath = true
// MCP-2769: opt-in proxy env forwarding to spawned stdio upstreams.
if globalConfig != nil {
envConfigCopy.ForwardProxyEnv = globalConfig.ForwardProxyEnv
}
envConfig = &envConfigCopy
}

Expand Down
40 changes: 40 additions & 0 deletions internal/upstream/secure_env_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,3 +431,43 @@ func TestWildcardMatching(t *testing.T) {
}
}
}

// TestProxyForwardingWiring verifies that the global ForwardProxyEnv flag is
// threaded through to the per-server secure environment manager (MCP-2769):
// proxy vars only reach a spawned stdio upstream when opted in, and credentials
// are redacted on the way.
func TestProxyForwardingWiring(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://user:pass@proxy.example.com:8080")

serverConfig := &config.ServerConfig{
Name: "test-server",
Command: "echo",
Args: []string{"test"},
Enabled: true,
}
logger := zap.NewNop()

buildEnvMap := func(forward bool) map[string]string {
cfg := config.DefaultConfig()
cfg.ForwardProxyEnv = forward
client, err := managed.NewClient("test-id", serverConfig, logger, nil, cfg, nil, secret.NewResolver())
require.NoError(t, err)
envMap := make(map[string]string)
for _, envVar := range client.GetEnvManager().(*secureenv.Manager).BuildSecureEnvironment() {
if parts := strings.SplitN(envVar, "=", 2); len(parts) == 2 {
envMap[parts[0]] = parts[1]
}
}
return envMap
}

// Disabled (default): proxy must not be forwarded.
if _, ok := buildEnvMap(false)["HTTPS_PROXY"]; ok {
t.Error("HTTPS_PROXY forwarded to upstream without ForwardProxyEnv opt-in")
}

// Enabled: proxy forwarded with credentials redacted.
if got := buildEnvMap(true)["HTTPS_PROXY"]; got != "http://proxy.example.com:8080" {
t.Errorf("HTTPS_PROXY = %q, want redacted http://proxy.example.com:8080", got)
}
}
2 changes: 1 addition & 1 deletion oas/docs.go

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions oas/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ components:
$ref: '#/components/schemas/secureenv.EnvConfig'
features:
$ref: '#/components/schemas/config.FeatureFlags'
forward_proxy_env:
description: |-
ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy
environment variables to spawned stdio upstream servers (MCP-2769). OFF by
default: proxy URLs commonly embed credentials (http://user:pass@proxy), so
forwarding them to every upstream is a credential-leak risk. When enabled,
values are forwarded with their userinfo (credentials) redacted.
type: boolean
health_check_interval:
description: |-
Discovery & health-check cadence (spec 074, #608). Both are *Duration
Expand Down Expand Up @@ -2497,6 +2505,15 @@ components:
enhance_path:
description: Enable PATH enhancement for Launchd scenarios
type: boolean
forward_proxy_env:
description: |-
ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy
environment variables to spawned upstream servers (MCP-2769). It is OFF by
default and deliberately kept out of the AllowedSystemVars default list:
proxy URLs frequently carry credentials (http://user:pass@proxy), so
forwarding them to every stdio upstream is a credential-leak risk. When
enabled, values are forwarded with their userinfo (credentials) redacted.
type: boolean
inherit_system_safe:
type: boolean
type: object
Expand Down
Loading