ADR-0025 — MCP egress: allow approved private/in-cluster upstreams + close the initializer SSRF gap
Status: Accepted (founder-approved 2026-07-22, issue #2019) Author: Principal Architect Refs: #2013, #1988, #2019 · ADR-0021 (T4 group fan-out, T7 termination), ADR-0022 (team gateway), ADR-0024 (MCP-tool governance)
Context
Completing the governed refund E2E on beta-dev (#2013) surfaced two coupled egress defects in the MCP team-gateway path.
(1) Legit private/in-cluster upstreams are unreachable by governed tools/call. internal/mcp/egress/deny.go always-denies RFC1918/loopback/link-local/ULA (alwaysDenyCIDRs), enforced twice in roundtripper.go — at resolve and at DialContext (ReasonPrivateIP). This is the correct SSRF defense for arbitrary public egress, but an MCP upstream is legitimately private: in GKE a ClusterIP/headless Service, on the dev docker stack the fixture resolves to 172.18.x (inside 172.16.0.0/12). The allow-list model (tenant_egress_allowlist, migration 035) is hostname-pattern-only and rejects IP literals, and always-deny is code, not data — so there was no vetted way to reach ANY private upstream through the guarded client.
(2) Initializer SSRF hole + inconsistent guarding. The initialize fan-out was constructed as mcpproxy.NewHTTPUpstreamInitializer(nil) (cmd/context-engine/main.go) — a plain &http.Client{Timeout:15s}, no egress guard. The tools/call Forward path uses newEgressHTTPClient (guarded). This asymmetry is why #2013 observed init reaching a private upstream while call did not — init was silently bypassing SSRF defenses and could hit any private IP including cloud metadata 169.254.169.254. Both hops must traverse the same guard.
Decision
D1 — Unify the guard (close the SSRF hole). Route the initialize fan-out through the SAME egress-guarded client as tools/call.
cmd/context-engine/main.go:NewHTTPUpstreamInitializer(newEgressHTTPClient(pool, 15*time.Second)).HTTPUpstreamInitializer.Initializestamps the tenant onto the outbound ctx viaegress.WithTenant(ctx, member.OrgID).Membergained anOrgIDfield (resolver-derived —GroupUpstream.OrgID, the same value asForwardParams.OrgID). Without it the guard fails-closedReasonNoTenant.- Intended consequence: init now ALSO fails-closed on private upstreams — making the D2 override the single unblock point for both hops. Redirects stay refused (factory default
ErrUseLastResponse).
D2 — Vetted private-upstream override (option (a): FQDN + allow_private flag, approval-scoped).
- Migration 168 extends
tenant_egress_allowlist:allow_private BOOLEAN NOT NULL DEFAULT false,pinned_cidr CIDR NULL, a new reasonmcp_private_upstream_approvalin the CHECK, and aCHECK (allow_private = false OR pinned_cidr IS NOT NULL). Rows stay keyed ondomain_pattern(an FQDN — dotted,ParsePattern-valid; single-label hosts and raw IP literals still rejected). Defaultfalse⇒ zero behavior change for every existing row. Migration 168 also addsmcp_server_units.private_upstream_approved BOOLEAN NOT NULL DEFAULT false+private_pinned_cidr CIDR(the binding capability + captured CIDR). deny.go:alwaysDenyCIDRsis split intooverridableCIDRs= {10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7} andneverOverridableCIDRs= {0.0.0.0/8,127.0.0.0/8,169.254.0.0/16,fe80::/10,::1/128}.alwaysDenyCIDRsis now the union of the two (never drifts). NewIsNeverOverridable(ip).IsAlwaysDeniedunchanged for the non-override path.- Allowlist model (
patterns.go,allowlist.go):PatterncarriesAllowPrivate bool+PinnedCIDR netip.Prefix;PostgresAllowlistSELECTs the new columns; the Redis cache marshal is extended (with legacy[]stringtolerance). guard.go:DefaultGuard.PrivateOverride(ctx, tenant, host)returns the matching row's pinned CIDR.roundtripper.go: at resolve + atDialContext, if the request host matches an allow-list row withAllowPrivate=true, anIsAlwaysDeniedIP is permitted ONLY when it is NOTIsNeverOverridableAND falls inside that row'sPinnedCIDR(privateAllowed). Everything else staysReasonPrivateIP.- Pin at approval, re-validate at connect (DNS-rebinding defense): the pin is captured on the binding at approval time. Each request re-resolves the FQDN; at connect the pinned IP is re-validated ∈
pinned_cidrinDialContext. An attacker repointing the FQDN to metadata is denied (metadata ∈ neverOverridable, ∉ pinned_cidr) at BOTH the resolve and the dial hop. - Two gates, per-host: only a Manager-approved binding may seed the row (
SeedFromMCPServerApproval), AND only when the binding'sprivate_upstream_approvedcapability is set — writing THAT capability requires L5 clearance (UpdateMCPServerBinding,privateUpstreamMinClearance = 5). Never "allow all private."
What STAYS denied (unchanged, even WITH the flag): cloud metadata 169.254.0.0/16, loopback 127.0.0.0/8/::1, IPv6 link-local fe80::/10, 0.0.0.0/8; any private IP outside an approved row's pinned_cidr; raw IP literals in mcp_servers.url; 3xx redirects; cross-tenant (RLS on tenant_egress_allowlist + tenant-scoped guard). Composes with ADR-0024 governance: the edge clearance floor / allowlist / tool-policy still gate the call BEFORE egress; this ADR only widens the network reachability for an explicitly-approved host. A seed whose pinned_cidr overlaps the SSRF floor is refused (ValidatePinnedCIDR), so a floor-overlapping pin never reaches the DB.
Consequences
Security posture — SSRF vectors CLOSED: the initializer no longer bypasses the guard (both hops guarded); metadata/loopback/link-local unreachable even with the flag; private reach is per-approved-FQDN + pinned-CIDR + Manager-approval + L5 capability; DNS-rebinding defeated by pin + connect-time re-validate; redirects refused; passthrough ban and RLS unchanged.
GKE / prod path (build-for-prod): a real in-cluster upstream is a ClusterIP/headless Service. Register mcp_servers.url = https://svc.ns.svc.cluster.local:port/mcp; set private_upstream_approved=true + private_pinned_cidr = <Service CIDR or ClusterIP /32> on the binding (L5); Manager approves; the approval seeds an allow_private=true egress row. Identical code path to dev — no dev-only branch.
Dev-unblock (one-step config, same mechanism): on the beta-dev refund binding set private_upstream_approved=true + private_pinned_cidr='172.18.0.0/16' (L5); the Manager-approval seeder writes tenant_egress_allowlist(domain_pattern='betadev-refund-mcp.mcp.internal', allow_private=true, pinned_cidr='172.18.0.0/16', reason='mcp_private_upstream_approval'). This unblocks BOTH the (now-guarded) initialize and tools/call through the single override, closing the #2013 dev-config chain.
Rollout
- Land D1 guard-unification + migration 168. Default
allow_private=false⇒ no behavior change; inert until a row setsallow_private. - Enable
private_upstream_approved+private_pinned_cidron the beta-dev refund binding (L5) → seed override row → verify init+call reach the172.18.xfixture end-to-end (re-run #2013 E2E). - Document GKE in-cluster upstream registration in the MCP runbook.