Skip to content

Commit 887b39c

Browse files
authored
Block Docker gateway addresses in egress proxy by default (#4395)
## Why Containerized MCP servers can reach host services via `host.docker.internal`, `gateway.docker.internal`, and the Docker bridge gateway IP (`172.17.0.1`). This enables lateral movement from a compromised or malicious MCP server to services running on the host, bypassing the container network boundary. The existing `insecure_allow_all` permission flag does not protect against this: users enabling it intend to open general internet access, not necessarily host access. These are distinct threat surfaces and warrant separate opt-ins. ## What changed The Squid egress proxy config now emits ACL deny rules for the three Docker gateway addresses **before** any allow rules. Squid evaluates access control in first-match-wins order, so placing the deny first ensures it cannot be bypassed by a subsequent `http_access allow all`. A new `--allow-docker-gateway` CLI flag (default `false`) provides an explicit opt-in for the small number of MCP servers that legitimately need host access. The flag threads through the full call chain: ``` --allow-docker-gateway (run_flags.go) → RunConfig.AllowDockerGateway (config.go) → runtime.Setup() (setup.go) → DeployWorkloadOptions.AllowDockerGateway (types.go) → createEgressSquidContainer() (client.go) → createTempEgressSquidConf() (squid.go) ``` Generated Squid config with default settings (blocking active): ```squid acl docker_gateway_hosts dstdomain host.docker.internal gateway.docker.internal acl docker_gateway_ip dst 172.17.0.1 http_access deny docker_gateway_hosts http_access deny docker_gateway_ip http_access allow all # (or ACL-based allow rules) http_access deny all ```
1 parent e62f833 commit 887b39c

14 files changed

Lines changed: 294 additions & 10 deletions

File tree

cmd/thv/app/run_flags.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ type RunFlags struct {
9191
OtelUseLegacyAttributes bool // Emit legacy attribute names alongside new ones
9292

9393
// Network isolation
94-
IsolateNetwork bool
94+
IsolateNetwork bool
95+
AllowDockerGateway bool
9596

9697
// Proxy headers
9798
TrustProxyHeaders bool
@@ -246,6 +247,9 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) {
246247

247248
cmd.Flags().BoolVar(&config.IsolateNetwork, "isolate-network", false,
248249
"Isolate the container network from the host (default false)")
250+
cmd.Flags().BoolVar(&config.AllowDockerGateway, "allow-docker-gateway", false,
251+
"Allow outbound connections to Docker gateway addresses (host.docker.internal, gateway.docker.internal, 172.17.0.1). "+
252+
"Only applies when --isolate-network is set. These are blocked by default even when insecure_allow_all is enabled.")
249253
cmd.Flags().BoolVar(&config.TrustProxyHeaders, "trust-proxy-headers", false,
250254
"Trust X-Forwarded-* headers from reverse proxies (X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Prefix) "+
251255
"(default false)")
@@ -596,6 +600,7 @@ func buildRunnerConfig(
596600
runner.WithAuditConfigPath(runFlags.AuditConfig),
597601
runner.WithPermissionProfileNameOrPath(runFlags.PermissionProfile),
598602
runner.WithNetworkIsolation(runFlags.IsolateNetwork),
603+
runner.WithAllowDockerGateway(runFlags.AllowDockerGateway),
599604
runner.WithTrustProxyHeaders(runFlags.TrustProxyHeaders),
600605
runner.WithEndpointPrefix(runFlags.EndpointPrefix),
601606
runner.WithNetworkMode(runFlags.Network),

docs/cli/thv_run.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/docs.go

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.yaml

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/container/docker/client.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ type deployOps interface {
9898
exposedPorts map[string]struct{},
9999
endpointsConfig map[string]*network.EndpointSettings,
100100
perm *permissions.NetworkPermissions,
101+
allowDockerGateway bool,
101102
) (string, error)
102103
createMcpContainer(
103104
ctx context.Context,
@@ -165,8 +166,14 @@ func (c *Client) createEgressSquidContainer(
165166
exposedPorts map[string]struct{},
166167
endpointsConfig map[string]*network.EndpointSettings,
167168
perm *permissions.NetworkPermissions,
169+
allowDockerGateway bool,
168170
) (string, error) {
169-
return createEgressSquidContainer(ctx, c, containerName, squidContainerName, attachStdio, exposedPorts, endpointsConfig, perm)
171+
gatewayIP := c.getDockerBridgeGatewayIP(ctx)
172+
return createEgressSquidContainer(
173+
ctx, c, containerName, squidContainerName,
174+
attachStdio, exposedPorts, endpointsConfig, perm,
175+
allowDockerGateway, gatewayIP,
176+
)
170177
}
171178

172179
// DeployWorkload creates and starts a workload.
@@ -243,6 +250,7 @@ func (c *Client) DeployWorkload(
243250

244251
// create egress container
245252
egressContainerName := fmt.Sprintf("%s-egress", name)
253+
allowDockerGateway := options != nil && options.AllowDockerGateway
246254
_, err = c.ops.createEgressSquidContainer(
247255
ctx,
248256
name,
@@ -251,6 +259,7 @@ func (c *Client) DeployWorkload(
251259
nil,
252260
externalEndpointsConfig,
253261
permissionProfile.Network,
262+
allowDockerGateway,
254263
)
255264
if err != nil {
256265
return 0, fmt.Errorf("failed to create egress container: %w", err)
@@ -1167,6 +1176,27 @@ func (c *Client) createNetwork(
11671176
return nil
11681177
}
11691178

1179+
// getDockerBridgeGatewayIP returns the gateway IP of the Docker default bridge
1180+
// network by inspecting it at runtime. This handles platform differences:
1181+
// Linux Docker Engine uses 172.17.0.1 by default, while Docker Desktop on macOS
1182+
// uses 192.168.65.1 and Colima typically uses 192.168.5.1 or similar. Querying
1183+
// the daemon directly is more accurate than hardcoding platform-specific IPs.
1184+
// Falls back to dockerDefaultBridgeGatewayIP on any error.
1185+
func (c *Client) getDockerBridgeGatewayIP(ctx context.Context) string {
1186+
nr, err := c.client.NetworkInspect(ctx, "bridge", network.InspectOptions{})
1187+
if err != nil {
1188+
slog.Debug("failed to inspect bridge network, using default gateway IP", "error", err)
1189+
return dockerDefaultBridgeGatewayIP
1190+
}
1191+
for _, cfg := range nr.IPAM.Config {
1192+
if cfg.Gateway != "" {
1193+
return cfg.Gateway
1194+
}
1195+
}
1196+
slog.Debug("bridge network has no gateway in IPAM config, using default gateway IP")
1197+
return dockerDefaultBridgeGatewayIP
1198+
}
1199+
11701200
// DeleteNetwork deletes a network by name.
11711201
func (c *Client) deleteNetwork(ctx context.Context, name string) error {
11721202
// find the network by name using filter for efficiency but verify exact match

pkg/container/docker/client_deploy_test.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ type fakeDeployOps struct {
3131
dnsID string
3232
dnsIP string
3333

34-
egressCalled bool
35-
egressID string
34+
egressCalled bool
35+
egressID string
36+
egressAllowDockerGW bool
3637

3738
ingressCalled bool
3839
ingressPort int
@@ -79,8 +80,9 @@ func (f *fakeDeployOps) createDnsContainer(_ context.Context, _ string, _ bool,
7980
return f.dnsID, f.dnsIP, f.errDNS
8081
}
8182

82-
func (f *fakeDeployOps) createEgressSquidContainer(_ context.Context, _ string, _ string, _ bool, _ map[string]struct{}, _ map[string]*network.EndpointSettings, _ *permissions.NetworkPermissions) (string, error) {
83+
func (f *fakeDeployOps) createEgressSquidContainer(_ context.Context, _ string, _ string, _ bool, _ map[string]struct{}, _ map[string]*network.EndpointSettings, _ *permissions.NetworkPermissions, allowDockerGateway bool) (string, error) {
8384
f.egressCalled = true
85+
f.egressAllowDockerGW = allowDockerGateway
8486
return f.egressID, f.errEgress
8587
}
8688

@@ -282,6 +284,34 @@ func TestDeployWorkload_NoIsolation_ReturnsPortFromBindingsAndSkipsAuxContainers
282284
assert.Equal(t, 56789, hostPort)
283285
}
284286

287+
func TestDeployWorkload_AllowDockerGateway_ForwardedToEgress(t *testing.T) {
288+
t.Parallel()
289+
290+
fops := &fakeDeployOps{dnsIP: "172.18.0.10"}
291+
c := newClientWithOps(fops)
292+
293+
opts := runtime.NewDeployWorkloadOptions()
294+
opts.AttachStdio = true
295+
opts.AllowDockerGateway = true
296+
297+
_, err := c.DeployWorkload(
298+
t.Context(),
299+
"ghcr.io/example/mcp:latest",
300+
"app",
301+
[]string{"serve"},
302+
map[string]string{},
303+
map[string]string{},
304+
&permissions.Profile{},
305+
"stdio",
306+
opts,
307+
true, // isolateNetwork required for egress container to be created
308+
)
309+
require.NoError(t, err)
310+
311+
require.True(t, fops.egressCalled, "egress container must be created when isolateNetwork=true")
312+
assert.True(t, fops.egressAllowDockerGW, "AllowDockerGateway must be forwarded to createEgressSquidContainer")
313+
}
314+
285315
func TestDeployWorkload_UnsupportedTransport_PropagatesError(t *testing.T) {
286316
t.Parallel()
287317

pkg/container/docker/squid.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ import (
2121

2222
const defaultSquidImage = "ghcr.io/stacklok/toolhive/egress-proxy:latest"
2323

24+
// dockerGateway* are Docker-specific addresses that resolve to the host network
25+
// interface from inside a container. They are blocked by default to prevent
26+
// containers from reaching host services unintentionally.
27+
const (
28+
dockerGatewayHostname = "host.docker.internal"
29+
dockerAltGatewayHostname = "gateway.docker.internal"
30+
dockerDefaultBridgeGatewayIP = "172.17.0.1"
31+
)
32+
2433
type proxyDirection int
2534

2635
const (
@@ -69,8 +78,10 @@ func createEgressSquidContainer(
6978
exposedPorts map[string]struct{},
7079
endpointsConfig map[string]*network.EndpointSettings,
7180
perm *permissions.NetworkPermissions,
81+
allowDockerGateway bool,
82+
gatewayIP string,
7283
) (string, error) {
73-
squidConfPath, err := createTempEgressSquidConf(perm, containerName)
84+
squidConfPath, err := createTempEgressSquidConf(perm, containerName, allowDockerGateway, gatewayIP)
7485
if err != nil {
7586
return "", fmt.Errorf("failed to create temporary squid.conf: %w", err)
7687
}
@@ -173,14 +184,46 @@ func createSquidContainer(
173184
return squidContainerId, nil
174185
}
175186

187+
// writeDockerGatewayDenyRules emits Squid ACL definitions and http_access deny
188+
// rules that block the Docker gateway addresses. These rules MUST be written
189+
// before any http_access allow rules: Squid evaluates access control in
190+
// first-match-wins order, so a deny placed after an allow is never reached.
191+
//
192+
// gatewayIP is the bridge network gateway IP resolved at runtime via
193+
// getDockerBridgeGatewayIP. It differs across platforms: 172.17.0.1 on Linux,
194+
// 192.168.65.1 on Docker Desktop for macOS, and varies on Colima/Rancher Desktop.
195+
// dockerGatewayHostname and dockerAltGatewayHostname cover hostname-based access;
196+
// the dst rule covers direct-IP access that bypasses DNS.
197+
// Note: gateway.docker.internal is Docker Desktop (macOS) specific; blocking it
198+
// on Linux is harmless since the name does not resolve there.
199+
func writeDockerGatewayDenyRules(sb *strings.Builder, gatewayIP string) {
200+
sb.WriteString(
201+
"# Block Docker gateway addresses — opt in with --allow-docker-gateway\n" +
202+
"acl docker_gateway_hosts dstdomain " +
203+
dockerGatewayHostname + " " + dockerAltGatewayHostname + "\n" +
204+
"acl docker_gateway_ip dst " + gatewayIP + "\n" +
205+
"http_access deny docker_gateway_hosts\n" +
206+
"http_access deny docker_gateway_ip\n\n",
207+
)
208+
}
209+
176210
func createTempEgressSquidConf(
177211
networkPermissions *permissions.NetworkPermissions,
178212
serverHostname string,
213+
allowDockerGateway bool,
214+
gatewayIP string,
179215
) (string, error) {
180216
var sb strings.Builder
181217

182218
writeCommonConfig(&sb, serverHostname, proxyEgress)
183219

220+
// Always block Docker gateway addresses unless the caller explicitly opts
221+
// in via --allow-docker-gateway. MUST precede any http_access allow —
222+
// Squid is first-match-wins.
223+
if !allowDockerGateway {
224+
writeDockerGatewayDenyRules(&sb, gatewayIP)
225+
}
226+
184227
if networkPermissions == nil || (networkPermissions.Outbound != nil && networkPermissions.Outbound.InsecureAllowAll) {
185228
sb.WriteString("# Allow all traffic\nhttp_access allow all\n")
186229
} else {

0 commit comments

Comments
 (0)