Skip to content
datahacks.dev
Excel

Zip Bombs and Oversized XLSX Uploads and Limits That Work

Stop zip bombs and oversized xlsx uploads with limits on file size, entry count, expansion ratio and rows, enforced before parsing and sized from real files.

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

A zip archive compresses repetitive data very well. A file full of zeros can shrink by a factor of a thousand, so a 5 MB upload can be a promise of 5 GB. An xlsx is a zip, and the same property applies to the parts inside it, so a hostile upload is cheap to build and expensive to open. The innocent version of the problem looks the same from the server's side: a customer exports a year of data, the sheet XML runs to 2 GB when expanded, and the parser asks for 20 GB of memory. This article sets limits that stop both. It belongs with the other controls in Excel security problems developers overlook.

Why xlsx expands

A typical sheet is stored as XML where each cell is an element such as <c r="A2" t="s"><v>14</v></c>. That is a lot of text for one value, and it compresses well because the same tags repeat. Ratios of 10 to 20 are normal for a real workbook. When a file is parsed into objects, memory use is larger still: a million cells with styles can occupy several hundred megabytes in a library that builds the whole tree (why Excel files get huge explains what makes a legitimate workbook large).

An attacker's file is different. Highly repetitive content gives a ratio of 1,000 or more, or the zip is nested, with an archive inside the archive. Python's zipfile documentation warns about decompression bombs, and the weakness is CWE-409. An xlsx contains no nested archives in normal use, so a zip entry inside the zip is itself a reason to reject.

Layer one: the edge

The cheapest limit is the one applied before your code sees any bytes. Set a maximum request body size in the web server or proxy.

Nginx
client_max_body_size 25m;

Choose the number from your data: look at the largest workbook your users legitimately send, add a margin, and write down the figure with its source. If the biggest real file in a year is 14 MB, 25 MB gives room, and 500 MB invites trouble. When people truly need to send bigger files, give them a CSV or a chunked upload path with its own rules, not a higher limit on this one (converting a huge Excel file to CSV or Parquet).

Application frameworks have their own limits on request size and upload size; set those too, because the proxy may not be the only way in.

Layer two: inspect the directory

The zip central directory lists every entry with sizes, and it can be read without extracting. Check it first.

Python
import zipfile

LIMITS = {
    "max_entries": 200,
    "max_total_uncompressed": 400 * 1024 * 1024,
    "max_entry_uncompressed": 200 * 1024 * 1024,
    "max_ratio": 100,
}

def inspect(path):
    with zipfile.ZipFile(path) as z:
        infos = z.infolist()
        if len(infos) > LIMITS["max_entries"]:
            raise ValueError("too many entries")
        total = 0
        for i in infos:
            total += i.file_size
            if i.file_size > LIMITS["max_entry_uncompressed"]:
                raise ValueError(f"entry too large: {i.filename}")
            if i.compress_size and i.file_size / i.compress_size > LIMITS["max_ratio"]:
                raise ValueError(f"ratio too high: {i.filename}")
            if i.filename.lower().endswith((".zip", ".gz", ".7z")):
                raise ValueError("nested archive")
            if i.filename.startswith("/") or ".." in i.filename.split("/"):
                raise ValueError("unsafe path")
        if total > LIMITS["max_total_uncompressed"]:
            raise ValueError("expands too far")

A real workbook has a few dozen entries. 200 is generous and a thousand is not normal. The ratio limit of 100 is far above real workbooks and far below a bomb. Calibrate by running the function over a sample of your actual uploads and printing the maximum of each measure; the limits should sit comfortably above those maxima.

Rejecting a path with .. or a leading slash stops zip slip, where an extractor writes outside its target directory. If you never extract to disk, the check is still harmless and keeps the habit.

Layer three: do not trust the headers

The sizes in the directory are declared by whoever made the file. A crafted archive can understate them. So limit the bytes you actually read, and never call extractall on an upload.

Python
def read_part(z, name, cap=200 * 1024 * 1024):
    out = bytearray()
    with z.open(name) as f:
        while True:
            chunk = f.read(1024 * 1024)
            if not chunk:
                break
            out += chunk
            if len(out) > cap:
                raise ValueError(f"{name} exceeds read cap")
    return bytes(out)

Better still, avoid reading whole parts into memory. Libraries with streaming modes read the sheet XML incrementally. In openpyxl, load_workbook(..., read_only=True) gives an iterator over rows, and you can stop after your row limit. Python's zipfile does verify CRC when you read a member to its end, but a bomb will have used your memory before you reach the end, which is why the cap matters.

Layer four: rows, columns and the declared range

A sheet can declare a gigantic used range without containing much data. A dimension of A1:XFD1048576 makes some libraries allocate for every cell, and a stray formatted cell far from the data can create the same effect in an honest file. The sheet XML has a <dimension ref="..."/> element that you can read cheaply, and ws.max_row and ws.max_column report the same in openpyxl.

Python
ws = wb["Orders"]
if ws.max_row > 500_000 or ws.max_column > 100:
    raise ValueError("sheet dimensions exceed limits")

Set both limits from the template. If the template has twelve columns, a sheet with thousands of columns is wrong whatever its size on disk (one header row and no merged cells describes the shape you can demand). Reading max_row in read-only mode relies on the declared dimension, which a bad file can fake, so also count rows as you iterate and stop at the cap.

Layer five: the backstop

All the checks above are logic, and logic can have gaps. A final layer limits the blast radius: run the parser in a worker with a memory ceiling, a CPU and wall-clock limit and no network (opening untrusted Excel files safely in a pipeline shows the container flags). With that in place, a bomb that slips through kills one worker and fails one job. Without it, the same bomb takes the application server down, and probably the other uploads in flight.

Choose numbers by measuring

A limit that is too low rejects customers, and one that is too high does nothing. Measure first.

  1. Collect the real files from the last year, or the largest few hundred.

  2. Compute file size, entry count, total uncompressed size, maximum ratio, rows and columns for each.

  3. Take the maximum of each measure and multiply by a margin of two to four.

  4. Write the results into configuration, with a comment: "largest in 2026 sample: 14 MB; limit 25 MB".

  5. Revisit when the business changes: a new customer with larger data is a request to review the numbers, not to delete them.

Keep a log of rejections by reason. If "too many rows" rises from a single customer, that customer has outgrown the template and needs a different path.

Say what went wrong

A rejected sender should learn what to do. "File rejected" helps nobody; "The workbook is 88 MB and the limit is 25 MB. Please export fewer rows or send a CSV" does. Return a stable error code as well, so your support team can look it up. Keep the response free of internals: do not echo entry names from the archive or stack traces.

Test the limits

Create fixtures that sit just below and just above each limit and assert the outcome. A compressed file of zeros makes a safe bomb for testing, because the test harness never extracts it: write a few megabytes of zeros into a zip entry, check that inspect rejects it on ratio, and confirm the worker is never started. A file one entry over the limit should be refused, and one entry under should pass. For hostile samples, see checking an Excel upload before reading it.

When the limits are the wrong tool

Limits are the wrong tool when the workbook is large for a legitimate reason and you must handle it. Then the answer is a different pipeline, such as streaming conversion to CSV or Parquet, or loading in chunks, not a bigger cap (reading a 500 MB Excel file). Limits do not protect against formula or macro attacks, which need their own controls. And limits on a trusted internal job can be looser, since the sender is you; keep a sanity cap anyway, because your own export code can have bugs too.

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.