Skip to main content

Egress Proxy & Credential Injection

The egress proxy is where Gig'MCP's core promise is enforced: the real API key never enters the sandbox. A Tier 1 ("sealed") server holds only a high-entropy placeholder; when it makes an HTTPS call to an allowlisted domain, the proxy — embedded in the gateway binary, on the trusted side of the boundary — swaps the placeholder for the vault-decrypted real key.

Source: internal/proxy (proxy, runtime CA, IP→identity registry) and internal/netmgr (host-side veth provisioning).

Why a MITM proxy

To rewrite an Authorization header inside an HTTPS request, the proxy has to terminate TLS. The proxy is a hand-rolled net/http CONNECT MITM (a spike found martian/goproxy unmaintained; the stdlib is simpler and a cleaner hook for source-IP lookup and header rewrite):

  • A runtime ECDSA P-256 CA is generated fresh in memory at gateway startup (gigmcp egress CA, 10-year validity).
  • Per-host leaf certificates are minted on demand and cached under a mutex (1-year validity; IP-address SANs for bare IPs, DNS SANs for hostnames).
  • The CA certificate is injected into every sandbox at /etc/gigmcp-ca.pem and pointed to by SSL_CERT_FILE and NODE_EXTRA_CA_CERTS, so well-behaved TLS stacks trust the proxy's leaves.

Route isolation, not environment variables

HTTPS_PROXY is set inside the sandbox as a convenience only. Environment-level proxying is trivially bypassable (a malicious server just ignores the variable), and was proven insufficient during development. The actual enforcement is the network topology:

  • Each sandbox gets a dedicated /30 subnet: host/proxy side at base+4k+1, sandbox side at base+4k+2, connected by a veth pair (vh<k>/vs<k>).
  • The sandbox's network namespace contains exactly one interface plus loopback, and exactly one route: default via <proxy-IP>.
  • Whatever the server does — raw sockets, custom DNS, ignoring HTTPS_PROXY — every packet that leaves the namespace lands on the proxy's side of the veth.
  • Belt-and-suspenders: at startup the gateway sets the iptables FORWARD policy to DROP, so even if kernel forwarding is on, packets are not routed past the gateway. This is best-effort; route isolation stands alone if it fails.

All of this is provisioned with CAP_NET_ADMIN only — no SYS_ADMIN, no privileged container.

Source-IP identity binding

The proxy identifies who is calling from the TCP connection itself, not from anything the sandbox sends:

  1. At spawn, the gateway allocates the /30, injects the veth, and calls Registry.Bind(sandboxIP, Identity{Server, Tenant}) — recording, e.g., 10.88.0.2 → (slack-mcp, profile 7).
  2. When a connection arrives, the proxy takes the source IP from the accepted connection's RemoteAddr and looks it up. Unknown source IPs get 403 unknown source.
  3. At reap, the mapping is unbound and the subnet returned to the pool.

This is unforgeable from inside the sandbox: the netns can only source addresses from its own /30 — the gateway assigned the address and there is no way to spoof a different sandbox's IP onto the wire. Identity binding is network-level, so it costs nothing per request, and the per-request audit log falls out of the same lookup.

Request lifecycle

The proxy accepts only CONNECT (anything else is 405):

CONNECT api.github.com:443 ← from sandbox 10.88.0.2
1. source IP → Identity{github-mcp, tenant} (registry lookup; unknown → 403)
2. resolve (identity, host) → Credential (vault + credential store; none → 403)
3. host vs AllowedHosts (not allowed → 403 + DENY audit line)
4. "200 Connection established", hijack the conn
5. mint/cache TLS leaf for api.github.com, handshake with the sandbox
6. for each HTTP request read off the MITM'd conn:
- placeholder in the configured header? → rewrite with the real key
- forward over a fresh TLS connection to the real upstream
- ALLOW audit line: src, server, tenant, host, method
- stream the response back (keep-alive supported)

The allowlist is checked at CONNECT time, before a leaf is ever minted — a denied host never even gets a TLS handshake. Allowlist entries match exact hostnames case-insensitively, plus leading-*. wildcards (*.slack.com, matched as a suffix); registry lint CI rejects bare or embedded wildcards, wildcards broader than two labels, and known exfiltration domains.

Credential injection mechanics

Each credential the resolver returns carries:

FieldMeaning
PlaceholderThe high-entropy sentinel placed in the sandbox env as GIG_PLACEHOLDER (generated per install; matched as a substring, which is why it must be high-entropy)
InjectHeaderWhich header to rewrite, e.g. Authorization
InjectFormatThe trusted output template, e.g. Bearer {token}
RealSecretThe vault-decrypted key
AllowedHostsThe manifest's egress allowlist, enforced as a hard cap

Injection only happens when the sandbox put the placeholder in the configured header — that signals intent to use the credential. Crucially, the final header value is dictated by the trusted InjectFormat, not by whatever framing the untrusted sandbox sent: a sandbox sending Basic PLACEHOLDER still gets the server-defined Bearer <realkey>. The real secret never depends on sandbox input.

These injection rules come from the server's registry manifest (credentials[].inject) — see Manifests.

danger

The placeholder is deliberately not a secret — assume the sandboxed server logs it, sends it places, shares it. It only ever becomes a real key on the trusted side of the boundary, on an allowlisted host, in the manifest-defined header.

Why keys never enter the sandbox

Putting all of the above together, for a Tier 1 server:

  • The sandbox environment contains GIG_PLACEHOLDER, never the key (--clearenv wipes everything else).
  • The key lives encrypted in the vault and is decrypted in gateway memory only at injection time.
  • The substitution happens in the proxy process, outside every namespace boundary, after the identity and allowlist checks.
  • Even a fully compromised server can only replay the placeholder — to hosts on its own allowlist, where the proxy controls what the header actually says.

Tier 2 ("entrusted") servers are the documented exception: the real secret goes into the sandbox env for credentials the proxy can't inject (database strings, cert-pinned clients), with the allowlist still enforced. See the Security Model for that trade-off.

Audit log

Every decision produces a structured log line:

ALLOW src=10.88.0.2 tenant=slack-mcp/7 host=slack.com method=POST
DENY src=10.88.0.6 tenant=evil-mcp/3 host=pastebin.com reason=not-in-allowlist

Because identity comes from the IP→tenant registry, the audit trail is attributable per (server, tenant) with no cooperation from the sandboxed code. Audit events are surfaced in the dashboard and the REST API.

Configuration

VariableDefaultPurpose
GIG_PROXY_PORT8081Port the egress proxy listens on (reachable from each sandbox via its /30 host-side IP)

See Configuration for the full environment reference.