ASVS 5.0 V5.2.2 (level 1) requires that when the application accepts a file, on its own or inside an archive such as a zip, it checks that the extension is one it expects and that the content really matches the type that extension claims. That includes checking magic bytes, re writing images, and using specialized libraries for content validation.
At level 1 this can focus on files used to make business or security decisions. From level 2 up, it applies to every file the application accepts.
Why it matters
Filenames and the Content-Type header come from the client, so they prove nothing. An attacker renames shell.php to photo.png, or uploads an HTML file with an image content type to get stored script execution in a viewer. A spreadsheet import that trusts the extension may hand a crafted file to a parser with a known memory corruption bug. See CWE-434 for the unrestricted upload weakness.
A single check is not enough. Extension only fails against renamed files. Magic bytes only fail against polyglots, where a file is valid as two formats at once, such as an image with a script appended. That is why the standard lists several techniques, and why image re writing is so useful: the output is something you generated, not something they sent.
How to implement it
Combine four steps and fail closed.
- Allowlist extensions. Normalize case, take only the last extension, reject double extensions you do not expect, and reject names with no extension.
- Check magic bytes. Read the first bytes and compare with the signature for the claimed type.
- Parse with a real library. If the library cannot open it, it is not that format.
- Re write when you can. For images, decode and re encode to strip anything extra.
A Node example for a PNG or JPEG avatar using file-type and sharp:
import { fileTypeFromBuffer } from 'file-type';
import sharp from 'sharp';
import path from 'node:path';
const ALLOWED = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg' };
export async function acceptAvatar(originalName, buffer) {
const ext = path.extname(originalName).toLowerCase();
const expected = ALLOWED[ext];
if (!expected) throw new Error('extension not allowed');
const detected = await fileTypeFromBuffer(buffer);
if (!detected || detected.mime !== expected) throw new Error('content does not match extension');
// Re-encode: drops metadata and any appended payload
return sharp(buffer, { limitInputPixels: 16_000_000 }).rotate().webp({ quality: 85 }).toBuffer();
}
For archives, apply the same checks to each entry before it is written, and to office files, use a format aware parser. Store the result under a name you generate, serve it with the type you decided and X-Content-Type-Options: nosniff, and keep user uploads on a separate domain where practical.
Choosing libraries and handling office files
Pick a detection library that reads the content, not the name, and keep it updated. In Python, python-magic reads libmagic signatures, and Pillow parses images properly. In Java, Apache Tika detects types from content. In Go, http.DetectContentType looks at the first 512 bytes. None of these alone decides safety, they feed your allowlist.
Office documents and PDFs deserve extra care. A docx is a zip, so apply the archive limits from V5.2.3 first, then check that the expected parts exist, such as [Content_Types].xml and word/document.xml. Reject files that contain macros or embedded objects if you do not need them: macro enabled formats use different extensions and a vbaProject.bin part. For PDFs, consider converting to images or to a flattened PDF in a sandbox, which discards active content.
import zipfile
def looks_like_docx(path):
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
if '[Content_Types].xml' not in names or 'word/document.xml' not in names:
return False
if any(n.endswith('vbaProject.bin') for n in names):
return False
return True
Run parsers in a separate low privilege process or container with CPU, memory and time limits, because parsers are where memory bugs live. That also reduces the blast radius of a vulnerable parsing library. Keep the libraries patched, and scan them regularly, for example with a free dependency scan at gold.safeguard.sh/scan, since image and document parsers regularly appear in advisories.
Decide what happens on rejection: return a plain message, log the reason and a hash of the file, and do not keep the content. If the same user triggers many rejections, slow them down.
How to test it
Build a small set of hostile samples and keep them in your test suite.
photo.pngthat is really a text file or an HTML page.- A valid PNG with a script or PHP code appended after the data.
- A file named
report.pdf.exeandimage.png%00.php. - A zip containing a file whose extension and content disagree.
- A file with a valid extension and an empty body.
- Mixed case, such as
IMAGE.PnG, and trailing dots or spaces.
Each must be rejected, or, for the appended payload case, come out clean after re encoding. For the re encoded case, assert that the output has no trailing bytes and that metadata is gone. Run the suite on every change to the upload code.
Common mistakes
- Trusting the
Content-Typeheader, which the client chooses. - Checking the extension with
endsWithon a blocklist instead of an allowlist. - Checking magic bytes only, which polyglot files defeat.
- Validating the archive file but not the files inside it.
- Saving the original file and serving it, instead of the re encoded copy.
- Using the original filename in storage, which opens the door to path problems (see V5.3.2).
Quick check
Why is the Content-Type header not a valid check?
The client sets it, so an attacker can claim any type. Check extension, magic bytes and real parsing on the server.
What does re encoding an image give you?
The output is produced by your own code, so appended payloads and hostile metadata in the original are dropped.