Chapter 06 · Execution Safety

Egress & SSRF Defense

This is layer 5: where on the network the agent may reach. Once an agent can run code and make HTTP calls, the network itself becomes an attack surface — and the agent chooses its own URLs from untrusted content. A two-ring wall stops it from being tricked into hitting internal services or stealing cloud credentials: an outer ring that allowlists the names it may talk to, and an inner ring that blocklists the addresses nobody may talk to.

SSRF — Server-Side Request Forgery

Tricking your own server into making a network request on the attacker's behalf, typically to reach an internal address — a database, an admin panel, or the cloud metadata service — that the attacker can't reach directly. For an agent this is acute because the agent decides its own URLs from untrusted content: a web page, a retrieved document, or a tool result can hand it a URL, and "fetch this for me" is exactly the kind of request agents are built to honor.

The canonical attack

The most damaging SSRF target in a cloud deployment is the instance metadata service at 169.254.169.254. Any process that can reach it can read the credentials the cloud assigns to the running container — and those credentials are usually enough to read storage, query databases, or escalate further.

Attack scenario: "fetch this URL for me"

A user (or an injected instruction hidden in a document) tells the agent: "Summarize the page at this link for me," where the link points at http://169.254.169.254/latest/meta-data/iam/security-credentials/. To the agent this is an ordinary fetch. Without an egress wall, the agent's own HTTP client dutifully connects to the metadata endpoint, reads back the container's cloud credentials, and folds them into its answer — which the attacker then exfiltrates.

No code was injected and no credential was phished. The agent was simply helpful with an address it should never have been allowed to reach. The two-ring wall below makes that fetch impossible at two independent points.

The two-ring model

Every outbound HTTP request from agent or tool code passes through two independent rings. They check different things, in different places, and neither trusts the other. Defeating one still leaves the other standing.

Ring 1 — Application policy Ring 2 — Network
Checks The name (hostname) you asked for The address (resolved IP) you actually connect to
Model Allowlist — only names you're permitted to talk to Blocklist — addresses nobody may talk to
When Before DNS resolution, on the URL the agent chose At socket-connect time, on the real IP (and again on each redirect)
Where EgressPolicyDelegatingHandler Microsoft.Security.AntiSSRF handler
Stops Calls to hosts you never authorized; unauthenticated background calls Calls to internal/private/metadata IPs, including DNS-rebinding swaps
i
Names versus addresses — why both

Ring 1 is an allowlist of names you're permitted to talk to. Ring 2 is a blocklist of addresses nobody may talk to. They are orthogonal: a name can pass the allowlist and still resolve to a forbidden address, and a forbidden address can be reached by a name that was never on any allowlist. Each ring closes the gap the other can't see.

Ring 1 — the application allowlist

The outer ring is a DelegatingHandler that sits in the HTTP pipeline and inspects every request before it leaves — src/Content/Infrastructure/Infrastructure.AI/Egress/EgressPolicyDelegatingHandler.cs. For each outbound call it:

  • resolves the per-identity IEgressPolicy from IAmbientRequestScope.Current — so the rules in force are the ones that belong to this agent identity, not a global default;
  • enforces a hostname allowlist before DNS resolution — the name is checked against the policy first, so a denied host never even gets looked up;
  • denies any request that has no agent identity attached — there are no unauthenticated background calls; if nothing is on the ambient scope, the request is refused rather than allowed through anonymously;
  • audits every allow/deny decision to JSONL via IEgressAuditWriter, so the full record of what the agent tried to reach is on disk; and
  • throws EgressBlockedException on a deny, turning a blocked egress into a clear, catchable failure rather than a silent drop.
!
No identity, no egress

The deny-on-missing-identity rule is deliberate and easy to overlook. A background job or a stray code path that fires an HTTP request without establishing an agent identity on the ambient scope is denied — not given a permissive fallback. This closes the gap where an unattended call could quietly bypass the per-identity policy.

Ring 2 — the connect-time IP blocklist

The inner ring is wired in src/Content/Infrastructure/Infrastructure.AI/DependencyInjection.Egress.cs using the Microsoft.Security.AntiSSRF handler (a NuGet package). Its defining property is connect-time IP filtering: it checks the actual resolved IP at the moment the socket connects, not the IP implied when the URL was first parsed. That timing is the whole point — it defeats DNS rebinding, where the address behind a name changes after the name was approved.

It blocks, with no override, the address ranges that have no business being reached from agent code:

Category Ranges blocked Why it matters
Private (RFC 1918) 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 Internal services, databases, admin panels on the private network
Loopback 127.0.0.0/8, ::1 Services bound to localhost on the same host as the agent
Link-local 169.254.0.0/16, fe80::/10 The range the cloud metadata endpoint lives in
IPv6 unique-local fc00::/7 The IPv6 equivalent of the private ranges
Cloud metadata 169.254.169.254 — hard-blocked, never overridable Credential theft target; blocked even if an operator widens other rules

The handler also re-validates redirects: a 3xx chain is re-checked at the socket on each hop, so a server can't return a redirect that smuggles the agent to an internal IP after the first, allowed hop passed.

DNS rebinding — why connect-time matters

An attacker controls a hostname that passes a name check, then makes DNS resolve that name to an internal IP between the check and the connect. A check that ran at URL-parse time saw a harmless address; by the time the socket opens, the name points inward. Ring 1 checks the name; Ring 2 checks the IP at the moment of connection — so the swap is caught at exactly the point it tries to take effect.

Per-skill allowlists (additive only)

Ring 1's policy isn't one fixed list. A skill can declare the external hosts it legitimately needs through its manifest, read by src/Content/Infrastructure/Infrastructure.AI/Egress/SkillManifestEgressPolicyResolver.cs, which reads egress.allowlist from a skill's manifest. The critical constraint is that this is additive only:

A skill may broaden, never narrow
A skill can add hosts to the default allowlist, but it can never narrow or override the baseline. A skill cannot punch a hole in the default policy or relax a control someone else set — it can only ask for additional reach. When a skill declares no policy at all, the resolver falls back to DefaultEgressPolicy, a frozen, thread-safe default entry list.

Hostname matching follows RFC 6125 TLS semantics — the same rules a browser uses to match a certificate to a host:

  • Exact match, or a single leftmost-label wildcard.
  • *.example.com matches foo.example.com but not foo.bar.example.com — the wildcard covers exactly one label, not a whole subtree.
  • Scheme and port are matched per entry.
  • Anything unmatched is denied — the policy is default-deny.
i
Default-deny, in plain terms

If a host isn't on the allowlist — and matched on scheme and port — the request is refused. There is no implicit "the rest of the internet is fine." That is why an unconfigured harness is safe but restrictive, and why adding your real external hosts is a deliberate step (see the operator note at the end).

MCP client hardening

When the harness connects out to an external MCP server — a tool catalog hosted elsewhere — that connection is its own SSRF surface. The connection path in src/Content/Infrastructure/Infrastructure.AI.MCP/Services/McpConnectionManager.cs hardens it before the first byte leaves:

  • pre-flight rejects any non-http(s) scheme — a server URL using anything other than HTTP/HTTPS is refused outright;
  • blocks metadata endpoints early169.254.169.254, metadata.google.internal, and metadata.goog are rejected before a connection is even attempted;
  • routes through an SSRF-guarded HttpClientMcpConnectionManager.ResolveTransportHttpClient reuses the shared client carrying the AntiSSRF ring (with a per-server EntraTokenAuthHandler layered on when managed-identity auth is configured). MCP servers are admin-configured and often connected outside an agent turn, so they ride the SSRF ring (Ring 2) rather than the per-skill allowlist (Ring 1) — there's no agent identity on the scope to resolve a per-skill policy against;
  • applies a connection/initialization timeout (McpServerDefinition.StartupTimeoutSeconds) so a hung or slow server can't block harness startup; and
  • attaches auth headers per McpServerAuthConfig — supporting ApiKey, Bearer, and Entra credential modes.
Operator note: default-deny means you must list your real hosts

Because Ring 1 is default-deny, a freshly cloned harness can reach almost nothing outbound — that's the safe starting state. Before your agents can call the external APIs they actually need, add those hosts to the allowlist (the frozen default for everyone, or egress.allowlist in a skill manifest for skill-specific reach). The metadata endpoint and the private ranges stay blocked regardless: Ring 2 isn't something you open up.

Going deeper

This page is the operator-level map of the two rings. For the complete defense matrix — every threat, every check, and how the pieces fit together end to end — see the full threat-model writeup at documentation/security/ssrf-defense.md. From here the guide moves to layer 6: making the text flowing in and out safe, where the content safety and injection controls catch the exfiltration URLs and injected instructions an egress wall alone doesn't see.