Every call to the case-management API goes through one small function. It attaches the auth token, sends the request, and — if the server answers with a redirect — decides whether to follow it. Reads (GET) and writes (POST) both pass through. The reads were careful. The writes were not.
What the read path did
When a read got a redirect, the code did not blindly chase it. It parsed the redirect's target host and checked it against an allowlist: the API's own host, or the specific S3 buckets the API legitimately redirects document downloads to. Anything else, it refused — with an explicit error naming the unexpected host. And it followed the redirect without the auth header, so the token never travels to a redirect target.
That is two separate protections doing two separate jobs. Dropping the auth header prevents a token leak. Checking the host prevents the tool from being steered into making a request to somewhere it shouldn't — a server-side request forgery. The read path had both.
What the write path did
The write path dropped the auth header too — so, like the read path, it did not leak the token. But it did not check the host. If the server answered a write with a redirect, the code followed it to wherever the Location header pointed. No allowlist. No refusal. The one protection that was missing was exactly the one the read path had been given.
State it plainly, because the asymmetry is the whole point: the lower-stakes operation was guarded against being redirected to an arbitrary host, and the higher-stakes operation was not. A write changes the firm's data. A read does not. The path that can do more damage had less protection.
The oldest argument in the book
The fix did not need a new idea. It needed the oldest one: kal va-chomer — the argument from lesser to greater. If a read, which only looks, is guarded against following a redirect to an untrusted host, then a write, which changes data, must be guarded all the more so. The protection that was justified for the lighter case is required, a fortiori, for the heavier one. There was no argument for the read path having a guard that the write path could decline.
So the allowlist check was extracted into a single function and called from every place that follows a redirect — the read path, the write path, and the binding guard's document re-download. One function, not three copies, so the three cannot drift apart later and reintroduce exactly this gap. (An earlier pass had been bitten by two copies of a parser drifting; the lesson carried.)
The honest size of it
This was not exploited, and it was not a token leak. Both paths already withheld the auth header from redirect targets, so the credential was never at risk. And in normal operation the API does not redirect write requests at all — so the unguarded code never ran. The accurate description is narrow: a latent server-side-request-forgery gap on the write path, reachable only if the upstream (or something impersonating it) began returning redirects on writes, and even then bounded to an unauthenticated outbound request to an attacker-chosen host. Not nothing. Not a breach.
The obvious objection deserves a direct answer: if the API never redirects writes, why fix it? Because "the upstream does not currently do X" is a statement about today's behavior, not a guarantee. The entire category of bug this audit chases is the kind that lies dormant until an input arrives that the code never expected — a malformed response, a timezone past 5 PM, an upstream that starts redirecting. A guard that only protects the read path is a guard built on the assumption that writes will never be redirected. Assumptions about how a remote system will always behave are not a security boundary.
The redirect on the document-download path was left alone, deliberately. It uses a different mechanism — it lets the runtime follow the redirect and then checks the final host after the fact — and that mechanism is genuinely safe: the runtime strips the authorization header on a cross-origin redirect, and the final host is validated before the bytes are trusted. Converting it to match the others would have been changing working, secure code for the sake of uniformity. The goal was to close a gap, not to make three different-but-correct things look identical.
What was verified
The shared host check was tested against the cases that matter: it accepts the API's own host and all three forms of the legitimate S3 buckets, and it rejects an outright attacker host, a malformed Location, and — the one that catches people — a suffix-confusion lookalike, where the attacker's domain ends with the real host's name as a prefix of a longer string. That last case is why the check is an exact host match plus specific bucket patterns, not a substring test. A substring test is how the misfile guard got the ANDERS-matches-ANDERSON bug in an earlier pass; the same shape of mistake was not going to be repeated in a security check.
Whether there is a third place a redirect is followed that this audit did not find — that remains open until a pass looking for exactly that finds nothing. The claim here is bounded to what was checked: three redirect-following sites, now one guard, verified against the hosts that matter.