Outbound requests and SSRF

Does obfuscation break outbound request controls?

An HTTP client is the one dependency in an application that will happily go wherever it is pointed. What stops it are options: how many redirects to follow, which protocols are acceptable, a function that inspects each hop before it is taken, whether credentials survive a change of host. All of them are property names on an options object read by a package you installed. We protected a file that configures one, renamed the option names a group at a time, and recorded where the requests ended up.

What the sample actually does

The file configures an HTTP client with five narrowings and two controls, then makes eight requests. No socket is opened: the network is a fixed routing table inside the unprotected dependency, so the measurement is reproducible and nothing leaves the machine. Seven of the eight requests are refused in the unprotected run, each by a different guard, which is deliberate - a request that two guards refuse cannot tell you what either guard was worth.

The narrowings are the ones a careful team writes after its first SSRF finding. At most three redirects. HTTPS only. A caller-supplied inspector that refuses a hop to a private address, a protocol downgrade, or a host outside the partner domain. Credentials do not follow a redirect to a different host. A time budget of 120 milliseconds.

The library's defaults are the permissive ones this family ships: twenty-one redirects, HTTP and HTTPS both acceptable, no inspection of hops at all, headers that travel with the request wherever it goes, and no time limit. Protection alone was applied first on five presets and all five behaved identically to the original.

A user-supplied link reached an internal service

The inspector is the important option, and it is worth naming how it differs from most of the guards in this series. It is not downgraded to a weaker built-in when its name is lost, because the library ships no equivalent. It is simply gone. There is no inspection.

With that one option renamed, the request that follows a user-supplied short link stopped being refused and completed. It ended at an internal service reachable by name over TLS, and the body it returned contained the platform's database password. The application's own summary lines moved from internal-service-reached=false to true and db-password-in-body=false to true.

This is the ordinary shape of server-side request forgery: a URL your application accepts from a user, followed to somewhere your network trusts because the request came from inside it. What makes it worth measuring here is that nothing about the application's code changed. The inspector function is still in the bundle, still correct, still doing what it says. It is simply never called, because the key it arrives under is a key the client has never heard of.

One guard held on that arm, and the measurement shows it. The request that bounces to the cloud metadata service was still refused, because the metadata service is plaintext and the protocol allowlist was intact. Defence in depth is visible as a second reason string rather than as a philosophy.

Both halves, and the credentials come back in the body

Renaming the protocol allowlist on its own is the smaller half: a plaintext fetch to an internal metrics endpoint that the application had deliberately refused went through, and the reported protocols moved from https to http+https.

Renaming the inspector and the protocol allowlist together is the result to remember. The metadata webhook completed, ending at the link-local metadata address, and the body it returned carried an access key identifier and a secret access key. The bearer token the application sends to its partner travelled over plaintext on another request in the same run. Accepted requests went from one in eight to five in eight.

For the sixth consecutive pass, a paired configuration failed loudly on each half and quietly on both: each single rename left the other guard standing and produced a refusal with a different reason, while the two together produced a fetch that looked entirely successful. An application that logs failures learns about each half. It learns nothing about the combination, because there is nothing to log.

Credentials that follow a redirect, and a cap that stops mattering

The option that stops credentials following a redirect to another host is quiet on its own. Renaming it sent the bearer token to a second host on an ordinary, allowed hop inside the partner's domain. Nothing failed; the request succeeded exactly as before. The only difference is a partner's access log now containing a live token.

On the arm that renames the whole options block, that same token reached a third host - a collector on a domain the inspector exists to refuse - because with no inspector the chain kept going. The application's own line moved from one host to three, the last of which is not the partner at all.

The redirect cap and the time budget are the two arms that are about cost rather than confidentiality. The cap went from three to the library's twenty-one, and a chain the application refused completed after six hops. The time budget went to unbounded, and a slow response the application had abandoned was waited for. Neither is a breach; both are how a dependency turns one user request into an unbounded amount of your own capacity.

Two options pinned to values identical to the library default behaved exactly as predicted and changed nothing, as did an arm renaming names the sample owns on both sides of the call.

The two loud arms, which are the good news

Renaming the authorization header's key is loud in the useful direction. The token stopped being attached to any request at all: the application's own line went from a partner host to (nowhere). In production every authenticated call would come back unauthorised, which is a failure you find in the first minute of testing.

Renaming the fields of the result the client hands back is louder still - it crashed. The application reads properties on a verdict object that no longer has them, and the run ended with a type error rather than a wrong answer. That is the best outcome available on a mismatch, and it is worth noticing which side of the boundary produced it: names flowing OUT of the dependency into your code tend to fail loudly, while names flowing IN from your code to the dependency tend to fail silently, because the dependency's job is to tolerate what it does not recognise.

That asymmetry is the practical reason this class of problem is hard to test for. The direction that breaks your application is the direction you notice. The direction that removes your guard is the direction that keeps working.

What to do about it

Scope member renaming so the options object handed to an HTTP client is excluded, or build it with quoted string keys and read it with literal bracket access. Then assert the configuration rather than the behaviour: log the number of redirects the client will follow, the protocols it will accept and whether an inspector is installed, and fail the boot if any of them is not what you configured.

It is also worth putting one control outside the application entirely. Egress rules that refuse link-local and private ranges, or a forward proxy that does, do not depend on a property name surviving your build. The measurements above are a good argument for having both: the inspector is the precise control and the network rule is the one that still works when the inspector's name is gone.

The test that would have caught every arm in this article is not a unit test on the inspector - it is a request through the real, built artifact to a host you control that redirects to a private address, asserting that it is refused. That test fails on the arms that matter and passes on the ones that do not.

Frequently asked questions

Does protecting my JavaScript cause SSRF?

No. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at the client's option names.

Why is the redirect inspector the worst option to lose?

Because the library ships no equivalent, so losing the name removes inspection entirely rather than substituting a weaker version. With it gone, a user-supplied link was followed into an internal service and the response body contained a database password.

What made the metadata credentials reachable?

Two renames together. The inspector refuses a hop to a private address and the protocol allowlist refuses plaintext, so each on its own still produced a refusal. With both names lost, the request completed and returned an access key and secret from the metadata service.

Are network egress rules a substitute for the client options?

They are a complement. An egress rule that refuses private and link-local ranges does not depend on a property name surviving a build, which is exactly why it is worth having alongside the inspector rather than instead of it.

Why did renaming the result fields crash instead of failing quietly?

Because those names flow out of the dependency into your code. Names flowing the other way are absorbed by a library that must tolerate unknown keys; names coming back are read by your code, which usually assumes they exist. The direction decides how loud the failure is.

How do I test for this in CI?

Point the built artifact at a host you control that redirects to a private address and assert that the request is refused. That test exercises the guard through the real configuration rather than testing the inspector function in isolation, which passes on every arm here.

What is the recommended fix?

Exclude HTTP client options from member renaming or express them with quoted string keys and literal bracket reads, then assert the effective client configuration at start-up and keep an egress control that does not depend on the build.

Related reading