Cybersecurity

File Upload Validation in Python: Beyond the Extension

A practical guide to validating file uploads by real content in Python — magic bytes for PDF and audio, safe ZIP inspection for DOCX, and the traps to avoid.

Long Nguyen Avatar

Long Nguyen

Fullstack Developer · AI Engineer · Researcher

3 min read
Extension checks are fooled by a renamed file while a content check reading header bytes blocks it

An Extension Is Not Validation

This class of flaw has a name: the Unrestricted File Upload vulnerability (catalogued as CWE-434), and it consistently appears in real-world breaches. Relying on the extension, or on the client-sent Content-Type header, are two of the most common ways to get it wrong — both are trivially bypassed by simply renaming a file or spoofing a header.

If your backend accepts uploads and trusts the file extension to decide what a file is, it isn't validating anything. A file's extension is just the tail of its name, and anyone can rename anything — a malicious file called resume.pdf passes an extension check untouched. To do file upload validation in Python properly, you have to read what a file actually contains, not what it claims to be called.

This guide walks through a small, dependency-free validation module: verifying PDFs and audio by their byte signatures, and DOCX by safely inspecting its archive structure. Every piece uses only the Python standard library.

Reading the Header Without Breaking Everything

Every check starts by reading the first few bytes of the file — but a validator must not consume the file, or the code that saves it afterward gets an empty stream. So the helper reads a chunk and then rewinds the pointer back to the start with seek(0):

def _read_head(f, size):
    f.seek(0)
    head = f.read(size)
    f.seek(0)
    return head

That leading seek(0) matters too: by the time a file reaches your validator, something else may have already read from it, leaving the pointer partway through. Seeking to the start before reading, and again after, makes the function safe to call at any point. The file object here behaves like any Python file — see the io docs for how seek and read work.

Validating a PDF

A real PDF identifies itself with the marker %PDF-. The subtlety is that the PDF specification allows arbitrary junk before that header — many real-world readers accept %PDF- anywhere within the first 1024 bytes rather than strictly at byte zero. So the check searches the first kilobyte instead of only the first few bytes:

def is_pdf(f):
    # The PDF spec allows junk before the header; readers accept %PDF-
    # anywhere in the first 1024 bytes.
    return b'%PDF-' in _read_head(f, 1024)

This is a good example of why "just check the first 4 bytes" advice is too naive. Formats have quirks, and matching real-world behavior — here, scanning a window rather than a fixed offset — is what separates a validator that works from one that rejects legitimate files. The signature itself is defined in the PDF specification (MDN's container reference is a handy overview of how formats declare themselves).

Validating a DOCX (the Tricky One)

DOCX is where naive signature checks fall apart. A .docx file isn't its own format — it's a ZIP archive, so it starts with the ZIP signature PK\x03\x04. But so does every other ZIP-based file: .xlsx, .pptx, .epub, a plain .zip. Confirming "this is a ZIP" is not the same as confirming "this is a Word document."

So validation happens in two steps: first the ZIP signature, then a look inside the archive for the structure a real DOCX must have — a [Content_Types].xml entry and a word/ folder:

import zipfile


def is_docx(f):
    # DOCX is a ZIP archive (PK\x03\x04) containing [Content_Types].xml
    # and a word/ folder. Checking the archive listing rejects arbitrary
    # ZIPs without decompressing anything (zip-bomb safe).
    if _read_head(f, 4) != b'PK\x03\x04':
        return False
    try:
        with zipfile.ZipFile(f) as zf:
            names = zf.namelist()
            return '[Content_Types].xml' in names and any(
                n.startswith('word/') for n in names
            )
    except (zipfile.BadZipFile, OSError, ValueError):
        return False
    finally:
        f.seek(0)

Two safety details are worth calling out. First, namelist() reads only the archive's index — it never decompresses the contents, which keeps this check safe against zip bombs (a tiny archive that expands to gigabytes). See the zipfile docs for what namelist does. Second, the broad except treats any malformed or unreadable archive as simply "not a valid DOCX" rather than letting a crafted file crash the request — and the finally rewinds the file so it's still usable afterward.

Content Must Match the Claim

With the individual checks in place, the resume validator ties them to the claimed extension. This is a subtle but important rule: a file shouldn't pass just because it matches some allowed type — it must match the type it claims to be:

from pathlib import Path


def is_valid_resume_file(f):
    """Content must match the claimed extension, not just any allowed type."""
    ext = Path(f.name).suffix.lower()
    if ext == '.pdf':
        return is_pdf(f)
    if ext == '.docx':
        return is_docx(f)
    return False

Why enforce agreement between claim and content? Because a mismatch is a red flag in itself. A file named .pdf whose bytes say DOCX — or vice versa — is either broken or an attempt at something, and neither belongs in your pipeline. The default return False also means anything not explicitly allowed is rejected: a deny-by-default posture, which is the safe direction for security code.

Validating Audio: One Format, Many Signatures

Audio is a lesson in a different trap: a single logical format can legitimately begin several different ways. Browsers producing audio through MediaRecorder emit different container formats — Chrome, Edge, and Firefox tend to produce WebM or Ogg, while Safari produces MP4/M4A — and the front end here always names the blob answer.webm regardless of what it really is. So the validator checks against the whole set of signatures the real world produces, not one:

def is_valid_audio_file(f):
    head = _read_head(f, 12)
    if head.startswith(b'\x1aE\xdf\xa3'):            # EBML -> WebM/Matroska
        return True
    if head.startswith(b'OggS'):                     # Ogg
        return True
    if head[4:8] == b'ftyp':                          # MP4 / M4A
        return True
    if head.startswith(b'RIFF') and head[8:12] == b'WAVE':  # WAV
        return True
    if head.startswith(b'ID3'):                       # MP3 with ID3 tag
        return True
    if len(head) >= 2 and head[0] == 0xFF and (head[1] & 0xE0) == 0xE0:  # raw MP3 frame sync
        return True
    return False

A few of these are worth understanding, because they show how varied signatures get:

  • MP4/M4A doesn't start at byte zero — its ftyp marker sits at offset 4, after a length field, which is why the check reads head[4:8] rather than the start.
  • WAV needs two markers: it opens with RIFF, but so do other RIFF-based formats, so you also confirm WAVE appears at bytes 8–12.
  • MP3 has two legal forms: one with an ID3 metadata tag at the front, and one that starts straight into audio with a "frame sync" — 11 bits set to 1, which the bitwise check head[1] & 0xE0 == 0xE0 detects.

That last MP3 case is the kind of detail a tutorial signature table won't warn you about: miss it, and legitimate MP3s from some encoders get rejected. Matching the messy reality of real files is the actual job.

Content Validation Is One Layer, Not the Whole Wall

This module answers exactly one question well — "is this file really the type it claims?" — with no third-party dependencies. But it's one layer in a stack, not a complete upload defense. Around it you still want a size cap enforced before reading, uploads stored outside any executable or code-served path, and files served back so a crafted name can't escape its directory. The guiding instinct is trust boundaries: a file crossing from a user into your system stays untrusted until its content proves otherwise.

Get that layering right and each individual check — like the ones above — slots into a defense that holds up against real, adversarial input rather than just well-behaved test files.

It's also worth knowing the limit of content validation itself: a polyglot file — one crafted to be simultaneously valid as two different formats — can carry legitimate magic bytes for an allowed type while still hiding something dangerous. This is exactly why serious guidance layers defenses rather than trusting any single check: magic-byte validation, plus size caps, plus storing uploads outside executable paths, plus (for images) re-encoding to destroy embedded content. The OWASP File Upload Cheat Sheet is the reference worth reading in full.

FAQ

Frequently asked questions

Why isn't checking the file extension enough to validate an upload?

An extension is just part of the filename, and anyone can rename a file. A malicious file renamed to end in .pdf passes an extension check unchanged. Only reading the file's real content — its byte signature — reveals what it actually is.

How do you validate a DOCX file in Python?

A DOCX is a ZIP archive, so you first confirm the ZIP signature (PK\x03\x04), then inspect the archive's listing for a [Content_Types].xml entry and a word/ folder. Reading only the archive index with namelist() avoids decompressing anything, which keeps it safe from zip bombs.

Why does the audio validator check so many different signatures?

A single logical audio format can legitimately start several ways. Browsers emit different containers via MediaRecorder — WebM, Ogg, MP4/M4A — and MP3 alone has two valid forms. A correct validator accepts the full set real encoders produce, not one signature from a tutorial.

Is magic-byte validation enough for secure uploads on its own?

No. It answers 'is this the claimed type?' well, but robust upload handling also caps file size before reading, stores files outside executable paths, and serves them back safely. Content validation is one important layer in a larger defense.

Stay updated with Netalith

Get coding resources, product updates, and special offers directly in your inbox.