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.pemand pointed to bySSL_CERT_FILEandNODE_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
/30subnet: host/proxy side atbase+4k+1, sandbox side atbase+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 FORWARDpolicy toDROP, 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:
- At spawn, the gateway allocates the
/30, injects the veth, and callsRegistry.Bind(sandboxIP, Identity{Server, Tenant})— recording, e.g.,10.88.0.2 → (slack-mcp, profile 7). - When a connection arrives, the proxy takes the source IP from the accepted connection's
RemoteAddrand looks it up. Unknown source IPs get403 unknown source. - 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:
| Field | Meaning |
|---|---|
Placeholder | The 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) |
InjectHeader | Which header to rewrite, e.g. Authorization |
InjectFormat | The trusted output template, e.g. Bearer {token} |
RealSecret | The vault-decrypted key |
AllowedHosts | The 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.
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 (--clearenvwipes 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
| Variable | Default | Purpose |
|---|---|---|
GIG_PROXY_PORT | 8081 | Port the egress proxy listens on (reachable from each sandbox via its /30 host-side IP) |
See Configuration for the full environment reference.