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
50 changes: 50 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ See [OAuth Documentation](mcp-go-oauth.md) for complete details.
```json
{
"api_key": "your-secret-api-key",
"trusted_hosts": ["mcp.example.com"],
"read_only_mode": false,
"disable_management": false,
"allow_server_add": true,
Expand All @@ -388,6 +389,7 @@ See [OAuth Documentation](mcp-go-oauth.md) for complete details.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `api_key` | string | Auto-generated | API key for REST API authentication. Required; if empty, one is auto-generated and enforced (logged on startup) |
| `trusted_hosts` | string[] | `[]` | Non-loopback `Host` header values accepted on loopback listeners (reverse-proxy deployments). See below |
| `read_only_mode` | boolean | `false` | Prevent all configuration modifications |
| `disable_management` | boolean | `false` | Disable server management operations (restart, enable, disable) |
| `allow_server_add` | boolean | `true` | Allow adding new servers via API/tools |
Expand All @@ -399,6 +401,54 @@ See [OAuth Documentation](mcp-go-oauth.md) for complete details.
- **Auto-Generation**: If no API key is provided, one is generated and logged for easy access
- **Tray Integration**: Tray app automatically manages API keys for core communication

### Reverse Proxy Deployments (`trusted_hosts`)

When mcpproxy listens on a loopback address (the default `127.0.0.1:8080`), DNS-rebinding
protection rejects any request whose `Host` header is not itself a loopback address with
`403 Forbidden: invalid Host header`. This blocks malicious websites from rebinding their
domain to `127.0.0.1` and driving a victim's browser into the local MCP server — but it
also blocks legitimate reverse proxies (nginx, Caddy, CloudPanel) that forward the public
domain in the `Host` header.

Add the public domain(s) to `trusted_hosts` to allow them:

```json
{
"listen": "127.0.0.1:8004",
"trusted_hosts": ["mcp.example.com"]
}
```

- Entries are hostnames, matched case-insensitively. An entry without a port matches that
host on **any** port; an entry with a port (`"mcp.example.com:8443"`) requires an exact
port match.
- A leading dot makes an entry a subdomain wildcard: `".example.com"` matches
`example.com` and every subdomain of it (Django/Vite convention).
- The single entry `"*"` disables Host and Origin validation entirely. **Not
recommended** — it re-opens DNS-rebinding: any website the local user visits could
drive requests into the proxy.
- A request that carries an `Origin` header must likewise have a loopback or trusted
origin host (MCP spec requirement); requests without `Origin` (non-browser clients,
reverse proxies) are never rejected by the Origin check.
- Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are always accepted; requests on
non-loopback listeners are never subject to Host validation.
- Environment override: `MCPPROXY_TRUSTED_HOSTS` (comma-separated list).
- Hot-reloadable: editing the config file applies without a restart.

With `trusted_hosts` configured, a standard nginx block works without overriding `Host`:

```nginx
location / {
proxy_pass http://127.0.0.1:8004;
proxy_set_header Host $host;
proxy_buffering off;
}
```

See [Reverse Proxy Deployment](operations/reverse-proxy.md) for a full guide covering
nginx and Caddy examples, streaming/SSE buffering, and enabling `require_mcp_auth` when
exposing MCPProxy beyond localhost.

### Security Scanner (`security`)

The deterministic, offline `tpa-descriptions` baseline scanner always runs and is
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ MCPProxy uses a JSON configuration file located at `~/.mcpproxy/mcp_config.json`
| `listen` | string | `127.0.0.1:8080` | Address and port to listen on |
| `data_dir` | string | `~/.mcpproxy` | Directory for data storage |
| `api_key` | string | auto-generated | API key for REST API authentication |
| `trusted_hosts` | string[] | `[]` | Non-loopback `Host` header values accepted on a loopback listener. Needed when running behind a reverse proxy — see [Reverse Proxy Deployment](/operations/reverse-proxy) |
| `require_mcp_auth` | boolean | `false` | Require an API key on the `/mcp` endpoint (off by default for client compatibility). Enable when exposing MCPProxy beyond localhost |
| `enable_socket` | boolean | `true` | Enable Unix socket/named pipe for local communication |

### Feature Flags
Expand Down
1 change: 1 addition & 0 deletions docs/configuration/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Environment variables are useful for CI/CD environments or temporary overrides d

| Variable | Description | Default |
|----------|-------------|---------|
| `MCPPROXY_TRUSTED_HOSTS` | Comma-separated `Host` header allowlist for loopback listeners behind a reverse proxy (see [Reverse Proxy Deployment](/operations/reverse-proxy)) | - |
| `MCPPROXY_TLS_ENABLED` | Enable TLS/HTTPS | `false` |
| `MCPPROXY_TLS_CERT` | Path to TLS certificate | - |
| `MCPPROXY_TLS_KEY` | Path to TLS private key | - |
Expand Down
8 changes: 8 additions & 0 deletions docs/errors/MCPX_HTTP_403.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ The upstream MCP server accepted mcpproxy's credentials but rejected the
request: 403 Forbidden. Unlike 401, the identity is recognised — it just isn't
authorised for what was attempted.

:::note Different 403?
If mcpproxy's **own** HTTP server returns `403 Forbidden: invalid Host header`
(rather than an upstream server), that is DNS-rebinding protection on a loopback
listener, not an authorisation failure. See
[Reverse Proxy Deployment](../operations/reverse-proxy.md) for the `trusted_hosts` fix.
:::

## Common causes

- The OAuth scopes granted at login don't include the scopes required for this
Expand Down Expand Up @@ -55,3 +62,4 @@ mcpproxy activity show <id>

- [`MCPX_HTTP_401`](MCPX_HTTP_401.md) — credentials missing or invalid
- [OAuth Authentication](../features/oauth-authentication.md)
- [Reverse Proxy Deployment](../operations/reverse-proxy.md) — for `403 Forbidden: invalid Host header`
139 changes: 139 additions & 0 deletions docs/operations/reverse-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
id: reverse-proxy
title: Reverse Proxy Deployment
sidebar_label: Reverse Proxy
description: 'Run MCPProxy behind nginx or Caddy — fix "403 Forbidden: invalid Host header" with trusted_hosts, and secure the exposed endpoint.'
keywords: [reverse proxy, nginx, caddy, trusted_hosts, invalid Host header, DNS rebinding, 403 Forbidden, Host header]
---

# Reverse Proxy Deployment

MCPProxy listens on a loopback address by default (`127.0.0.1:8080`) and is designed
to run locally. You can still put it behind a reverse proxy (nginx, Caddy, Traefik,
CloudPanel) to add HTTPS, a public hostname, or shared access — but two things need
attention: **Host-header validation** and **authentication**.

## The `403 Forbidden: invalid Host header` error

When MCPProxy listens on a loopback address, it rejects any request whose `Host`
header is **not itself a loopback address**, returning:

```
403 Forbidden: invalid Host header
```

This is **DNS-rebinding protection**. It stops a malicious website from rebinding its
own domain to `127.0.0.1` and driving a victim's browser into the local MCP server.
The side effect: a reverse proxy that forwards the real public domain in the `Host`
header (e.g. `mcp.example.com`) is rejected the same way.

The fix is to add your public domain(s) to the `trusted_hosts` allowlist.

## `trusted_hosts` configuration

```json
{
"listen": "127.0.0.1:8080",
"trusted_hosts": ["mcp.example.com"]
}
```

| Behaviour | Detail |
|-----------|--------|
| Matching | Hostnames, compared **case-insensitively** |
| Without a port | `"mcp.example.com"` matches that host on **any** port |
| With a port | `"mcp.example.com:8443"` requires an **exact** port match |
| Subdomain wildcard | A leading dot — `".example.com"` — matches `example.com` **and every subdomain** (`mcp.example.com`, `a.b.example.com`), same convention as Django's `ALLOWED_HOSTS` and Vite's `server.allowedHosts` |
| Disable entirely | The single entry `"*"` turns Host **and Origin** validation off. **Not recommended:** it re-opens DNS rebinding — any website the local user visits can then drive requests into the proxy |
| Loopback | `localhost`, `127.0.0.1`, `[::1]` are **always** accepted — no need to list them |
| Non-loopback listeners | If `listen` is already a non-loopback address, Host validation never runs |
| Unix socket | Socket/named-pipe connections are never subject to Host validation |
| Default | Empty (`[]`) = full DNS-rebinding protection |

- **Environment override:** `MCPPROXY_TRUSTED_HOSTS` (comma-separated), e.g.
`MCPPROXY_TRUSTED_HOSTS="mcp.example.com,mcp.internal:8443"`.
- **Hot-reloadable:** editing the config file applies without a restart.

### Origin validation (browser requests)

Per the MCP specification's security best practices, MCPProxy also validates the
`Origin` header on loopback listeners: when a request **carries** an `Origin` and its
host is neither loopback nor in `trusted_hosts`, it is rejected with
`403 Forbidden: invalid Origin header`. Requests **without** an `Origin` header —
every non-browser MCP client, CLI tool, and server-side integration — are unaffected.

Note that reverse proxies forward a browser's `Origin` header as-is. A browser
client served **from your public domain** works as soon as that domain is in
`trusted_hosts` (its `Origin` matches the same entry as its `Host`). A browser
frontend hosted on a **different** origin must have its own host added to
`trusted_hosts` too, or its requests are rejected.

## nginx

With `trusted_hosts` configured, a standard nginx block works without rewriting the
`Host` header:

```nginx
server {
listen 443 ssl;
server_name mcp.example.com;

location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;

# MCPProxy streams responses (SSE at /events, streamable HTTP at /mcp).
# Disable proxy buffering so events are delivered as they are produced.
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
```

## Caddy

Caddy forwards the request `Host` by default, so with `mcp.example.com` in
`trusted_hosts` no extra header handling is needed. Disable response buffering so
streaming works:

```caddy
mcp.example.com {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
```

`flush_interval -1` disables Caddy's response buffering (the equivalent of nginx's
`proxy_buffering off`), which streaming endpoints require.

## Authentication through the proxy

Exposing MCPProxy beyond localhost changes the threat model. Two endpoint families
authenticate differently:

- **REST API (`/api/v1/...`)** — an API key is **always** required. Pass it as the
`X-API-Key` header (recommended) or the `?apikey=` query parameter. The key is
auto-generated and logged on first start if you don't set one.
- **MCP endpoint (`/mcp`)** — **unauthenticated by default** for client
compatibility. When you expose MCPProxy through a reverse proxy, enable
`require_mcp_auth` so `/mcp` also rejects unauthenticated requests:

```json
{
"listen": "127.0.0.1:8080",
"trusted_hosts": ["mcp.example.com"],
"require_mcp_auth": true
}
```

MCP clients then authenticate with the same API key (`X-API-Key` header).

Prefer the `X-API-Key` header over `?apikey=` where the client supports it — query
strings are more likely to be logged by intermediate proxies.

## Related

- [Configuration File](/configuration/config-file) — full option reference
- [Environment Variables](/configuration/environment-variables) — `MCPPROXY_TRUSTED_HOSTS`
- [REST API](/api/rest-api) — authentication details
24 changes: 18 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,24 @@ type Config struct {
Logging *LogConfig `json:"logging,omitempty" mapstructure:"logging"`

// Security settings
APIKey string `json:"api_key,omitempty" mapstructure:"api-key"` // API key for REST API authentication
RequireMCPAuth bool `json:"require_mcp_auth" mapstructure:"require-mcp-auth"` // Require authentication on /mcp endpoint (default: false)
ReadOnlyMode bool `json:"read_only_mode" mapstructure:"read-only-mode"`
DisableManagement bool `json:"disable_management" mapstructure:"disable-management"`
AllowServerAdd bool `json:"allow_server_add" mapstructure:"allow-server-add"`
AllowServerRemove bool `json:"allow_server_remove" mapstructure:"allow-server-remove"`
APIKey string `json:"api_key,omitempty" mapstructure:"api-key"` // API key for REST API authentication
RequireMCPAuth bool `json:"require_mcp_auth" mapstructure:"require-mcp-auth"` // Require authentication on /mcp endpoint (default: false)
// TrustedHosts lists non-loopback Host header values accepted on loopback
// listeners (GH #898). DNS-rebinding protection rejects requests whose Host
// header is not a loopback address when mcpproxy listens on loopback; a
// reverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host
// trips it. Entries are hostnames, case-insensitive; an entry without a
// port matches any port, with a port it must match exactly; a leading dot
// (".example.com") is a subdomain wildcard. The single entry "*" disables
// Host and Origin validation entirely. The same list also validates the
// Origin header when present (MCP spec DNS-rebinding defense). Empty
// (default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS
// (comma-separated).
TrustedHosts []string `json:"trusted_hosts,omitempty" mapstructure:"trusted-hosts"`
ReadOnlyMode bool `json:"read_only_mode" mapstructure:"read-only-mode"`
DisableManagement bool `json:"disable_management" mapstructure:"disable-management"`
AllowServerAdd bool `json:"allow_server_add" mapstructure:"allow-server-add"`
AllowServerRemove bool `json:"allow_server_remove" mapstructure:"allow-server-remove"`

// Internal field to track if API key was explicitly set in config
apiKeyExplicitlySet bool `json:"-"`
Expand Down
33 changes: 33 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1758,3 +1758,36 @@ func TestToolResponseModeEnvOverride(t *testing.T) {
assert.Contains(t, err.Error(), "tool_response_mode")
})
}

// GH #898: trusted_hosts loads from the config file and the
// MCPPROXY_TRUSTED_HOSTS env var (comma-separated) overrides it.
func TestTrustedHostsLoadAndEnvOverride(t *testing.T) {
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "mcp_config.json")
raw, err := json.Marshal(map[string]any{
"listen": "127.0.0.1:0",
"data_dir": tmp,
"trusted_hosts": []string{"mcp.example.com"},
})
require.NoError(t, err)
require.NoError(t, os.WriteFile(cfgPath, raw, 0o600))

t.Run("file value loads", func(t *testing.T) {
cfg, err := LoadFromFile(cfgPath)
require.NoError(t, err)
assert.Equal(t, []string{"mcp.example.com"}, cfg.TrustedHosts)
})

t.Run("env wins over file", func(t *testing.T) {
t.Setenv("MCPPROXY_TRUSTED_HOSTS", "a.example.com, b.example.com:8443 ,")
cfg, err := LoadFromFile(cfgPath)
require.NoError(t, err)
assert.Equal(t, []string{"a.example.com", "b.example.com:8443"}, cfg.TrustedHosts)
})

t.Run("unset by default", func(t *testing.T) {
cfg := &Config{}
require.NoError(t, cfg.Validate())
assert.Empty(t, cfg.TrustedHosts)
})
}
12 changes: 12 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,18 @@ func applyTLSEnvOverrides(cfg *Config) {
cfg.DataDir = value
}

// Override trusted hosts for reverse-proxy deployments (GH #898).
// Comma-separated list of Host header values accepted on loopback listeners.
if value := os.Getenv("MCPPROXY_TRUSTED_HOSTS"); value != "" {
var hosts []string
for _, h := range strings.Split(value, ",") {
if h = strings.TrimSpace(h); h != "" {
hosts = append(hosts, h)
}
}
cfg.TrustedHosts = hosts
}

// Override retrieve_tools serialization mode from environment (Spec 085).
// Explicit MCPPROXY_* alias per the established loader convention; the
// value is validated by cfg.Validate() right after these overrides apply.
Expand Down
9 changes: 9 additions & 0 deletions internal/runtime/config_hotreload.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"reflect"
"slices"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
)
Expand Down Expand Up @@ -168,6 +169,14 @@ func DetectConfigChanges(oldCfg, newCfg *config.Config) *ConfigApplyResult {
if oldCfg.AllowServerRemove != newCfg.AllowServerRemove {
result.ChangedFields = append(result.ChangedFields, "allow_server_remove")
}
// trusted_hosts (GH #898 — hot-reloadable). hostValidationMiddleware reads
// the live snapshot per request, so reporting the change is all the
// propagation needed. slices.Equal, not DeepEqual: the PATCH round-trip
// collapses []string{} to nil (omitempty), which DeepEqual would
// spuriously report as a change.
if !slices.Equal(oldCfg.TrustedHosts, newCfg.TrustedHosts) {
result.ChangedFields = append(result.ChangedFields, "trusted_hosts")
}

// Environment configuration (can be hot-reloaded)
if !reflect.DeepEqual(oldCfg.Environment, newCfg.Environment) {
Expand Down
31 changes: 31 additions & 0 deletions internal/runtime/config_hotreload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -523,3 +523,34 @@ func TestDetectConfigChanges_ToolResponseMode(t *testing.T) {
assert.NotContains(t, result.ChangedFields, "tool_response_mode")
})
}

// GH #898: a lone trusted_hosts edit must be reported as a hot-reloadable
// change — hostValidationMiddleware reads the live snapshot per request, so
// the entry exists to acknowledge the reload instead of logging "no changes
// detected". nil vs []string{} (the PATCH round-trip artifact) must not be
// reported as a change.
func TestDetectConfigChanges_TrustedHosts(t *testing.T) {
mk := func(hosts []string) *config.Config {
return &config.Config{
Listen: "127.0.0.1:8080", DataDir: "/d", TLS: &config.TLSConfig{},
TrustedHosts: hosts,
}
}

t.Run("trusted_hosts change detected", func(t *testing.T) {
result := DetectConfigChanges(mk(nil), mk([]string{"mcp.example.com"}))
require.True(t, result.Success)
assert.Contains(t, result.ChangedFields, "trusted_hosts")
assert.False(t, result.RequiresRestart, "trusted_hosts is hot-reloadable")
})

t.Run("nil vs empty slice not reported", func(t *testing.T) {
result := DetectConfigChanges(mk(nil), mk([]string{}))
assert.NotContains(t, result.ChangedFields, "trusted_hosts")
})

t.Run("unchanged list not reported", func(t *testing.T) {
result := DetectConfigChanges(mk([]string{"a.example.com"}), mk([]string{"a.example.com"}))
assert.NotContains(t, result.ChangedFields, "trusted_hosts")
})
}
Loading
Loading