SSRF tests for agent tools that inject credentials
SSRF tests for agent tools need a destination matrix that catches loopback, private ranges, rebinding, redirects, and IPv6 before auth.

An agent tool that injects credentials turns ordinary outbound HTTP into a boundary decision. If the tool accepts a destination from an agent, validates a pleasant-looking hostname, and then lets its HTTP client go wherever DNS and redirects lead, the tool has handed its credential to an attacker-selected service.
The fix is not a longer denylist pasted into a URL validator. You need a destination matrix that exercises the whole request lifecycle: parsing, DNS resolution, address selection, connection, redirect handling, and only then credential injection. The test must observe the peer that received the request, because that is where the secret went.
Credential injection must happen after destination approval
A tool should decide whether it may contact a destination before it attaches any authentication material. That means bearer tokens, basic credentials, custom headers, request signatures, client certificates, and SSH identities all belong on the far side of the destination check.
Teams often put the check in the wrong place. They validate the original URL string, construct a request with an Authorization header, and call a convenient HTTP helper with redirect following enabled. The helper then resolves names, chooses IPv6 or IPv4, follows a Location header, and may reuse headers or connection state in ways the validation code never saw.
That design has one useful property: it is easy to write. It also leaves the dangerous decision to a component that does not know whether the request carries a credential.
Keep two decisions separate:
- Is this URL syntactically acceptable for the tool?
- Is this concrete network destination approved for an authenticated connection?
The first decision parses the scheme, host, port, path, userinfo, and percent encoding. The second decision classifies resolved addresses and controls the actual connection. A host allowlist can inform both decisions, but it cannot replace the second one.
RFC 3986 makes the parsing issue plain. The authority component has the form [userinfo@]host[:port]; a string that visually contains a trusted hostname before @ can still name an IP address after it. RFC 3986 even uses this construction in its discussion of misleading URI authority strings. Do not split URLs yourself, and do not search the raw string for a trusted name. Parse with one standards-aware parser, reject userinfo for destinations if the tool has no legitimate need for it, and read the parsed host field.
The security invariant is simple enough to make testable:
The tool must not send an authenticated byte until it has approved the connected peer and the request authority for that request.
"Authenticated byte" includes a request line or Host header only when those values themselves disclose a secret. In most HTTP cases, the hard line is the credential-bearing header or signed payload. Be stricter when the path, query string, or body contains a capability URL, tenant identifier, or another sensitive value.
Define the decision point before you write cases
You cannot test an SSRF control until you can name the exact point where it makes its allow or deny decision. "Before the request" is too vague. A request has several moments where the target can change.
Use this sequence as the model for a credential-injecting HTTP tool:
- Parse the supplied absolute URL and reject unsupported schemes, malformed authorities, userinfo, and ports outside the tool's contract.
- Resolve the hostname through the resolver the process will actually use. Gather A and AAAA answers, not only the answer your development machine happened to prefer.
- Classify every candidate address. Reject the request when the set contains an address outside the approved destination classes, unless the connector can pin itself to an approved address.
- Open a connection to an approved address, then verify the remote peer address before writing the credential.
- Build the authenticated request only after that verification. Set the intended Host header and TLS server name deliberately.
- Treat a redirect as a new request that begins at parsing, not as a continuation that inherits trust.
This is more demanding than checking hostname != "localhost". It has to be. RFC 8305 describes clients issuing AAAA and A queries close together and trying addresses with IPv6 preference in mind. A test suite that resolves only A records can certify code that fails the moment a reachable AAAA record appears.
The decision point also determines what your harness needs to record. For each attempt, capture:
- the URL received by the tool
- DNS answers returned to the tool
- the address selected for connection
- the address observed by the receiving server
- every request header, with test credentials redacted in reports
Do not use a test that asserts only a status code or a thrown exception. A 403 from your capture server proves it received the request. A timeout may prove nothing at all. The assertion you want is: "The blocked listener observed no credential-bearing request."
Build the matrix around destinations, not hostnames
A destination matrix is the durable artifact for this work. Each row names an input form, the resolver response, optional redirect behavior, and the result you expect. It prevents the usual drift where one developer tests 127.0.0.1, another tests 10.0.0.1, and nobody tests the strange forms the runtime accepts.
Start with a small matrix that has a real oracle. The example below assumes that public.test points to a harness listener classified as allowed, while every other address class must deny before credential injection.
| Case | Submitted URL | DNS or redirect behavior | Expected result |
|---|---|---|---|
| Public IPv4 | https://public.test/echo | A record to harness public fixture | Allow and send test credential |
| IPv4 loopback | http://127.0.0.1:18080/ | Literal address | Deny before connect |
| IPv4 loopback variant | http://127.1:18080/ | Parser-dependent shorthand | Reject or classify as loopback, never allow |
| Private RFC 1918 | http://10.20.30.40/ | Literal address | Deny before connect |
| Private RFC 1918 | http://172.20.30.40/ | Literal address | Deny before connect |
| Private RFC 1918 | http://192.168.20.40/ | Literal address | Deny before connect |
| IPv6 loopback | http://[::1]:18080/ | Literal address | Deny before connect |
| IPv6 link-local | http://[fe80::1]/ | Literal address | Deny before connect |
| IPv4-mapped IPv6 | http://[::ffff:127.0.0.1]/ | Literal address | Deny before connect |
| Mixed DNS answer | https://mixed.test/ | Allowed A, blocked AAAA | Deny, or pin connection to allowed address |
| Rebinding name | https://rebind.test/ | First allowed, next blocked | Deny authenticated request to blocked peer |
| Redirect to loopback | https://public.test/to-local | 302 to http://127.0.0.1:18080/ | Deny second request |
| Redirect to private DNS | https://public.test/to-private | 302 to https://internal.test/ | Resolve and deny second request |
| Userinfo deception | http://[email protected]/ | Literal host after @ | Reject before connect |
RFC 1918 names only three IPv4 private blocks: 10/8, 172.16/12, and 192.168/16. Test the boundaries, especially 172.15.255.255, 172.16.0.0, 172.31.255.255, and 172.32.0.0. A sloppy prefix check commonly blocks all of 172/8, which breaks legitimate public endpoints, or blocks only 172.16/16, which misses most of the allocated private range.
Do not mistake this table for a universal production policy. Some internal tools should be allowed to contact a specific private service. If that is your product requirement, give that service an explicit rule with a narrow hostname, port, and identity check. Do not make "private addresses are okay in our office" the default for every agent-selected destination.
URL parsing tests catch errors before DNS does
The parser has to identify the host that the connector will use. That sounds obvious until a test reveals one component normalizes input differently from another.
Put these cases in a parser-only suite. They should execute without DNS or a network socket:
http://[email protected]/admin
http://127.0.0.1.nip.example/
http://[::1]/
http://[::ffff:7f00:1]/
http://0177.0.0.1/
http://2130706433/
http://127.0.0.1%2f.example/
http://public.test:[email protected]/
http://public.test./
The expected result is not always "this exact string must parse as loopback." URI and URL libraries differ on older numeric IPv4 forms, zone identifiers, and invalid percent encodings. Your test should state the security property instead: if the runtime accepts the input and would connect it to a blocked address, your tool must deny it. If the runtime rejects it, rejection is fine.
That distinction matters because many teams write a custom host filter first and hand the unchanged URL to another library second. The filter may view 2130706433 as an unfamiliar registered name while the connector interprets it as 127.0.0.1. You have just tested two parsers and trusted the safer answer.
Use the same parsed representation for validation and connection. When that is not possible, make the final connector report the numeric peer it selected, then perform a last address-class check before credentials leave the process.
You should also test hostname normalization. A trailing dot in public.test. refers to the same DNS name as public.test in ordinary DNS use, but simplistic allowlists often compare raw strings. Internationalized hostnames add another place for disagreement. Convert to the runtime's canonical hostname representation once, then match a DNS name on label boundaries. A suffix check such as endsWith("trusted.example") accepts untrusted.example and trusted.example.attacker.test; neither is a trusted subdomain.
DNS rebinding tests must force the second lookup
DNS rebinding is a timing failure. The initial lookup returns an acceptable address. A later lookup for the same name returns loopback, a private address, or another blocked target. If validation and connection do not use the same resolution result, an attacker can pass the first check and steer the second.
A useful fixture needs a tiny authoritative DNS server under your control and two HTTP listeners. One listener is your allowed fixture. The other is a blocked fixture that records whether it received the test Authorization header. Give the DNS server an answer schedule for rebind.test:
query 1: rebind.test. A 198.51.100.20
query 2: rebind.test. A 127.0.0.1
query 1: rebind.test. AAAA 2001:db8::20
query 2: rebind.test. AAAA ::1
Use harness-only addresses and map the allowed listener in the test network as needed. The public-looking documentation addresses in this example are labels for the fixture, not endpoints the test should contact on the Internet.
Now run two variants. In the first, make the tool perform its own validation lookup and then call an HTTP library that resolves names separately. Your blocked listener should receive the credential if the implementation has the gap you are looking for. In the second, configure the connection path to use the approved resolved address, preserve the original hostname only for Host and TLS name verification, and assert that the blocked listener observes nothing.
Caching can hide this problem. A resolver cache, connection pool, or operating system cache may turn your second answer into a non-event. The test harness should expose cache controls, close idle connections between runs, use a fresh hostname per run when necessary, and record every DNS query. If a rebinding test passes without confirming two distinct lookups, it did not test rebinding.
Do not solve the issue by trusting a DNS TTL. TTL is a caching hint, not a statement that a name remains safe between validation and connection. The code must carry the approved address into the connection or verify the peer after the socket opens.
Redirects are separate destination decisions
A redirect changes the destination. It may change the scheme, host, port, path, or all four. Treating it as a trusted continuation is how an apparently safe public URL becomes a request to 127.0.0.1, an instance metadata service, a router, or an internal control plane.
Turn off automatic redirect following in the transport used by the credential-bearing request. Read the redirect response, enforce a modest redirect limit, parse the Location value against the current URL according to the library's URL resolution rules, and run the complete destination decision again.
Your redirect fixture should include at least these behaviors:
- A public URL returns a 302 to an IPv4 loopback literal.
- A public URL returns a 307 to a hostname that resolves to a private IPv4 address.
- A public URL returns a relative Location such as
/next, which should remain on the already approved authority after normal resolution. - A public URL returns a scheme-relative Location such as
//other.test/path, which changes authority and needs full validation. - A public URL returns a redirect chain where the first hop is allowed and a later hop is blocked.
Status codes matter. A 301, 302, or 303 may prompt clients to change a non-GET method into GET. A 307 or 308 is meant to preserve the method and body. Do not rely on a client library's default treatment when a signed POST or a bearer-authenticated write is involved. Record the method, body hash, Host header, and Authorization header at every redirect fixture.
Dropping Authorization whenever the authority changes is good defense, but it does not make destination validation optional. A request without an Authorization header can still carry a signed URL in the path, a client certificate, cookies, or a sensitive body. A same-host redirect can also point from an ordinary path to a local service if DNS changes between hops.
This is one place where a test should be deliberately boring. Make the blocked redirect listener return 200. If your assertion merely expects a client error, a redirect-following client can make the SSRF request successfully and your test will call it a pass for the wrong reason. Assert on the listener's captured request count and captured credential state.
IPv6 is a first-class SSRF surface
IPv6 omission is usually accidental. A developer tests 127.0.0.1, blocks RFC 1918 ranges, and ships code that regards [::1] as an exotic hostname. Modern clients can query AAAA records alongside A records, and an IPv6 route can win even when IPv4 tests look clean.
RFC 4291 identifies ::1/128 as the IPv6 loopback address and ::/128 as the unspecified address. It also defines link-local unicast addresses under fe80::/10. These are address classes that should never become an agent-selected authenticated destination by accident.
Test these categories explicitly:
| Address class | Example | Default test expectation |
|---|---|---|
| Unspecified | [::] | Deny |
| Loopback | [::1] | Deny |
| Link-local | [fe80::1] | Deny |
| Unique local | [fc00::1], [fd12:3456::1] | Deny unless explicitly approved |
| Multicast | [ff02::1] | Deny |
| IPv4-mapped loopback | [::ffff:127.0.0.1] | Deny |
| IPv4-mapped private | [::ffff:192.168.1.10] | Deny |
Address classification must operate on the binary address, not the spelling. The same address can appear in compressed or expanded IPv6 form. A string comparison against ::1 misses 0:0:0:0:0:0:0:1. Parsing to a real IP type first is the unglamorous part that prevents that category of bug.
Scope identifiers deserve a clear rule as well. An input such as [fe80::1%25en0] contains an interface zone after percent encoding. Most tools should reject zone-scoped literal destinations from agent input rather than attempting to decide which local interface is safe. If the product has a valid local-network use case, make interface choice an explicit user-owned configuration, never an agent-supplied URL detail.
Mixed A and AAAA answers need a hard decision. If the resolver returns one allowed IPv4 address and one blocked IPv6 address, the safest default is denial. If availability requires using the allowed answer, the connector must pin the socket to that address and must not later hand the hostname back to a resolver. "We prefer IPv4" is not a control. Network libraries, operating systems, and connection races can make another choice.
Run the matrix inside a closed harness
Do not point SSRF tests at your laptop's real localhost services or a cloud metadata endpoint. You want proof, not a stressful afternoon. Put the resolver and every listener in an isolated test network, use disposable credentials, and make the listeners log their own address and received headers.
A simple harness has four actors:
- A test runner that invokes the tool with a URL and a disposable credential reference.
- A controllable DNS server that can return A and AAAA answers in a programmed order.
- An allowed HTTP fixture that records successful authenticated requests.
- A blocked HTTP fixture that records any connection or request as a test failure.
Give the blocked fixture a response body that makes leaks obvious in local logs, but never print a full token. For example, it can return the peer address, request method, Host header, and a boolean stating whether Authorization was present. The runner compares that evidence against the expected matrix row.
The result format should be plain enough to inspect in CI:
case=redirect_to_loopback
submitted=https://public.test/to-local
resolved=198.51.100.20
redirect=http://127.0.0.1:18080/
decision=deny
allowed_requests=0
blocked_connections=0
blocked_credentials=0
A failure should preserve the same fields, plus the actual peer. That turns a vague regression into an actionable report:
case=rebind_ipv6
submitted=https://rebind.test/export
validation_answer=2001:db8::20
connected_peer=::1
blocked_credentials=1
result=FAIL
That report identifies the bug immediately: the code approved one DNS answer and connected to another. It also tells you whether the credential crossed the boundary, which is the risk you care about.
If your tool supports proxy configuration, add proxy rows to the matrix. An HTTP proxy changes where the TCP connection goes, while CONNECT can still cause the proxy to reach an attacker-selected target. Decide whether the proxy itself is a trusted transport and whether the tool validates the ultimate authority. A proxy test that only checks the proxy's address can certify an open relay for internal destinations.
Keep authority and destination separate in the implementation
TLS makes this problem easy to muddle. To connect safely to a hostname, the tool may need to open a socket to an approved numeric address while presenting the original approved hostname as the TLS server name and HTTP Host header. Those are different fields with different jobs.
The numeric peer answers, "Where is this socket going?" The TLS server name answers, "Which certificate identity must this server prove?" The Host header answers, "Which HTTP authority does this request target?" Your implementation should keep all three values explicit rather than letting an HTTP convenience API infer them from a mutable URL string.
This separation also exposes a bad recommendation that remains popular: resolve once, then replace the hostname in the URL with the numeric address. That may avoid a second DNS lookup, but it can break TLS certificate validation, virtual hosting, and signed-request schemes. Developers often respond by disabling certificate verification or weakening host checks. That is worse than the original bug.
Instead, use a transport that supports dialing the approved address while retaining strict TLS validation for the original hostname. After the handshake, confirm that the socket peer is the approved address. If your runtime cannot offer those controls, do not claim it prevents DNS rebinding for authenticated agent requests. Put the limitation in the product boundary and avoid injecting credentials into agent-chosen destinations.
SSH has the same shape even though it does not use HTTP redirects. Resolve and classify the requested host, pin the connection to the approved address, and verify the host key against the intended host identity. A host key prompt or a permissive known-hosts rule is not a destination policy.
Sallyport keeps credentials inside its encrypted vault and performs HTTP and SSH actions itself rather than handing secret material to the agent. That boundary only holds if the action layer applies destination controls before it uses a stored credential.
Make the matrix a release gate, not a security document
A destination matrix is useful only when it runs against the same request path that will ship. A unit test for isPrivateIp() does not test the HTTP client's resolver, redirect code, proxy behavior, or connection pool. Keep the unit tests, but make the harness suite mandatory whenever you update the transport library, URL parser, resolver configuration, or credential injection code.
Add rows when you fix a bug. Do not collapse a painful incident into a broad statement like "improve SSRF validation." Preserve the exact input, DNS sequence, redirect response, and expected absence of credentials. Future maintainers need the scar tissue in executable form.
Review failures by category. Parser discrepancies point to duplicate URL handling. A blocked listener that sees a TCP connection but no request may mean you validated too late, even if no token leaked. A blocked listener that sees an Authorization header means the invariant failed and should stop the release.
The work pays off because it changes the question your team asks. Stop asking whether a hostname looked external. Ask which peer received an authenticated request, how it got selected, and whether your test can prove the answer. If you cannot answer that from the test output, the tool still has an SSRF blind spot.
FAQ
Is hostname allowlisting enough to prevent SSRF in an agent tool?
No. A hostname is only an input to resolution, and a hostname that looked harmless can resolve to a local or private address when the client connects. Test the resolved address set and the actual connected peer, not just the original text the agent supplied.
Should an HTTP client forward credentials across redirects?
Treat every redirect as a fresh destination request. Validate the new URL, resolve it again, apply the same destination decision, and attach credentials only after it passes. If the redirect changes authority, dropping the Authorization header is sensible but not sufficient.
Which IPv6 addresses should an SSRF test block?
Yes. Block or explicitly classify IPv6 loopback (::1), unspecified (::), link-local (fe80::/10), unique local addresses (fc00::/7), multicast, and IPv4-mapped IPv6 forms. A test suite that exercises only dotted IPv4 addresses leaves a large blind spot.
How do I test DNS rebinding safely?
DNS rebinding happens when the name passes an earlier lookup but returns a different address later, often just before the connection. Use a controllable DNS fixture that answers first with an allowed address and later with a blocked address, then verify that the tool does not send an authenticated request to the blocked peer.
Can I validate DNS once and let the HTTP library connect normally?
No. Checking one resolution result and then allowing a library to resolve the hostname again creates a time-of-check to time-of-use gap. Resolve the name, classify every candidate, and connect using the approved address or verify the peer address after connection before sending credentials.
What is a destination matrix for SSRF testing?
A destination matrix is a list of URL inputs, DNS answers, redirect targets, and expected security decisions. It turns vague claims such as "we block private IPs" into repeatable tests that catch parser errors, alternate address formats, and library behavior changes.
Do read-only agent tools need SSRF protections?
Yes, if the tool attaches a secret, client certificate, signed request, or any credential with authority beyond the agent. A request that merely reads public documentation has a smaller blast radius, but it should still avoid unplanned access to local services.
What should happen when DNS returns both public and private addresses?
Fail closed. If the resolver returns a mixture of allowed and blocked addresses, an ordinary HTTP client may select the blocked one because of address ordering or IPv6 preference. A credential-injecting tool should reject mixed answers unless it controls which approved address it connects to.
Is blocking RFC 1918 ranges enough for SSRF defense?
No. Private address ranges are only one category. Loopback, link-local addresses, carrier-grade NAT space, documentation ranges, unspecified addresses, multicast, IPv4-mapped IPv6 values, redirects, and DNS changes all need deliberate treatment.
How can I prove an agent tool never sent a credential to a blocked destination?
Use a harmless capture server that records the remote peer, request path, Host header, and whether an Authorization header arrived. Seed it with a disposable test token that carries no access outside the harness. A good failure report says which destination received the token, not merely that a request returned 200.