Skip to content
datahacks.dev
Excel

Checking an Excel Upload Before Reading It Into Code

Check an Excel upload before parsing: size, name, zip signature, required parts, macro parts, sheet names and header row, in a fixed order with clear errors.

7 min read
Summarize in AI
Flat illustration of a spreadsheet grid with a selected cell and a formula bar.

An upload endpoint receives a file called orders.xlsx. The extension says spreadsheet, the browser says application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, and the user says it is the monthly orders. Each of those is a claim, and none of them is a fact. This article is the checklist that turns claims into facts, in the order that rejects bad files most cheaply. It is the front gate of the pipeline described in Excel security problems developers overlook, and it hands over to opening untrusted Excel files safely in a pipeline for the parsing itself.

Put the checks in order of cost

Run the cheap checks first and the expensive ones last. A file that fails the first test should never reach the fourth.

  1. Size. One integer comparison.

  2. Name. Normalize and constrain the name, and generate your own storage name.

  3. Signature. Read the first few bytes.

  4. Archive directory. Read the zip's table of entries.

  5. Required and forbidden parts. Look at names and the content types file.

  6. Workbook structure. Parse workbook.xml for sheet names.

  7. Template match. Read the header row of the expected sheet.

  8. Values. Validate cells, as ordinary input, row by row.

Steps one to six need no spreadsheet library and no large allocation, which is the point.

Step one and two: size and name

Reject by size before reading the body into memory. The proxy limit is the first guard and your application checks again.

Python
import os, re, uuid

MAX_BYTES = 25 * 1024 * 1024

def check_size(path):
    if os.path.getsize(path) > MAX_BYTES:
        raise Reject("too_large", "The file is larger than 25 MB.")

def safe_original_name(name):
    base = os.path.basename(name)
    return re.sub(r"[^A-Za-z0-9._ -]", "_", base)[:100]

stored_name = f"{uuid.uuid4().hex}.xlsx"

Keep the cleaned original name for display and logs only. The stored name is your own, with the extension you chose. A name from the client never becomes a path. Reject files whose original extension is not .xlsx with a message that suggests Save As, but do not rely on the extension for anything else.

Step three: the signature

An xlsx is a zip, and every zip begins with the two bytes PK. A legacy .xls file begins with the Compound File Binary signature D0 CF 11 E0 A1 B1 1A E1, which is also what an encrypted modern workbook looks like. Read eight bytes and decide.

Python
def read_signature(path):
    with open(path, "rb") as f:
        head = f.read(8)
    if head[:2] == b"PK":
        return "zip"
    if head == bytes.fromhex("D0CF11E0A1B11AE1"):
        return "cfb"
    return "unknown"

A cfb file is either a legacy workbook or an encrypted one. Policy decides what happens: reject legacy formats, and route encrypted files to the password path if you support it (password protected Excel files). unknown is a rejection: the sender uploaded a CSV, an image or a renamed text file, and the message should say what was expected.

Step four: the archive directory

Apply the limits from zip bombs and oversized XLSX uploads: entry count, total uncompressed size, ratio, nested archives and unsafe paths. These read only the directory. Catch zipfile.BadZipFile and report it as "not a valid xlsx file"; do not include the exception text, which can reveal internals.

Step five: required and forbidden parts

A real xlsx has certain parts, and a few parts should never appear in an upload.

Python
import zipfile

REQUIRED = {"[Content_Types].xml", "xl/workbook.xml"}
FORBIDDEN_PREFIXES = ("xl/vbaProject", "xl/externalLinks/", "xl/embeddings/", "xl/activeX/")
MAIN_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"

def check_parts(path):
    with zipfile.ZipFile(path) as z:
        names = set(z.namelist())
        missing = REQUIRED - names
        if missing:
            raise Reject("not_xlsx", "This does not look like an Excel workbook.")
        for n in names:
            if n.startswith(FORBIDDEN_PREFIXES):
                raise Reject("active_content", "The workbook contains macros, links or embedded objects.")
        ct = z.read("[Content_Types].xml").decode("utf-8", "replace")
        if MAIN_TYPE not in ct:
            raise Reject("wrong_type", "The workbook is not a standard Excel file.")

The main content type for a plain workbook is the one shown, and a macro-enabled workbook declares application/vnd.ms-excel.sheet.macroEnabled.main+xml instead, so the check rejects xlsm files even when the extension was changed. Rejecting external links is a policy choice: some legitimate workbooks have them, and for an upload from outside you are unlikely to want formulas that reference files on the sender's network. Decide, then write it into the error message so the sender can fix it by breaking the links.

Step six: sheet names

Read xl/workbook.xml for the list of sheets and compare it with what the template promises. Use a real XML parser with entity resolution off, not a regular expression, once the file has passed the earlier tests.

Python
from defusedxml import ElementTree as ET

NS = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}

def sheet_names(path):
    with zipfile.ZipFile(path) as z:
        root = ET.fromstring(z.read("xl/workbook.xml"))
    return [s.get("name") for s in root.findall("m:sheets/m:sheet", NS)]

names = sheet_names(path)
if "Orders" not in names:
    raise Reject("missing_sheet", f"The workbook must contain a sheet named Orders. Found: {', '.join(names[:5])}.")
if len(names) > 10:
    raise Reject("too_many_sheets", "The workbook has more than 10 sheets.")

Echoing at most a handful of sheet names back is useful and safe, provided you escape them wherever you display the message. Sheet names are user-controlled text.

Step seven: the header row

Reading only the first row is cheap in read-only mode and catches the commonest mistake, which is someone uploading last year's layout.

Python
from openpyxl import load_workbook

EXPECTED = ["order_no", "customer_id", "ordered_on", "qty", "unit_price"]

wb = load_workbook(path, read_only=True, data_only=True)
ws = wb["Orders"]
header = [str(c).strip().lower() if c is not None else "" for c in next(ws.iter_rows(min_row=1, max_row=1, values_only=True))]
wb.close()
if header[:len(EXPECTED)] != EXPECTED:
    missing = [h for h in EXPECTED if h not in header]
    raise Reject("bad_header", f"Missing or misplaced columns: {', '.join(missing) or 'order of columns differs'}.")

If the header does not match, stop. Do not guess which column is which; a guessed mapping loads the wrong numbers into the wrong fields and nobody notices until the month-end. The deeper treatment of template checks is in checking an incoming workbook against a template.

Step eight: values are still input

After the structure checks, each cell is an untrusted value. Validate types, ranges, lengths and formats row by row, collect the errors with their row numbers and report the first few hundred at most. Load through parameters into a staging table (staging Excel data in a table first) and never build SQL from cell text. Strings destined for output files need the formula guard from formula injection in Excel exports.

Reject with a code, a message and a log line

Each failure produces three things.

  • A stable code (too_large, not_xlsx, active_content, bad_header), which the front end and support can rely on.

  • A message for the sender that says what to change.

  • A log line with the code, the file hash, the size, the sender and the time.

Python
class Reject(Exception):
    def __init__(self, code, message):
        super().__init__(message)
        self.code = code
        self.message = message

Counting rejections by code tells you what to fix: many bad_header rejections mean the template is unclear, and a rise in active_content from one sender is worth a conversation. Computing the SHA-256 of the file once at the start lets you recognize the same file arriving repeatedly.

Test with a small fixture set

Keep a fixture for each rejection code, plus one valid file. Run the checker in CI and assert the code returned for each. Build the fixtures in the test suite with zipfile and openpyxl, so no binary files live in the repository: write a plain text file named .xlsx, a zip with the wrong parts, a workbook with a vbaProject.bin entry and a valid workbook with a wrong header. The valid file ensures that the checks do not reject real work.

When these checks are not the whole job

These checks establish that the file is shaped like the template. They do not establish that the sender is who they claim, that the numbers are right or that the content is lawful to hold. They do not replace a malware scanner for files that humans will open later. And they do not make parsing safe by themselves: that depends on limits, a constrained worker and a parser with entity expansion off, as covered across this cluster. Treat the upload check as a gate and not a guarantee, and keep the layers after it. For the policy decision of what to accept in the first place, read why data teams should block macros by default. The format itself is documented in ECMA-376 and the Python module in the zipfile reference.

Also inSecurity
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.