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.

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.
Size. One integer comparison.
Name. Normalize and constrain the name, and generate your own storage name.
Signature. Read the first few bytes.
Archive directory. Read the zip's table of entries.
Required and forbidden parts. Look at names and the content types file.
Workbook structure. Parse
workbook.xmlfor sheet names.Template match. Read the header row of the expected sheet.
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.
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.
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.
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.
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.
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.
class Reject(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = messageCounting 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.


