Defense in Depth: How We Protect AI Proxy Infrastructure from SSRF, DNS Rebinding, and Injection Attacks
A technical deep dive into the six security hardening layers shipping in Govyn v1.2: IPv6 SSRF protection, DNS rebinding defense, MCP header injection prevention, content filter scoping, ReDoS mitigation, and Content-Type enforcement on error responses.
Why AI proxy infrastructure is a uniquely attractive SSRF target
An AI governance proxy sits in a privileged network position. It accepts URLs from users and makes outbound HTTP requests on their behalf. That is the textbook definition of an SSRF attack surface.
But AI proxies are worse than typical web applications for three reasons.
First, they are designed to make outbound requests. A standard web application might have one or two endpoints that fetch external resources. An AI proxy has outbound fetching as its core function. Every proxied request goes outbound. Every webhook fires outbound. Every MCP server connection goes outbound.
Second, they hold high-value secrets. The proxy stores encrypted API keys for OpenAI, Anthropic, Google, and other LLM providers. It stores encrypted authentication credentials for upstream MCP servers. A successful SSRF attack against the proxy’s internal network could reach databases, metadata services, and configuration endpoints that expose these secrets.
Third, the attack surface is user-controlled. Users configure webhook URLs, MCP server endpoints, and alert notification targets. Each of these is a potential SSRF vector. Unlike a hardcoded integration with a known third-party service, these URLs are arbitrary inputs from authenticated but untrusted users.
The proxy architecture that makes Govyn effective at governance — intercepting every request at the network layer — also means that SSRF, injection, and denial-of-service attacks against the proxy itself must be taken seriously. A compromised proxy compromises every agent behind it.
This post documents the six attack categories we addressed in v1.2, the specific defenses we implemented, and how to verify each one.
The attack surface map: three outbound fetch sites
An AI governance proxy makes outbound HTTP requests from three distinct code paths. Each has different trust characteristics and different attack vectors.
1. Upstream LLM providers
The proxy forwards agent requests to OpenAI, Anthropic, Google, and other providers. These URLs are hardcoded per-provider (e.g., https://api.openai.com/v1/chat/completions). The SSRF risk here is low because the destination is not user-controlled.
2. MCP server connections
The proxy connects to Model Context Protocol servers that provide tool-calling capabilities to agents. These URLs are configured by organization administrators. They are user-controlled, authenticated outbound connections that carry decrypted credentials in the request headers.
SSRF risk: high. An attacker who registers a malicious MCP server URL pointing to http://[::1]:5432 could reach the proxy’s local PostgreSQL instance with the proxy’s own credentials.
3. Webhook and alert targets
The proxy fires HTTP requests to webhook URLs configured for alert notifications. These URLs are fully user-controlled.
SSRF risk: high. Same attack vector as MCP servers, but with the additional concern that webhook payloads may contain sensitive telemetry data.
SSRF Attack 1: IPv6 bypasses
Most SSRF protections in production applications check for IPv4 private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and 127.0.0.0/8. This was the state of Govyn’s SSRF protection before v1.2. It blocked the common cases.
It did not block IPv6.
IPv6 has its own set of non-routable address ranges that map to internal services. An attacker who understands these ranges can bypass IPv4-only SSRF protections entirely.
The IPv6 loopback: [::1]
The IPv6 equivalent of 127.0.0.1. If your SSRF filter checks for 127.0.0.1 but not ::1, an attacker submits http://[::1]:5432 and reaches your local PostgreSQL listener.
# Blocked by IPv4-only filter:
http://127.0.0.1:5432
# NOT blocked by IPv4-only filter:
http://[::1]:5432
Both reach the same service. One gets caught. One does not.
Link-local addresses: fe80::/10
IPv6 link-local addresses (fe80::1 through fe80::ffff:...) are automatically assigned to every IPv6-enabled network interface. They are only routable on the local network segment. An attacker who targets http://[fe80::1]:8080 may reach services on the proxy’s local network that are not exposed to the internet.
Unique Local Addresses (ULA): fc00::/7
The IPv6 equivalent of RFC 1918 private ranges. ULA addresses (fd00:: through fdff::...) are used for private networking in IPv6 environments. Cloud providers increasingly use IPv6 ULA ranges for internal service communication.
# IPv6 ULA -- private network, same risk as 10.0.0.1
http://[fd00::1]:8080
http://[fc00::abcd]:9090
Documentation range: 2001:db8::/32
Reserved for documentation and examples. Should never appear in production. If it does, something is misconfigured. We block it.
IPv4-mapped IPv6 addresses: ::ffff:10.0.0.1
This is the most dangerous bypass. IPv4-mapped IPv6 addresses embed an IPv4 address inside an IPv6 representation. The address ::ffff:10.0.0.1 is functionally identical to 10.0.0.1. Most operating systems treat them interchangeably.
An attacker who submits http://[::ffff:10.0.0.1]:5432 is targeting the same private 10.0.0.1 address that your IPv4 filter blocks — but the IPv4 filter never sees it because the address is wrapped in IPv6 notation.
# Blocked:
http://10.0.0.1:5432
# NOT blocked by naive IPv4 check:
http://[::ffff:10.0.0.1]:5432
http://[::ffff:192.168.1.1]:8080
http://[::ffff:127.0.0.1]:6379
Every IPv4 private range has an IPv4-mapped IPv6 equivalent. If you only block the IPv4 form, you have blocked nothing.
SSRF Attack 2: DNS rebinding
Even with comprehensive IP range blocking, there is a time-of-check-to-time-of-use (TOCTOU) vulnerability in the standard SSRF defense pattern.
The standard (broken) pattern
- User submits URL:
https://attacker.com/webhook - Application validates at creation time: resolve
attacker.comto1.2.3.4(public IP, passes check) - Application stores the URL
- Later, application fetches the URL
- DNS has changed:
attacker.comnow resolves to10.0.0.1(private IP) - Application fetches
10.0.0.1— SSRF successful
This is DNS rebinding. The attacker controls a DNS server that returns a public IP during validation and a private IP during the actual fetch. DNS TTL (time-to-live) values can be set to zero or one second, making the window between validation and exploitation trivially small.
How attackers exploit DNS TTL
The attacker’s DNS server is configured to alternate between responses:
# First query (validation time):
attacker.com A 1.2.3.4 TTL=1
# Second query (fetch time, 1 second later):
attacker.com A 10.0.0.1 TTL=1
With a one-second TTL, the cached DNS result expires before the application makes its outbound fetch. The resolver queries again and gets the private IP. The SSRF filter already approved the URL. The fetch proceeds to the internal network.
More sophisticated attacks use DNS servers that respond with the public IP for the first N queries and then switch, ensuring the validation passes even if the application resolves multiple times during setup.
Why creation-time validation alone fails
Many applications validate webhook URLs when they are created or updated, then store them for later use. The URL https://attacker.com/webhook passes validation at 2:00 PM because it resolves to a public IP. At 2:05 PM, when the webhook fires, the DNS has changed. The validation at creation time is meaningless.
This is not theoretical. DNS rebinding tools like rbndr.us and singularity automate this attack. Penetration testing frameworks include DNS rebinding modules. Any SSRF protection that only validates at creation time is vulnerable.
Defense: ipaddr.js classification + async DNS resolution + fail-closed behavior
Govyn v1.2 replaces the simple IPv4 regex check with a layered defense that addresses both IPv6 bypasses and DNS rebinding.
Layer 1: Synchronous IP classification with ipaddr.js
The isPrivateUrl function uses the ipaddr.js library to classify IP addresses across both IPv4 and IPv6 address families. Every blocked range is defined semantically:
const BLOCKED_RANGES = new Set([
"loopback", // 127.0.0.0/8, ::1
"uniqueLocal", // fc00::/7 (ULA)
"linkLocal", // 169.254.0.0/16, fe80::/10
"private", // 10/8, 172.16/12, 192.168/16
"carrierGradeNat", // 100.64.0.0/10
"unspecified", // 0.0.0.0, ::
"reserved", // 2001:db8::/32, other reserved
]);
The ipaddr.js library handles the classification of both pure IPv6 addresses and IPv4-mapped IPv6 addresses. When it encounters ::ffff:10.0.0.1, it extracts the embedded IPv4 address and checks that against the blocked ranges:
function isPrivateIp(ip: string): boolean {
try {
const addr = ipaddr.parse(ip);
if (addr.kind() === "ipv6") {
const v6 = addr as ipaddr.IPv6;
if (v6.isIPv4MappedAddress()) {
const v4 = v6.toIPv4Address();
return BLOCKED_RANGES.has(v4.range());
}
}
return BLOCKED_RANGES.has(addr.range());
} catch {
// Unparseable IP = reject (fail closed)
return true;
}
}
Three design decisions matter here:
-
Semantic range names, not CIDR blocks. Using
ipaddr.jsrange classifications instead of hardcoded CIDR blocks means the library handles edge cases in range boundaries. No off-by-one errors in subnet math. -
Explicit IPv4-mapped unwrapping. The code explicitly checks for IPv4-mapped IPv6 addresses and unwraps them before classification.
::ffff:127.0.0.1is detected as loopback, not passed through as an unrecognized IPv6 address. -
Fail closed on parse errors. If
ipaddr.jscannot parse the address, it is blocked. Unknown formats are rejected, not allowed.
Layer 2: Async DNS resolution at fetch time
The resolveAndCheckUrl function resolves the hostname via DNS immediately before the outbound fetch. This eliminates the TOCTOU window that DNS rebinding exploits.
async function resolveAndCheckUrl(urlStr: string): Promise<boolean> {
// Fast rejection via synchronous check
if (isPrivateUrl(urlStr)) return true;
// Resolve A and AAAA records in parallel
const [v4Result, v6Result] = await Promise.allSettled([
dns.resolve4(hostname),
dns.resolve6(hostname),
]);
// ... collect all resolved IPs ...
// Check ALL resolved IPs -- if ANY is private, block
return allIps.some((ip) => isPrivateIp(ip));
}
Key behaviors:
- Both A and AAAA records are resolved. If a hostname has only AAAA records pointing to
fd00::1, the IPv4-only resolve would return ENODATA and the check would pass. Resolving both record types catches this. - Parallel resolution.
Promise.allSettledresolves A and AAAA records concurrently. No sequential delay. - Any-match blocking. If a hostname resolves to five IPs and one is private, the request is blocked. Attackers cannot hide a private IP among public ones.
- Fail closed on DNS errors. If both
resolve4andresolve6fail with real errors (not ENODATA/ENOTFOUND), the URL is blocked. DNS infrastructure failures do not become SSRF bypasses. - ENODATA/ENOTFOUND distinction. A hostname with only A records will return ENODATA for the AAAA query. That is not a failure — it means “no AAAA records exist.” Only real DNS errors (ESERVFAIL, ETIMEOUT) trigger the fail-closed behavior.
Layer 3: Fetch-time enforcement
The async DNS check runs immediately before every outbound fetch, not at URL creation time. In the MCP handler:
// Fetch-time DNS rebinding check
const isPrivate = await resolveAndCheckUrl(server.url);
if (isPrivate) {
res.status(200).json(
jsonRpcError(requestId, -32603, SSRF_ERROR_MESSAGE),
);
return;
}
Even if a URL passes validation when the MCP server is registered, it is re-checked every time the proxy makes an outbound request to it. DNS rebinding attacks fail because the private IP is detected at the moment of the fetch, not at some earlier validation point.
Layer 4: Hostname blocklist
In addition to IP-based checks, known dangerous hostnames are blocked directly:
const BLOCKED_HOSTNAMES = new Set([
"localhost",
"metadata.google.internal",
]);
The GCP metadata service at metadata.google.internal is a common SSRF target for credential theft. Blocking the hostname directly prevents DNS-based bypasses for this specific high-value target.
Layer 5: Protocol restriction
Only http: and https: protocols are allowed. ftp:, file:, gopher:, and other protocols are rejected. The gopher: protocol is particularly dangerous for SSRF because it allows crafting arbitrary TCP payloads.
Injection Attack 1: MCP header name injection
MCP servers use custom authentication headers. When an organization registers an MCP server with authType: "header", they specify a custom header name (e.g., X-API-Key) and the proxy sends the decrypted credential in that header.
The attack: CRLF injection via header names
If the header name is not validated, an attacker can inject CRLF sequences to add arbitrary headers to the outbound request:
# Attacker registers MCP server with headerName:
"X-Fake\r\nAuthorization: Bearer stolen-token\r\nX-Ignore"
# The proxy constructs headers:
headers["X-Fake\r\nAuthorization: Bearer stolen-token\r\nX-Ignore"] = decryptedValue
# HTTP request sent:
X-Fake:
Authorization: Bearer stolen-token
X-Ignore: <actual decrypted credential>
The injected Authorization header could override the legitimate one, redirecting the request to a server the attacker controls while the proxy thinks it is authenticating correctly.
The attack: sensitive header hijacking
Even without CRLF injection, an attacker who specifies Authorization as their custom header name can overwrite the proxy’s own authentication headers. If the proxy uses Authorization internally for any purpose, the user-controlled value takes precedence.
Similarly, setting the custom header to Host could redirect the request to an unintended server, and Cookie could inject session tokens.
Defense: strict regex validation + sensitive header blocklist
Govyn v1.2 validates MCP server header names with two layers:
const HEADER_NAME_PATTERN = /^[a-zA-Z0-9-]+$/;
const SENSITIVE_HEADERS = new Set([
"authorization",
"cookie",
"host",
"content-type",
]);
const headerNameSchema = z
.string()
.max(200)
.regex(
HEADER_NAME_PATTERN,
"Header name must contain only alphanumeric characters and hyphens",
)
.refine(
(name) => {
const lower = name.toLowerCase();
return !SENSITIVE_HEADERS.has(lower)
&& !lower.startsWith("x-govyn-");
},
"Sensitive headers cannot be used as custom header names",
);
The regex ^[a-zA-Z0-9-]+$ allows only alphanumeric characters and hyphens. This eliminates CRLF injection entirely — carriage returns, line feeds, colons, and spaces are all rejected. No header name containing \r, \n, :, or any other special character can pass validation.
The sensitive header blocklist prevents overwriting security-critical headers even with syntactically valid names. Authorization, Cookie, Host, and Content-Type are explicitly blocked. The x-govyn- prefix is reserved for the proxy’s internal headers.
Both checks run at MCP server creation and update time via Zod schema validation, rejecting invalid inputs before they reach the database.
Injection Attack 2: Content filter evasion via metadata injection
Govyn’s content filter policies allow organizations to define regex patterns that block or flag requests containing specific content — PII patterns, prohibited topics, prompt injection attempts.
The attack: metadata injection to bypass filters
If the content filter evaluates the entire request body (the naive approach), an attacker can embed filter-triggering text in non-content fields to cause false positives, or craft messages that are split across content and metadata fields to evade detection.
Consider a content filter that blocks requests containing social security numbers (pattern: \d{3}-\d{2}-\d{4}):
{
"model": "gpt-4-turbo-2024-04-09",
"messages": [
{ "role": "user", "content": "Tell me about tax filing" }
],
"user": "123-45-6789"
}
If the filter evaluates the entire JSON body, this request is blocked — the user field matches the SSN pattern. But the actual message content contains nothing sensitive. The false positive degrades the service.
The reverse is also dangerous. An attacker who knows the filter patterns can structure their payload so the sensitive content appears in a field the filter does not check, while the messages content appears benign.
Defense: extractMessageContent scopes evaluation to message text only
Govyn v1.2 introduces extractMessageContent, a function that extracts only the actual message text from a request body, ignoring model names, token counts, user IDs, and all other metadata:
function extractMessageContent(
requestBody: Record<string, unknown>,
): string {
const messages = requestBody["messages"];
if (!Array.isArray(messages)) return "";
const parts: string[] = [];
for (const msg of messages) {
if (typeof msg !== "object" || msg === null) continue;
const content = msg["content"];
if (typeof content === "string") {
parts.push(content);
} else if (Array.isArray(content)) {
for (const part of content) {
if (typeof part === "object" && part !== null) {
const text = part["text"];
if (typeof text === "string") {
parts.push(text);
}
}
}
}
}
return parts.join("\n");
}
The function handles both OpenAI and Anthropic message formats:
- String content:
{ "content": "hello" }— extracted directly - Array content (vision/multipart):
{ "content": [{ "type": "text", "text": "describe" }] }— onlytextparts are extracted, image URLs and other non-text blocks are ignored - Multi-message conversations: All message content is concatenated with newlines
Fields that are explicitly excluded from content filter evaluation:
model— model name strings likegpt-4-turbo-2024-04-09should never trigger content filterstemperature,max_tokens,top_p— numeric parametersuser— user identifier stringstools,tool_choice— function/tool definitionssystem— system prompts (debatable; currently excluded to match the scoping design)
The policy evaluation engine uses this function for both block and content_filter rule types:
case "content_filter": {
const pattern = config["pattern"] as string;
const messageContent = extractMessageContent(requestBody);
if (safeRegexTest(pattern, messageContent)) {
return config["action"] === "block" ? "block" : "allow";
}
return "allow";
}
This scoping means content filters evaluate what the agent is actually saying, not what the request envelope looks like. Metadata injection — both for evasion and for false positive triggering — is eliminated.
DoS Attack: ReDoS via crafted glob patterns
Govyn policies use glob patterns to match tool names in MCP access policies. The pattern file_* matches file_read, file_write, file_delete. Internally, these glob patterns are converted to regular expressions: file_* becomes ^file_.*$.
The attack: catastrophic backtracking
Regular expression denial-of-service (ReDoS) occurs when a regex engine encounters a pattern with overlapping quantifiers that cause exponential backtracking. If an attacker can control the glob pattern (via a policy configuration), they can craft a pattern that causes the regex engine to hang:
# Attacker creates a policy with this glob pattern:
*.*.*.*.*.*.*.*.*.*.*.*.*
# Converted to regex:
^.*\..*\..*\..*\..*\..*\..*\..*\..*\..*\..*\..*$
# When tested against a long non-matching input, the regex engine
# tries every possible combination of how to split the input
# across the .* groups. This is exponential in the number of groups.
With 12 .* groups and a 100-character non-matching input, the regex engine may need to evaluate billions of paths before concluding the match fails. This blocks the event loop and denies service to all other requests.
Defense: isSafePattern pre-check before regex construction
Govyn v1.2 includes a safeRegex module that validates regex patterns before constructing RegExp objects:
const MAX_PATTERN_LENGTH = 500;
const NESTED_QUANTIFIER = /(\+|\*|\{[^}]+\})\s*\)(\+|\*|\{[^}]+\})/;
const OVERLAPPING_ALT = /\(([^)]*\|[^)]*)\)(\+|\*|\{[^}]+\})/;
function isSafePattern(pattern: string): boolean {
if (pattern.length > MAX_PATTERN_LENGTH) return false;
if (NESTED_QUANTIFIER.test(pattern)) return false;
if (OVERLAPPING_ALT.test(pattern)) return false;
try {
new RegExp(pattern);
return true;
} catch {
return false;
}
}
Three checks run before any regex is constructed:
- Length limit (500 chars). Prevents extremely long patterns that could be slow even without backtracking.
- Nested quantifier detection. Patterns like
(a+)+or(a*)*contain a quantifier inside a group that is itself quantified. These are the classic ReDoS trigger. - Overlapping alternation detection. Patterns like
(a|a)*contain alternations inside quantified groups where the alternatives overlap. These also cause exponential backtracking.
If any check fails, the pattern is rejected and the glob match returns false (fail closed — the tool is not matched, the policy does not apply). The regex is never constructed.
The glob-to-regex conversion itself is also hardened:
function globMatch(pattern: string, value: string): boolean {
if (pattern === "*") return true;
if (!pattern.includes("*")) return pattern === value;
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
const regexStr = `^${escaped.replace(/\*/g, ".*")}$`;
// ReDoS protection: reject unsafe patterns
if (!isSafePattern(regexStr)) return false;
return new RegExp(regexStr).test(value);
}
The globMatch function escapes all regex special characters except * before conversion. This prevents attackers from injecting raw regex syntax through glob patterns. The character . is escaped to \., + to \+, and so on. Only * is treated as a wildcard.
Bonus: Content-Type confusion on error responses
When an upstream LLM provider returns an error (HTTP 4xx or 5xx), the response may have an unexpected Content-Type. Some providers return HTML error pages, plain text, or responses with no Content-Type header at all.
If the proxy forwards these responses without setting Content-Type, the client may interpret the error body incorrectly. A browser-based client might render HTML from an error response, creating a reflected XSS vector. An API client might fail to parse the error, masking the real problem.
Defense: forced JSON Content-Type on all error responses
Govyn v1.2 forces Content-Type: application/json on all upstream error responses before forwarding them to the client:
res.setHeader("Content-Type", "application/json");
res.send(errorBody);
This ensures that every error response the client receives is JSON, regardless of what the upstream provider returned. Clients can reliably parse error responses with JSON.parse(). HTML injection through error responses is neutralized.
Testing methodology: how to verify each defense
Each defense in v1.2 is covered by automated tests. Here is how to verify them manually.
SSRF: IPv6 bypass testing
# These should all be rejected:
curl -X POST https://proxy.govyn.io/api/v1/mcp-servers \
-d '{"url": "http://[::1]:5432", "name": "test", "authType": "none"}'
curl -X POST https://proxy.govyn.io/api/v1/mcp-servers \
-d '{"url": "http://[fd00::1]:8080", "name": "test", "authType": "none"}'
curl -X POST https://proxy.govyn.io/api/v1/mcp-servers \
-d '{"url": "http://[::ffff:10.0.0.1]:8080", "name": "test", "authType": "none"}'
curl -X POST https://proxy.govyn.io/api/v1/mcp-servers \
-d '{"url": "http://[fe80::1]:8080", "name": "test", "authType": "none"}'
# This should be allowed (public IPv6):
curl -X POST https://proxy.govyn.io/api/v1/mcp-servers \
-d '{"url": "http://[2607:f8b0:4004::1]:8080", "name": "test", "authType": "none"}'
DNS rebinding: fetch-time resolution testing
Set up a DNS rebinding server (e.g., rbndr.us) that alternates between a public IP and 127.0.0.1. Register it as an MCP server URL. On the first request, the proxy will resolve it to the public IP and pass the creation-time check. On the second request (an actual tool call), the proxy re-resolves at fetch time, detects the private IP, and blocks the request.
Header injection: CRLF and sensitive header testing
# CRLF injection -- should be rejected by regex:
curl -X POST https://proxy.govyn.io/api/v1/mcp-servers \
-d '{"url": "https://mcp.example.com", "name": "test", "authType": "header", "headerName": "X-Key\r\nHost: evil.com", "authValue": "secret"}'
# Sensitive header -- should be rejected by blocklist:
curl -X POST https://proxy.govyn.io/api/v1/mcp-servers \
-d '{"url": "https://mcp.example.com", "name": "test", "authType": "header", "headerName": "Authorization", "authValue": "secret"}'
# Reserved prefix -- should be rejected:
curl -X POST https://proxy.govyn.io/api/v1/mcp-servers \
-d '{"url": "https://mcp.example.com", "name": "test", "authType": "header", "headerName": "X-Govyn-OrgId", "authValue": "secret"}'
Content filter scoping: metadata injection testing
Create a content filter policy with pattern \d{3}-\d{2}-\d{4} (SSN pattern). Send a request where the model name contains 123-45-6789 but the message content does not. The request should be allowed — the filter evaluates only message content, not model metadata.
ReDoS: backtracking pattern testing
Create an MCP access policy with a glob pattern containing excessive wildcards. The isSafePattern check should reject it before regex construction, and the policy should not match any tools (fail closed).
Comparison: IPv4-only protection vs defense-in-depth
| Attack Vector | IPv4-only Filter | Govyn v1.2 Defense-in-Depth |
|---|---|---|
http://127.0.0.1 | Blocked | Blocked |
http://10.0.0.1 | Blocked | Blocked |
http://[::1] | PASSES | Blocked |
http://[fd00::1] | PASSES | Blocked |
http://[fe80::1] | PASSES | Blocked |
http://[::ffff:10.0.0.1] | PASSES | Blocked |
http://[2001:db8::1] | PASSES | Blocked |
| DNS rebinding (TOCTOU) | PASSES | Blocked (fetch-time resolution) |
ftp://internal:21 | Depends | Blocked (protocol restriction) |
metadata.google.internal | Depends | Blocked (hostname blocklist) |
| DNS failure during resolution | Depends | Blocked (fail closed) |
| Header CRLF injection | Not addressed | Blocked (regex validation) |
| Sensitive header override | Not addressed | Blocked (header blocklist) |
| Content filter evasion via metadata | Not addressed | Blocked (message content scoping) |
| ReDoS via crafted glob pattern | Not addressed | Blocked (isSafePattern pre-check) |
| HTML error response forwarding | Not addressed | Forced JSON Content-Type |
Key takeaways
-
IPv4-only SSRF protection is insufficient. IPv6 loopback, ULA, link-local, and IPv4-mapped addresses bypass it entirely. Use a library like
ipaddr.jsthat classifies addresses across both address families. -
Validate at fetch time, not creation time. DNS rebinding exploits the gap between when you validate a URL and when you use it. The only reliable defense is to resolve DNS and check the result immediately before the outbound fetch.
-
Fail closed on everything. Unparseable IPs, DNS failures, invalid URLs, unsafe regex patterns — all should be rejected by default. The cost of a false positive (a legitimate request blocked) is far lower than the cost of a false negative (an SSRF reaching your internal network).
-
User-controlled header names are injection vectors. Validate with a strict allowlist regex and block sensitive headers explicitly. CRLF injection in header names can override authentication.
-
Scope content filters to actual content. Evaluating the entire request body against content filters creates both evasion paths (hide content in metadata) and false positives (metadata matching filter patterns). Extract message text specifically.
-
Pre-check regex patterns for ReDoS. Never construct a
RegExpfrom user-controlled input without checking for nested quantifiers and overlapping alternations first.
FAQ
Does Govyn block all internal network access, or can I allowlist specific internal URLs?
Govyn v1.2 blocks all private, loopback, link-local, ULA, and reserved IP ranges by default. There is no allowlisting mechanism for internal URLs. This is intentional — the proxy should never need to reach internal services via user-configured URLs. If you need the proxy to communicate with internal services, those connections should be configured at the infrastructure level (environment variables, service mesh), not through user-facing URL inputs.
How does the DNS rebinding defense handle CDNs and load balancers?
CDNs and load balancers that resolve to public IPs work normally. The resolveAndCheckUrl function only blocks requests when ANY resolved IP falls within a private range. A CDN hostname like cdn.cloudflare.com that resolves to multiple public IPs passes all checks. The concern is when an attacker-controlled hostname resolves to a mix of public and private IPs — in that case, the presence of any private IP triggers a block.
What happens if the DNS resolution takes too long?
The DNS resolution in resolveAndCheckUrl uses Promise.allSettled with Node.js default DNS timeouts (typically 5 seconds). If DNS resolution times out, the error is treated the same as a DNS failure — if both A and AAAA lookups fail with non-benign errors, the URL is blocked (fail closed). In practice, DNS timeouts are rare for legitimate hostnames. If your MCP server or webhook endpoint has DNS that takes more than 5 seconds to resolve, you have a reliability problem independent of security.
Does the content filter scoping apply to system prompts?
Currently, extractMessageContent processes the messages array, which includes system messages if they are included in the messages field. However, the top-level system field used by some Anthropic API calls is not included in the extracted content. This is a deliberate scoping decision — system prompts are set by the application developer, not the end user, and should not be subject to user-facing content filters. If you need to filter system prompts, create a separate policy targeting the system field.
Can an attacker bypass the ReDoS protection by using a very long but safe pattern?
The isSafePattern function enforces a maximum pattern length of 500 characters. Even without nested quantifiers, a 500-character regex with many . matches can be slow on pathological inputs. The 500-character limit bounds the worst case to a manageable duration. In practice, legitimate glob patterns for tool names (e.g., file_*, database_read_*) are short. Any pattern approaching 500 characters is suspicious regardless of its backtracking characteristics.
Further reading
- Proxy vs SDK: Why Architecture Matters for AI Agent Governance — why enforcement at the network layer matters
- PII Protection Policy — content filter configuration for sensitive data
- Production Safety — recommended policy configurations for production deployments
- Compliance Audit — audit trail and policy versioning for compliance requirements
Govyn is an open-source API proxy for AI agent governance. Defense-in-depth security ships in v1.2. MIT licensed. Self-host or cloud-hosted.