Does the File Match Its Name? A Layered Decision Table for Uploads
A file called logo.png can be anything. Trusting the extension alone means a script, a broken file or the wrong format may reach your storage and your staff. This article proposes a layered decision table. The layers reduce risk. None of them guarantees a file is harmless, and the design should be reviewed against your own platform.
Layers
- Size. Reject empty files and files over a configured limit before any further work.
- Extension allowlist. Only named extensions pass. This is a filter on intent, not on content.
- Declared type. The type the browser sent is recorded, but treated as a claim from the sender.
- Content signature. Read the first bytes and compare them with the known signature of each allowed format.
- Structural parse. For formats you will process, such as images or documents, run a bounded parse in an isolated step with time and memory limits. A failed parse means reject.
- Storage and serving. Store under a generated name, outside any location that executes files, and serve with a fixed content type that you choose from the detected type rather than from the original name.
Decision table
Extension allowed, signature matches extension, parse passes: accept. Extension allowed, signature belongs to a different allowed format, parse passes: accept as the detected type, store with the extension that matches the detected type, and tell the uploader. Extension allowed, signature matches, parse fails: reject as damaged. Extension allowed, signature not in the allowed set: reject. Extension not allowed: reject, whatever the content. Empty or oversize: reject before reading further.
Two more rules: a rejection message may name the reason class (type, damage, size) but must not echo the file's contents. Do not rename and retry automatically.
Worked hypothetical example
The form accepts PNG, JPEG and PDF, with a limit configured at 5 MB. Six uploads arrive:
- logo.png, real PNG content, parses: accept.
- photo.jpg, but the content is a PNG and parses: accept as PNG, stored as a png file, uploader told the type was corrected.
- brochure.pdf, starts like a PDF but the parse fails: reject as damaged.
- invoice.pdf, contents are plain script text: signature not allowed, reject.
- image.png, 0 bytes: reject at the size layer.
- archive.zip: extension not allowed, reject.
Totals: two accepted (items 1 and 2) and four rejected (items 3 to 6). Six uploads, two plus four. The report should show those counts.
Stop conditions
- If the parse step exceeds its time or memory bound, stop it and reject. Do not retry with larger limits automatically.
- If the storage step cannot confirm the write, report the upload as unconfirmed. Do not claim success, and do not silently repeat any external write whose outcome is uncertain. Let the uploader or a person decide.
- If the allowlist configuration is missing, reject everything rather than accept everything.
What this does not cover
A file can be valid in format and still unwanted in meaning. Format checks do not examine whether a document contains misleading text or sensitive material. Treat accepted files as untrusted when staff open them, and keep your serving rules as fixed constraints. Add malware scanning only if you have a vetted way to run it, and describe it as one more layer.
Proposed acceptance tests (not yet performed)
- Submit the six sample uploads and check two accepts and four rejects.
- Rename an allowed script to .png and check it is rejected at the signature layer.
- Submit a truncated PDF and check the parse layer rejects it.
- Submit a 0 byte file and a file one byte over the limit and check both are rejected at the size layer.
- Check the stored name is generated and the served type follows the detected type, not the original name.
- Remove the allowlist setting in a test environment and check nothing is accepted.
Limits
Signatures are short prefixes and can be forged. That is why the table pairs them with a parse, isolated handling and fixed serving rules. Say "checked against our rules", not "safe", in the interface.