Skip to main content

A PNG Filename Is Not an Upload Policy

Updated 6 min readMarkdown
A PNG Filename Is Not an Upload Policy

An upload endpoint accepts a file called avatar.png, saves it under that name and returns its public URL. It has checked the extension. It has not yet established that the bytes are an image, that processing them is affordable, or that another user should be able to retrieve them.

A robust upload design treats acceptance, processing and publication as separate decisions. Define a narrow input policy, receive files into private quarantine, inspect them under resource limits, then publish only the approved representation. Keep authorisation on both upload and retrieval.

This is a design walkthrough for ordinary application uploads, using a profile-image service as the worked example. It is not a complete malware detection system, and a successful image decode is not a general certificate of safety.

Begin with what the product actually needs

“Allow images” leaves too many questions unanswered. Does the service need animated images? Original camera files? SVG? Metadata? Very large panoramas? Each answer changes the processing and serving paths.

For an illustrative avatar service, choose a smaller contract: authenticated users can submit JPEG or PNG; the service produces a static, square JPEG derivative; originals remain private and are deleted according to a documented retention policy. These are example product choices, not universal secure-upload limits.

That decision removes entire classes of ambiguity. A user is uploading source material for a transformation, rather than receiving permission to host arbitrary bytes at the application's origin.

The browser can help people choose a file:

<input type="file" name="avatar" accept="image/jpeg,image/png">

But the HTML accept attribute is a picker hint, not server validation. A client can submit a request without using your page at all.

For cookie-authenticated uploads, require the framework's established CSRF protection when receiving the request. Authentication and upload permissions establish who may upload; format validation checks the input. None of those checks alone establishes that the signed-in user intended this submission. OWASP's upload guidance includes CSRF protection alongside the file-specific controls.

Give every limit a place to act

An extension check does not cap memory use. A byte limit does not cap decompressed dimensions. A pixel limit does not cap the time an unusually troublesome decoder spends on an input.

Use a budget at each layer:

Boundary Example policy question
Reverse proxy and request reader How many total request bytes may arrive?
Multipart parser How many files and form fields may be parsed?
Quarantine store How much temporary storage can one user consume?
Image decoder What dimensions, frame count, memory and processing time are acceptable?
Published derivative What output dimensions, format and byte size will be served?

In Flask, MAX_CONTENT_LENGTH limits the request payload. It includes multipart overhead; it is not the same thing as a per-file limit. Align it with the proxy's policy so users get a predictable rejection before the expensive stage.

An image's compressed size also says little about the memory needed to decode it. Pillow documents pixel-count warnings and errors for decompression bombs. Keep those protections, choose an application limit appropriate to the task, and isolate processing so a parser problem has a bounded effect. Disabling warnings to make an awkward upload succeed erases evidence of the constraint.

Keep the filename out of storage authority

Treat the supplied filename as display metadata. Generate the actual storage key on the server and record the mapping in your database. A filename should not decide a filesystem path, overwrite target, tenant or public URL.

For example, an upload record could carry:

{
  "upload_id": "u_example",
  "owner_id": "user_example",
  "state": "quarantined",
  "original_name": "holiday.png",
  "private_object_key": "generated-server-key",
  "published_object_key": null
}

The identifiers are illustrative. The point is the relationship: ownership and storage authority come from server-controlled records, while the original name is merely a label.

Normalising a filename is still useful when displaying it or generating a download response. It does not replace the generated storage key or permission check. Avoid reflecting a raw filename into HTML, logs or response headers without the encoding required by that destination.

Inspect bytes, then produce a known output

The upload's Content-Type is a client claim. A file signature is one clue. A parser's successful interpretation is another. OWASP's upload guidance recommends layered checks because no one of these establishes safety by itself.

For the avatar contract, the processor should identify an allowed format from the received bytes, decode under its limits, reject unsupported dimensions or frame behaviour, then create a fresh derivative in the chosen output format. Handle orientation deliberately and decide what metadata the output should retain. Those are product and privacy choices as well as image-quality choices.

Do not publish the original merely because a thumbnail was generated successfully. That would bypass the narrower output contract. Equally, do not describe re-encoding as infallible sanitisation: the decoder itself remains software that needs updates, isolation and failure handling.

Antivirus or specialised content inspection may be appropriate for document uploads. Check where those files go. Sending a private document to a public scanning service is a separate disclosure decision, not an invisible implementation detail.

Make publication a state transition

A useful lifecycle is quarantined → processing → approved or rejected. Only approved records receive a published-object key. The retrieval path checks that state and the caller's access before returning a private asset or issuing a short-lived download URL.

Keep temporary and published objects in separate namespaces or stores. A guessed quarantine URL must not become an alternative download endpoint. The serving system should not execute uploaded content as application code, and user-controlled active content should not share the privileges of your application's origin.

The derivative and its database record also need reconciliation. A worker can fail after writing an object but before recording approval. Prefer an orphaned private object that a cleanup job can remove over an unreviewed public asset. Make retries recognise the upload identity rather than creating unlimited duplicates.

Test the rejected path as carefully as the happy path

Build fixtures for ordinary JPEG and PNG inputs, mismatched names and bytes, unsupported formats, empty files, oversized requests, excessive dimensions, duplicate submissions and processing failures. Use harmless fixtures in your own test environment; the objective is to verify policy enforcement, not collect live malware.

Then test permissions independently. User A should not read user B's private original, approve B's upload, replace its object key or retrieve a rejected file through a secondary route. A successful format check does not answer any of those questions.

Finally, confirm the browser receives the derivative's intended media type and cache policy, and that a failed processor leaves no publicly reachable original. The acceptance test is the complete lifecycle, from receipt to retrieval, rather than whether one function returned true.

References

1 view

About the author

  • We are a small team of developers with one goal in mind. Pushing low spec PCs to the limit.

No comments yet. Be the first to comment.