Treat an upload as untrusted bytes, not a file type. Cap the size while streaming, store it privately under a server-generated name, decode only the formats the feature needs, check decoded dimensions before full decoding, and publish a newly encoded copy. Bind approval to the content digest, authorize every retrieval, and serve user files from a separate origin with Content-Disposition: attachment and nosniff. Extensions and Content-Type headers are hints, not proof.
- The extension, the
Content-Typeheader and magic bytes are attacker-controlled hints; decoding with an allowlist of formats decides. - Enforce the byte cap while reading, and a separate decoded-pixel cap before full decoding.
- Quarantine first: nothing is public until processing succeeds for the exact digest being published.
- Store under a random server-side id and keep the original filename only as escaped metadata.
- Serve user content from another origin with
attachment,nosniffand a sandboxing CSP; never render uploaded SVG or HTML inline in the application origin.
| Setting | Value | Note | Source |
|---|---|---|---|
| Upload size | A per-feature cap enforced while streaming; 2,097,152 bytes in this example | Stop reading at the limit plus one byte. | OWASP ASVS v5.0.0 V5.2.1 |
| Decoded image size | A width × height cap checked before full decoding; 12,000,000 pixels in this example | Pillow's Image.MAX_IMAGE_PIXELS default is 89,478,485: above it Pillow warns, above twice it raises DecompressionBombError. | OWASP ASVS v5.0.0 V5.2.6; Pillow |
| Stored object name | A server-generated random id, such as secrets.token_urlsafe(16) | Never use the submitted filename as a path. | OWASP ASVS v5.0.0 V5.3.2 |
| Download response headers | Content-Disposition: attachment, X-Content-Type-Options: nosniff and the verified Content-Type | — | OWASP ASVS v5.0.0 V5.4.1 and V3.4.4 |
| Inline display of user files | A separate origin plus Content-Security-Policy: sandbox | SVG and HTML can carry script; rasterize or sanitize them first. | OWASP File Upload Cheat Sheet |
| Archive uploads | Rejected unless the feature needs them; otherwise cap entries and uncompressed bytes before extraction | — | OWASP ASVS v5.0.0 V5.2.3 |
- Upload exactly 2,097,152 bytes and then 2,097,153 bytes; the second must stop at the extra byte and create no object.
- Upload 4000×3000 and 4001×3000 images; only the first may publish.
- Upload HTML or SVG renamed to
.jpgwithContent-Type: image/jpeg; the decoder must reject it. - Replace a quarantined object after approval and confirm the new bytes are never published.
- Fetch quarantined, rejected and pending objects through every URL, including thumbnails and signed links; every request must fail.
- Applies to
Services accepting files or archives. Decoder safety, malware inspection, and storage isolation require deployment-specific validation.
- Part of
- Learning path: Developer foundations (step 5 of 13). Workflows: Fix a finding, Secure a feature
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Distinguish byte, format and decoded-resource checks.
- Bind approval to immutable content.
- Prove rejected or pending content remains private.
Before you start
Developers building upload features, and reviewers checking how uploaded files are stored, processed and served.
- Trace a request body from temporary storage to the eventual download response.
Refresh first: Enforce authorization for every object action
Go to the guided practiceAn upload is a sequence of trust decisions, not a single extension check. The client controls the filename, claimed media type, bytes and transmission pace. A storage service can faithfully store those bytes while a later previewer, browser or document parser interprets them differently. The security boundary must cover receipt, inspection, processing, publication, retrieval and eventual deletion.
Consider a profile-photo feature that needs only JPEG and PNG images, at most 2 MiB compressed, at most 12 megapixels decoded, and one published image per user. It does not need SVG, HTML, archives or the original filename as a filesystem path. Those decisions remove entire parser capabilities. A small compressed image may still require excessive decoded memory, so a byte cap and a pixel cap address different resources. An extension and a Content-Type header are useful consistency signals but are controlled by the sender and cannot establish the actual format.
Accept the stream into a private quarantine object with a server-generated id. Enforce the incoming byte budget while reading, before exhausting storage. Store owner, received length and a digest beside it. A worker with bounded CPU, memory and time decodes an allowed format, checks dimensions, strips unnecessary metadata and writes a newly encoded image. Publish the newly produced object only after all required checks succeed. A timeout or missing verdict leaves the upload unpublished. Framework-specific fixes are at /fix/file-upload/.
Bind inspection to the exact object version or digest that will be published. Otherwise a client can upload safe bytes, obtain approval and replace them with different bytes before release. Separate receipt credentials from publication credentials. The public read path must check the object's approved state and any required ownership policy, including thumbnails and temporary URLs. A signed URL to quarantine still bypasses quarantine if anyone possessing it can fetch the bytes. Do not put originals in the public bucket and hope an asynchronous scanner removes them quickly enough.
The Python program below implements receipt and processing for the photo contract with Pillow. receive reads at most one byte past the cap, so a 2,097,153-byte stream is rejected without being stored, and names the object with a random id. process opens the bytes with formats=("PNG", "JPEG"), rejects more than 12,000,000 pixels from the header before img.load() decodes the pixel data, and writes a new image that does not copy the original metadata. Install Pillow, save the file as upload_pipeline.py and run python3 upload_pipeline.py; it prints PASS: format, pixel, byte and metadata checks. The lab at /labs/path-traversal-python/ practices serving stored uploads without path traversal.
- States:
RECEIVING→QUARANTINED→PROCESSING→PUBLISHED, orREJECTEDat any step. - A processing timeout returns the object to
QUARANTINEDfor a bounded retry, or moves it toREJECTED. - A public
GETserves only the generated output of aPUBLISHEDobject. - Approval of digest
d1forq17@v1cannot publishq17@v2with digestd2.
# Requires Pillow (pip install Pillow). Run: python3 upload_pipeline.py
import hashlib
import io
import secrets
from PIL import Image
MAX_BYTES = 2 * 1024 * 1024 # 2 MiB = 2,097,152 bytes
MAX_PIXELS = 12_000_000
FORMATS = ("PNG", "JPEG")
class Rejected(Exception):
pass
def receive(stream):
"""Read into private quarantine; never read more than MAX_BYTES + 1 bytes."""
data = stream.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise Rejected("too_large")
return {"id": secrets.token_urlsafe(16), # never the client's filename
"sha256": hashlib.sha256(data).hexdigest(), "data": data}
def process(item):
"""Decode an allowed format, check dimensions before decoding pixels, re-encode."""
try:
with Image.open(io.BytesIO(item["data"]), formats=FORMATS) as img:
if img.width * img.height > MAX_PIXELS:
raise Rejected("too_many_pixels")
img.load()
out = io.BytesIO()
img.save(out, format=img.format) # new file; metadata is not copied
except (Image.UnidentifiedImageError, Image.DecompressionBombError, OSError):
raise Rejected("not_an_allowed_image") from None
return {"source": item["sha256"], "format": img.format, "data": out.getvalue()}
def png(width, height, **info):
from PIL import PngImagePlugin
meta = PngImagePlugin.PngInfo()
for key, value in info.items():
meta.add_text(key, value)
out = io.BytesIO()
Image.new("RGB", (width, height)).save(out, format="PNG", pnginfo=meta)
return out.getvalue()
def rejects(data, reason):
try:
process(receive(io.BytesIO(data)))
except Rejected as e:
assert str(e) == reason, e
else:
raise AssertionError(f"accepted, expected {reason}")
ok = process(receive(io.BytesIO(png(800, 600, Comment="gps=51.5,-0.1"))))
assert ok["format"] == "PNG" and b"gps=" not in ok["data"]
assert process(receive(io.BytesIO(png(4000, 3000))))["format"] == "PNG" # exactly 12,000,000
rejects(png(4001, 3000), "too_many_pixels")
rejects(b"<html><img src=x onerror=alert(1)></html>", "not_an_allowed_image")
gif = io.BytesIO()
Image.new("RGB", (8, 8)).save(gif, format="GIF")
rejects(gif.getvalue(), "not_an_allowed_image")
assert len(receive(io.BytesIO(b"\0" * MAX_BYTES))["data"]) == 2_097_152
try:
receive(io.BytesIO(b"\0" * (MAX_BYTES + 1)))
raise AssertionError("accepted 2,097,153 bytes")
except Rejected as e:
assert str(e) == "too_large"
print("PASS: format, pixel, byte and metadata checks")In this example a 2 MiB limit means 2,097,152 bytes and the image dimension rule means width × height must not exceed 12,000,000 pixels. Test exactly those boundaries rather than only a convenient large file. The valid case must produce a usable image; the rejected cases must create no public object. A response saying upload accepted may mean only that private receipt completed, so distinguish accepted-for-processing from published.
Instrument the decoder process and storage transitions in a staging system using synthetic files. Confirm cancellation kills abandoned processing, temporary objects expire and repeated failures cannot grow an unbounded retry queue. For the paper exercise below, supplied decoder results stand in for file bytes so you can evaluate the state machine without manufacturing image files. They establish the design decision only; a deployed decoder also requires package maintenance and integration checks against actual formats.
Re-encoding can remove unsupported active content and metadata, but the decoder itself processes attacker-controlled input. Isolating the worker limits the consequences of a parser flaw. Antivirus may identify known malicious content; a clean verdict is not proof of harmlessness. Keep the narrow allowlist and publication gate even when scanning is enabled. Download disposition, a separate content origin and nosniff can reduce browser interpretation risks, but cannot authorize who may read a private file.
Serve uploaded content so the browser cannot treat it as part of your application. Use a separate origin for user files, send Content-Disposition: attachment for downloads, X-Content-Type-Options: nosniff with the verified Content-Type, and Content-Security-Policy: sandbox for anything displayed inline. SVG is XML that can carry script, so rasterize it, sanitize it with an SVG-aware allowlist or serve it only as an attachment. A polyglot file is valid as two formats at once and passes magic-byte checks, which is why this pipeline decodes and re-encodes instead of trusting signatures; content disarm and reconstruction applies the same idea to office documents and PDFs, and /domains/appsec/secure-features/untrusted-document-conversion/ shows how to convert those in an isolated parser. If the feature accepts archives, apply /domains/appsec/secure-features/archive-path-safety/; the archive-extraction project linked below tests those rules.
Operational failures often appear as stuck uploads or mismatched versions. Expose a stable processing status to the owner, record the failed stage and retry count, and keep sensitive original bytes out of logs. A quarantine-retention policy needs enough time for investigation without keeping rejected personal documents indefinitely. Backpressure should reject new work predictably when capacity is exhausted; silently skipping inspection to reduce queue length converts an availability incident into a security failure.
CVE-2021-21972 was an unauthenticated file upload in the VMware vCenter Server vSphere Client plugin that let an attacker with network access to port 443 write a file and execute commands with unrestricted privileges on the host operating system. The missing controls were validating the upload and storing it where the web server will never execute it.
The pipeline can be correctly sandboxed yet still publish the wrong person's object. Suppose Alice receives upload id U1, then submits finishUpload(U2) where U2 belongs to Bob. A finish endpoint that merely finds a clean verdict for U2 may attach Bob's image to Alice's profile. Require the authenticated actor to own the upload session, verify the immutable content version, and bind the resulting published object to the intended feature. A scanner's clean verdict answers content-policy questions; it does not answer who may publish or retrieve the object.
Direct-to-storage uploads require the same discipline. Limit the issued upload capability to its object key, method, size constraints where supported and lifetime; an Amazon S3 presigned POST can carry the size limit in its policy as ["content-length-range", 1, 2097152]. The completion endpoint should verify actual stored properties and ownership rather than trusting client-reported length or digest. If a client can replace the same key after scanning, publication should use an immutable version or copied generated output. Changing the object key to a random string helps prevent collisions but does not establish ownership or immutability.
Consider concurrency too. If Alice uploads U1 and U2 and the older worker finishes last, an unconditional profile update can replace her newer choice. Bind publication to the current selected upload or compare a profile revision before committing. The old worker should mark its own processing complete without overriding a newer user decision. This is a correctness issue with privacy consequences when objects have different visibility or retention expectations.
Guided practice
Apply a complete photo-upload contract to supplied receipt and decoder evidence.
Supplied scenario material
Policy: JPEG/PNG only; bytes <=2097152; pixels <=12000000; publish only newly encoded output of approved object version.
A: a.png, header image/png, 1000 bytes, decoder PNG 800x600, success, q1@v1=d1.
B: b.jpg, header image/jpeg, 900 bytes, decoder HTML, q2@v1=d2.
C: c.png, 2097152 bytes, decoder PNG 4000x3000, success.
D: d.png, 2097153 bytes; stream still arriving.
E: e.png, 1000 bytes, decoder PNG 4001x3000, success.
F: f.png, 1000 bytes, decoder timeout.
G: q1@v1=d1 approved, but current source q1@v2=d9.1. Classify ordinary and disguised content
Decide A and B using decoder identity rather than submitted names.
Expected result and reasoning
A may proceed to re-encoding and publication. B is rejected because HTML is outside the feature contract despite its JPEG extension and header.
2. Calculate boundaries
Calculate the pixel counts for C and E; decide C, D and E.
Expected result and reasoning
C is exactly 12,000,000 pixels and exactly the byte limit, so it may publish after processing. D stops at byte 2,097,153. E is 12,003,000 pixels and is rejected. None of these decisions needs an arbitrary safety margin.
3. Resolve incomplete and stale verdicts
Decide the public state of F and G.
Expected result and reasoning
F remains private; retry is bounded or it becomes rejected. G cannot use
d1approval ford9. It must process the selected immutable version, then publish only its generated output.4. Audit retrieval
A user requests q2, a thumbnail of F and a signed link to G before approval. State the result for each.
Expected result and reasoning
All three are unavailable through the public retrieval path. Hiding the main UI or returning a processing message does not suffice if an alternate URL returns the raw object.
Paper exercise: decoder results stand in for real image files, so no files or services are needed. The Python program above tests the same byte, pixel and format rules on generated images.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
Path traversal lab: Serve an uploaded file without path traversal
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Keep extracted files inside their boundary
Inspect actual ZIP entries and repair a path policy. Test traversal, links, duplicate entries, expansion limits and ordinary nested files.
In the extracted folder, run:
python3 -m unittest test_archive.py7 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
Temporary directories, generated ZIPs and an independent containment guard keep deliberately broken policy code inside the exercise.
Convert untrusted documents in a locked-down container
Repair a document worker that drives LibreOffice and Ghostscript in Docker, until a zip bomb and DTD payloads stop at preflight, the container has no network or writable root, and a hung conversion is killed with its container.
Needs: The live tests need Docker with Linux containers, and internet access once to build the converter image (about 200 MB of Debian packages). The offline tests need only Python.
In the extracted folder, run:
python3 -m unittest -v test_worker.py
docker build -t doc-converter .
python3 -m unittest -v test_live.py12 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
The offline tests use a stand-in for the docker CLI. The 8 live tests ran against LibreOffice 25.2.3 and Ghostscript 10.05.1 in Docker 29.4.3: zip bomb, XXE, network and timeout cases. It is not content disarm and reconstruction, and output checks do not inspect PDF content.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP File Upload
- OWASP Input Validation
- OWASP ASVS 5.0.0: V5 File Handling. Version 5.0.0.
- Pillow: Image.open and decompression bomb protection. Pillow 12.3.0.
- Amazon S3: POST policy (content-length-range)
- MITRE CWE-434: unrestricted upload of file with dangerous type. CWE 4.20.
- NVD: CVE-2021-21972, file upload to RCE in VMware vCenter
Reference information
- SDLC phase
- DesignDevelopment
- ASVS 5.0.0 requirements
- v5.0.0-5.2.1 · v5.0.0-5.2.2 · v5.0.0-5.2.6 · v5.0.0-5.3.2 · v5.0.0-5.4.1
- OWASP WSTG v4.2 tests
- WSTG-BUSL-08 · WSTG-BUSL-09