DEV Community

Mobeen Ahmed
Mobeen Ahmed

Posted on

Following redirects safely in a PHP download proxy: why I cap them at five hops

When someone pastes a video link into ramrom.io, the file they end up downloading almost never sits at the first URL my server is given. The provider hands back a link, that link points to a CDN, the CDN points to another edge node, and sometimes that node hands back a signed URL with a fresh hostname. Redirects are normal. Unbounded redirects are a bug.

I learned that the hard way. An early version of my download proxy followed redirects with no cap at all, trusting whatever Location header came back. Most of the time it worked. Then one afternoon a provider started returning a redirect loop between two of its own endpoints, and my proxy sat there happily bouncing between them until the request timed out. The visitor saw a spinner that never stopped. Worse, my server had spent its whole request budget on a download that was never going to arrive.

So I put a hard cap on it: five hops, then stop. Here is why that number, and how the loop works.

Why five?

Five is not a magic number from a specification. It is a judgement call based on what the chains I actually see look like. In practice, a video file on my site resolves in one to three hops: the provider URL, maybe a shortener or regional edge, then the file itself. Four happens occasionally when a signed URL gets refreshed mid-flight. I have never seen a genuine chain need a sixth hop, and every chain longer than that has turned out to be a loop, a misconfigured endpoint, or a CDN that has lost track of where the file lives. Five gives real chains room to breathe and still stops a loop before it costs anything meaningful.

The loop itself

The shape of it is simple, and that is the point. I keep a counter and a small list of URLs I have already visited:

$maxRedirects = 5;
$redirects = 0;
$seen = [];
$url = $startUrl;

while ($redirects < $maxRedirects) {
    if (isset($seen[$url])) {
        // We have been here before. This is a loop, not a chain.
        break;
    }
    $seen[$url] = true;

    $response = fetch_headers_only($url);
    $status = $response['status'];

    if ($status >= 300 && $status < 400 && isset($response['location'])) {
        $url = resolve_relative($url, $response['location']);
        $redirects++;
        continue;
    }
    break;
}
Enter fullscreen mode Exit fullscreen mode

A few details in there matter more than the counter itself.

First, I track the URLs I have already visited. A loop between two endpoints never reaches the hop cap — it just alternates forever — so the cap alone does not catch it. The visited list does, on the second visit.

Second, I only fetch headers at each hop. There is no point downloading the body of a 301 response. A HEAD request, or a GET with the body discarded, keeps each hop cheap.

Third, resolve_relative matters. Providers do not always hand back an absolute URL. Sometimes the Location header is just /videos/abc123.mp4, and following that as-is would send my proxy to a nonsense address. Resolving it against the current URL before the next hop is a small step that prevents a whole class of confusing failures.

What happens when the cap is hit

When the loop runs out — either the cap is reached or a URL repeats — I stop and tell the visitor plainly: the download link could not be resolved, try again in a moment. No spinner, no retry storm, no half-downloaded file with a .mp4 name and an error page inside it.

That last part is worth saying explicitly. A proxy that gives up mid-chain and streams whatever it got is worse than one that fails cleanly. The visitor ends up with a file that looks right and plays nothing, and they blame the tool instead of the flaky link. Failing honestly protects the tool's reputation more than a hopeful partial file ever will.

Two more guard rails

The redirect cap does not live on its own. Two companion rules sit next to it in the same proxy.

One: after the chain resolves, I check the final URL against the same allowlist the rest of the proxy uses. A redirect is a chance for a URL to change hosts, so validating only the starting URL would leave the door open. Every hop's destination gets checked before I follow it.

Two: the whole proxy call has a timeout that is shorter than the page's own patience. Even a well-behaved chain of five hops should not take forever, and a slow endpoint should not hold a visitor's request hostage. The redirect cap limits hops; the timeout limits time. They do different jobs.

The boring lesson

None of this is clever. That is the lesson, really. Redirect handling is one of those things that works perfectly in testing — because test URLs redirect once, politely — and only shows its teeth in production, when a provider's CDN has a bad day. A counter, a visited list, and an honest error message cost about twenty lines of PHP, and they turned a failure mode that once ate my whole request budget into a one-line message a visitor can understand.

If you are writing a proxy that follows user-supplied or provider-supplied URLs, cap the hops before you ship it. Five works for me. Pick your own number, but pick one — and write down why, so the next person who reads the code does not "fix" it by removing it.

Top comments (0)