7 min read

DNS rebinding protection requires an execution-time lookup

DNS rebinding protection for agent HTTP actions: resolve at execution time, reject unsafe answers, pin each connection, and test retries and redirects.

DNS rebinding protection requires an execution-time lookup

An approval for api.example.test is not approval for whatever address that name returns later. If an agent can make an authenticated HTTP request, DNS rebinding turns a stale hostname decision into a route toward a local service, a cloud metadata endpoint, or an internal control plane. The credential injection may work exactly as designed. The destination decision is what failed.

I have seen teams build careful approval screens, then let the HTTP library resolve the name at the last possible moment without any check on the answer. That gap is easy to miss because ordinary DNS behaves politely. An attacker does not need ordinary DNS to cooperate. They need a name they control, a short cache lifetime, and a client that mistakes a hostname for a permanent destination.

DNS rebinding protection requires an execution-time lookup. Resolve immediately before opening the connection, inspect the complete result, select an allowed address, and connect to that exact address. Preserve the original hostname for HTTP and TLS validation. Do the work again whenever the client opens another connection, follows a redirect, or changes protocol.

Approval names a destination class, not an IP address forever

A hostname is an instruction to resolve, not a stable identity for a network peer. That distinction sounds fussy until an agent receives a URL from an issue, a build log, a tool response, or another service. The agent may ask for approval to call reports.partner.test; the approver sees a plausible public destination. Minutes later, the same name can answer 127.0.0.1, an IPv6 loopback address, or an address reachable only inside the machine's network.

The usual rebinding sequence has two phases. During the first phase, the authoritative DNS server returns a public address. The client or reviewer accepts the hostname, sometimes after a browser-like preflight request. During the second phase, the attacker changes the answer and relies on expiry, a reconnect, a redirect, a retry, or a second request to trigger another lookup. The target service sees a request from a local machine, where network firewalls often grant more access than they would to the public internet.

An agent gateway has a sharper version of this problem. It may attach a bearer token, basic credentials, or a custom authorization header only after a person approved the action. If the gateway checks the agent process but does not check the peer address, it can deliver a genuine credential to the wrong place. A request to a local administrative service may not need the credential at all. The attacker may use the request only to make the machine read a response that the agent can then receive.

Do not solve this by recording the first resolved IP address forever. Public services use load balancing, address changes, and IPv6. The safe claim is narrower: every connection must use an address that passed validation at the time that connection began. A previous approval can authorize the hostname and the credential use. It cannot excuse a later address that belongs to a blocked network.

Resolve immediately before the socket opens

The order of operations decides whether the check means anything. Resolve the hostname, validate the resolver's answer, choose one address, and pass that address directly to the connection routine. If you validate an address and then call a convenience API that resolves the hostname again, you have checked one destination and connected to another.

Keep two values separate throughout the request:

  • origin_host is the hostname the user approved and the certificate must cover.
  • peer_ip is the single validated address selected for this socket.
  • port is the requested port after the client has applied its allowed-port rules.
  • scheme determines whether the client requires TLS.

For HTTPS, connect the TCP socket to peer_ip, but send origin_host as the HTTP Host header and use origin_host for SNI and certificate verification. A certificate for the IP address is not a substitute. If the certificate does not match the hostname, fail the request. Disabling certificate validation to make address pinning easier replaces one routing bug with a much worse one.

A minimal connection boundary can look like this:

origin_host = parse_url(request_url).host
answers = resolver.lookup_all(origin_host)
allowed = [ip for ip in answers if public_routable(ip)]
if allowed is empty:
    fail("DNS answer contains no permitted address")

peer_ip = choose_one(allowed)
socket = tcp_connect(peer_ip, request_port)
tls = tls_handshake(socket, server_name=origin_host, verify_name=origin_host)
send_http(tls, host_header=origin_host)

That sketch intentionally says lookup_all. A single-address lookup hides a common failure: the resolver returns both an allowed public IPv4 address and an IPv6 loopback address. A library may prefer IPv6 even when the application inspected only IPv4. Either reject the hostname when any answer is blocked, or define a strict selection rule that passes only validated addresses into the connector. Rejecting mixed answers is simpler to audit and gives an attacker less room to exploit connection-racing behavior.

The word "immediately" matters. Resolve before the socket opens, not when the agent drafts a request, not when the approval card appears, and not when the user stores a credential. A small interval still exists between lookup and connect, but the client has already selected an address and does not need to ask DNS again for that socket.

Private IPv4 filtering is only the first fence

RFC 1918 reserves IPv4 ranges for private internets. It gives you three familiar blocks: 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16. Blocking them is necessary for an internet-facing client, but treating that list as the whole defense is a routine and expensive mistake.

A destination filter needs to reject address categories that cannot be safely treated as public. At minimum, reject loopback, unspecified addresses, link-local addresses, multicast, private-use addresses, and IPv6 unique local addresses. Reject IPv4-mapped IPv6 forms after normalizing them, because ::ffff:127.0.0.1 is still loopback in practical effect. Treat IPv6 zone identifiers as invalid for remote URLs. Do not let textual spelling decide safety; parse the address into a binary form and classify it there.

Several ranges deserve an explicit product decision rather than an accidental default. Carrier-grade NAT addresses in 100.64.0.0/10 are not globally reachable. Documentation and benchmarking ranges should not appear as normal production destinations. The IPv4 link-local range can reach metadata services on some cloud setups. IPv6 link-local addresses require an interface scope and should never arise from a public hostname request. The safest default for an external action gateway is an allow decision only for ordinary global unicast addresses, with named exceptions for a separate internal deployment mode.

Do not use a string prefix check. 127.1, 127.0.0.1, integer formats accepted by a permissive parser, IPv6 compression, and mapped forms make it unreliable. Parse the URL host as either a DNS name or an IP literal. If it is an IP literal, classify it directly. If it is a DNS name, resolve it and classify every returned address. Reject malformed names before resolution.

This is also where teams blur public DNS with public reachability. A resolver can return a public address that routes through a corporate proxy, a VPN, or a split-horizon network path. The DNS answer is one input, not a promise about the entire route. Address filtering stops direct rebinding into obvious local ranges. Network egress rules still need to stop access paths that the host can reach but the product should not use.

CNAMEs and mixed answers need one decision

A CNAME does not make a name safe. It delegates the final answer to another name, which can itself change or return a mixed answer set. The resolver normally follows that chain before it gives the application A and AAAA records, so the application should judge the final addresses it receives. If the resolver API exposes the chain, log it for diagnosis, but do not base enforcement on whether the first name looked familiar.

A strong default is simple: if any final answer for a requested hostname is in a blocked category, reject the request. This can reject a service that publishes an unreachable private address beside a public address, but that configuration already creates unpredictable clients. A gateway that sends credentials should ask the service owner to fix the DNS record rather than silently choosing a convenient answer.

Some teams prefer "use any allowed answer." That rule is popular because it keeps more integrations working. It also creates hard-to-reproduce behavior: one resolver order works, another connects somewhere blocked, and a Happy Eyeballs implementation starts IPv6 and IPv4 attempts at different times. If you choose that route, the connection layer must receive only the chosen allowed addresses and must never fall back to the original hostname. A generic HTTP library often does not give you that guarantee.

Record enough evidence to explain a rejection. The request log should include the original hostname, port, resolved address set, selected peer address if any, the policy outcome, and a request correlation identifier. Do not log credentials or response bodies merely to make debugging convenient. DNS safety failures usually become clear from the address list alone.

Connection pools are safe only when they preserve the peer

Revoke a run cleanly
Record each agent run in the Sessions journal, then revoke that run immediately when needed.

A reused TLS connection does not perform a fresh DNS lookup, and it should not need one. The socket already has a peer address chosen and validated when it opened. A client can reuse that exact connection for another request to the same origin if normal HTTP origin rules and certificate checks permit it.

A new connection is different. Pools often hide reconnects after an idle timeout, a transport failure, an HTTP/2 stream limit, or a change in proxy state. If the pool asks the operating system to connect by hostname again, it has opened the rebinding window. Put the resolver and connector beneath the pool, not beside the first request.

Connection coalescing needs caution. HTTP/2 can reuse one TLS connection for more than one hostname when the certificate covers them and the peer is suitable. A general browser may accept that tradeoff. An action gateway should make the destination decision explicit for each origin. Do not assume a socket opened for one approved hostname can carry a credentialed request for another merely because the certificate lists both names. The scope of approval, HTTP authority, and connection reuse are separate decisions.

Retries have the same shape. A retry after a connection failure is a new outbound attempt. Re-resolve, reclassify, select an address, and open a new socket. Never cache an allowed result longer than the connection it authorized. You may cache blocked results briefly to reduce repeated work, but do not turn that cache into an unreviewed routing authority.

Mid-call changes do not let an attacker move an established TCP connection to a new IP address. TCP has already selected its peer. The danger is the code path that creates a replacement connection, follows a redirect, upgrades a protocol, or issues another request after the first response. Testing only one successful request misses the paths that production clients use under load.

Redirects are separate outbound requests

A redirect changes the target URI. Treat it as a fresh action, even when the HTTP library calls it a convenience feature. Parse the Location value, reject unsupported schemes, resolve its hostname at execution time, validate its answers, and check its port before connecting. Carrying authorization headers automatically across an origin change is a separate credential-leak bug, so remove them unless the new origin has its own explicit authorization decision.

Limit redirects to a small fixed count. A rebinding test server can rotate names and answers across each hop, and unlimited redirects turn a simple outbound action into an opaque chain. Log each hop with the source origin, destination origin, address set, selected peer, status code, and reason for any rejection.

DNS checks also belong around protocol features that are easy to overlook. An HTTP proxy changes who receives the initial TCP connection, so validate the proxy address and then define what the proxy may resolve. A CONNECT tunnel can otherwise move the hostname lookup into a component that does not run the same checks. Webhooks, callback URLs, object-storage endpoints, and package registries all deserve the same treatment when an agent can influence their destination.

A rule that blocks private DNS answers does not make arbitrary URLs safe. It does not prevent a public server from issuing a dangerous command, returning a huge response, or redirecting toward an approved but hostile service. DNS rebinding defense is one boundary. It should be precise enough that people do not mistake it for a general-purpose policy language.

Test the resolver and the connector as one unit

Move credentials out of prompts
Route agent HTTP calls through Sallyport instead of handing bearer tokens to the agent.

Unit tests that feed 127.0.0.1 into an address classifier are necessary but insufficient. The failure occurs when the client passes a hostname through multiple layers and one layer performs an unguarded resolution. Your tests need to observe the actual socket destination.

Build a controlled authoritative test zone with a name such as flip.test. On its first lookup, return a public test endpoint that records a harmless request. On the next lookup, return a blocked address. Then force a second connection by closing the first endpoint after its response. The expected result is not a successful second request with a warning in the logs. The connector must refuse before it opens a socket to the blocked address.

Use a test matrix that changes one condition at a time:

  1. Return a public IPv4 answer, then a loopback IPv4 answer after a low TTL.
  2. Return an allowed IPv4 address beside ::1 in the same answer set.
  3. Return a CNAME whose final answer changes from public to IPv6 unique local.
  4. Return a redirect to a second hostname that resolves to a blocked address.
  5. Close a pooled connection and verify that the retry receives a fresh validation.

Add an assertion at the network boundary. A fake connector should record the numerical IP and port it received. The test fails if it receives a hostname, because that means another resolver can still run after validation. For integration tests, place a listener on the blocked loopback port and assert that it records zero connections. A rejected request that reaches the listener has already crossed the boundary you meant to protect.

Short TTL tests matter because they expose caches in unexpected places: the application resolver, the operating system, a proxy, a language runtime, or the HTTP library. Test both a resolver that honors the TTL and one that returns a changed answer immediately. Safety cannot depend on a particular cache being slow or fast.

Approval and address validation answer different questions

Approve the process, not guesswork
Approve a new agent process once, with its code-signing authority shown first.

Per-session approval answers "which agent process may act during this run?" Per-call approval answers "does this credential use deserve a human decision now?" DNS validation answers "which network peer may receive this connection?" Combining these into one approval prompt makes the interface look simpler while concealing an important change in what the person approved.

Show the hostname and port on an approval surface, but do not pretend that a hostname is an immutable peer address. The implementation should validate the address at execution time regardless of whether the person saw a prompt seconds earlier. If the result is blocked, deny the action and state the hostname and classified address category. A user can fix a mistaken DNS record; they cannot usefully approve an invisible rebinding race.

Sallyport's vault gate, session authorization, and per-call credential approvals control who can invoke an action and when a person must confirm it. Its HTTP action path still needs the same execution-time destination discipline, because a vault that never gives a secret to an agent should also avoid sending that secret to a DNS answer the user did not mean to trust.

Do not add a free-form rules engine to solve this narrow problem. The core behavior can remain deterministic: reject non-public answers for external HTTP actions, resolve at connection time, bind the socket to the selected address, and reevaluate on every new connection. A small rule with a testable boundary is easier to keep correct than a page of exceptions nobody can explain.

Logs must show the destination that was actually used

A hostname-only audit record cannot answer the question that matters after an incident: where did the socket go? Store both the requested origin and the selected peer address for every allowed connection. For denied attempts, store the returned answer set and the classification that caused the denial. Keep the timestamp close to connection creation so an investigator can correlate it with resolver behavior.

Avoid claiming certainty the log cannot support. If the HTTP library receives only a hostname, the audit record can say the application requested a hostname; it cannot prove the numerical peer. Fix the connector before polishing the audit entry. The evidence must come from the layer that controls the socket.

For a tamper-evident audit trail, include DNS decision records in the same sequence as authorization, connection, redirect, and response events. Sallyport projects its Sessions and Activity journals from a write-blind encrypted, hash-chained audit log, and sp audit verify can verify that chain offline over ciphertext. That is useful only if the action record includes the resolved destination rather than a comforting but incomplete hostname.

Start with the connection factory. Find every path that accepts a hostname, make it return a validated numerical peer, and make the socket API reject raw hostnames. Once that invariant holds, short TTLs and answer changes become ordinary test cases instead of a security surprise waiting behind the next reconnect.

FAQ

What is DNS rebinding?

It is a DNS attack where a hostname first resolves to an acceptable public address, then later resolves to an internal or local address. If an approved client trusts the hostname without checking the address it actually connects to, the attacker can redirect the next request.

Can DNS rebinding bypass a hostname approval?

Yes. A hostname can be harmless when someone approves it and dangerous when the client reconnects later. Approval must bind to the destination that the network stack uses for that particular request, not only to a remembered name.

How do I safely resolve a public hostname before an HTTP request?

Resolve the hostname immediately before connecting, validate every returned address, choose an allowed address, and connect to that chosen address. Keep TLS hostname verification tied to the original hostname, then repeat the process on every new connection or redirect.

Is a short DNS TTL automatically suspicious?

No. A low TTL tells a resolver how long it may cache an answer; it does not prove the next answer is safe. Treat every fresh resolution as untrusted input, including an answer returned seconds after the prior one.

Which IP addresses should an agent HTTP client block?

Block loopback, unspecified, private, link local, multicast, documentation, carrier-grade NAT, and IPv6 local ranges unless the user explicitly built a separate trusted internal mode. Checking only RFC 1918 IPv4 ranges leaves several routes into local services open.

Do CNAME records matter for DNS rebinding defenses?

Check every address in the complete chain. A public-looking alias can point through CNAME records to a private answer, and a resolver can return several A and AAAA records with different safety properties.

Should a client re-resolve DNS for every request?

It can. Existing pooled connections should keep their already validated peer address, while a new socket needs a fresh resolution and validation. Do not silently reuse an old hostname decision to bless a new TCP connection.

Are HTTP redirects a DNS rebinding risk?

Reject the redirect until the client resolves and validates its target as a new destination. Redirect handling is a new outbound action, even if the initial request went to an approved public host.

How can I test DNS rebinding protection?

Build a controlled DNS test that returns a public address first and a blocked local address next, then assert that the second connection never opens. Also test mixed A and AAAA answers, CNAME chains, reused connections, and addresses that change while a request is in flight.

Does process authorization prevent DNS rebinding?

A signed agent process tells you who requested an action, while execution-time destination checks tell you where the action will go. You need both: process authorization cannot make a rebinding target safe, and address filtering cannot decide whether the requesting process should use a credential.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov