Object storage

Does obfuscation break presigned URL options?

A presigned URL is a capability. Everything you put in the call that mints one is there to make that capability narrower: shorter, smaller, one content type, one object key. The SDK's own defaults are the wide version of every one of those, and they are not subtle - fifteen minutes rather than one, an hour rather than two, no size cap, no type cap, and no key constraint at all.

What the sample actually does

The file mints two capabilities. The first is a presigned download link for a customer statement, scoped to sixty seconds and carrying a content-disposition that forces the browser to download rather than render. The second is an upload policy scoped to one object key, two minutes, a five megabyte ceiling and a content type that must start with image/.

It then pushes six uploads through the policy: the intended avatar, a four gigabyte blob, an HTML payload, an upload aimed at another tenant's key, an upload aimed at index.html at the bucket root, and one arriving nine minutes late. In the unprotected run exactly one of those six is stored and the other five are refused, each for a different reason.

The presigner is copied unprotected. Its defaults mirror the real SDK: a signed GET link lives 900 seconds unless you say otherwise, an upload policy lives 3600 seconds, the condition list is empty, and there is no key constraint unless you put one in.

Protection alone, on all five presets, produced behaviour identical to the unprotected file.

A minute becomes a quarter of an hour

Renaming expiresIn took the download link from 60 seconds to 900. That is the SDK's default and it is fifteen times what the application asked for.

Whether that matters depends entirely on where the link goes, which is exactly why applications shorten it. A link that exists for one minute is a link that has to be clicked by the person it was generated for. A link that exists for fifteen minutes is a link that survives being pasted into a chat, forwarded in an email, captured in a screenshot, written to an access log or handed to a link-preview crawler. None of those become possible; all of them become likely.

Renaming Expires on the upload policy is the same movement at a larger scale: two minutes to an hour. The upload we sent nine minutes after minting the policy went from refused to stored.

The condition list that stops existing

Renaming Conditions is the widest single arm in this area. The five megabyte ceiling became unlimited and the content-type prefix became none, so the four gigabyte blob was stored and so was the HTML payload.

Both of those have consequences beyond the byte count. An unbounded upload against a policy you minted is billed to you. And an HTML object in a bucket that serves objects on its own origin is a script that runs on that origin, which is why the content-type condition was there in the first place.

The second half of that story is the download side. Renaming responseContentDisposition removed the attachment header from the signed link, so an HTML object was rendered inline rather than downloaded, and our model's script-runs-on-bucket-origin flag went from false to true. Those two renames are in different call sites and neither is aware of the other, but together they are the upload path and the execution path for the same object.

The key constraint, and writing to the bucket root

Renaming Key is the arm we did not expect to be as bad as it is. With no key pinned in the policy, the uploader chooses the path. The upload aimed at another tenant's key was stored at u/4/avatar.png, and the upload aimed at the bucket root was stored at index.html.

A presigned upload policy without a key constraint is a write capability for the whole bucket, for as long as the policy lives. Every other guard in the policy - size, content type, expiry - continues to work exactly as configured; it is only the question of where that has quietly become the uploader's decision.

Renaming the whole policy object at once, which is what a pattern scoped to that block does, produced all of it together: an hour of validity, no size ceiling, no type constraint, no key constraint, and five of the six uploads stored rather than one.

An arm that looks bad and is not, and one that looks fine and is

Renaming Fields or the ACL key inside it changed the reported ACL from private to bucket-default(private). The effective permission is identical, because this bucket's default happens to match what the policy was pinning. Nothing is broken.

That arm is worth reporting precisely because the label moved and the meaning did not. It would be easy to read the diff as a finding. It is also worth reporting because it is fragile: the same rename against a bucket whose default is public-read would have published every uploaded object, and nothing in the application would look any different. The safety here belongs to the bucket configuration, not to the code.

The genuinely inert arm was the acceleration flag, pinned to the same value as the SDK default. No change on either output target, as expected.

When your own code stops being able to read the answer

Renaming the fields on what the presigner returns produced a result that is easy to misread. The time to live read as undefined and the derived minutes as NaN, which is visible. But the intended upload printed as refused while the reason field still said stored.

The object was stored. The storage layer did exactly what the policy told it to do. What changed is that the application's own accept-or-reject bookkeeping read a field that was no longer there and recorded a refusal for a write that happened.

That is a reconciliation failure rather than a security failure, and it is the kind that surfaces weeks later as orphaned objects, missing database rows or a retry loop that uploads the same file repeatedly because it never believes it succeeded.

What to do about it

Exclude the option names your presigner reads and the fields you read back off it. Member renaming is optional and scoped by an expression you write, so this costs nothing but the pattern.

Then assert the narrowness, not the existence. A test that mints a link and checks it works proves nothing here, because every arm above still produced a working link. A test that mints a link and asserts its expiry is 60 seconds catches one arm. A test that submits an oversized upload against your own policy and asserts a refusal catches another. A test that submits an upload aimed at a key you did not authorise catches the worst one.

It is also worth setting the boring backstops that make a lost condition survivable: a bucket policy that refuses public ACLs regardless of what a presigned form asks for, a content-type allowlist enforced at the bucket rather than only in the policy, and lifecycle rules that cap what an unbounded upload can cost you.

Frequently asked questions

Does obfuscation break presigned URLs?

Not by itself. Protection alone, on all five presets we tested including both output targets and the compressed profile, produced behaviour identical to the unprotected file. Every result here required member renaming pointed at the option names the presigner reads.

How much longer does a link live if the expiry option is renamed?

Fifteen times longer in our measurement: sixty seconds became the SDK's 900 second default. An upload policy pinned to two minutes became the SDK's one hour default, and an upload arriving nine minutes late was accepted.

What happens if the condition list is renamed?

It stops existing. The five megabyte ceiling became unlimited and the content-type prefix became none, so a four gigabyte blob and an HTML payload were both stored against a policy that had been minted to accept neither.

Why does the key constraint matter so much?

Because without one the uploader chooses the path. In our measurement an upload was stored under another tenant's key, and another was stored as index.html at the bucket root. Every other condition in the policy carried on working; only the question of where had become somebody else's decision.

Was there an arm that looked like a finding but was not?

Yes. Renaming the fields block changed the reported ACL label while the effective permission stayed private, because this bucket's default matches what the policy was pinning. That safety belongs to the bucket configuration rather than to the code, and would not hold on a bucket whose default is public.

What should I exclude from member renaming?

The option names passed to the presigner - expiry, conditions, key, fields and the response headers - and the fields on the object it returns, which your own bookkeeping reads.

What backstops help if a condition is lost?

A bucket policy that refuses public ACLs whatever a presigned form asks for, content-type enforcement at the bucket as well as in the policy, and lifecycle rules that bound the cost of an unbounded upload. Those hold regardless of what the minting code sends.

Related reading