Skip to content
datahacks.dev
Excel

Opening Untrusted Excel Files Safely in a Pipeline

Open untrusted Excel files in a sandboxed worker with no network, memory and time limits, a read-only parser and no desktop Excel, then load results as data.

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

Treating an uploaded workbook as data is a comforting idea until the parser runs out of memory, hangs on a malformed part or reads an external entity from the file system. The parser is code, the file is its input, and the input is chosen by someone you do not know. The sensible response is to contain the parser, so that whatever the file does stays inside a box you can throw away. This article builds that box. It is the isolation step in the reference pipeline from Excel security problems developers overlook, and it comes after the cheap checks in checking an Excel upload before reading it.

The shape of the solution

Split the work into two programs. The web application accepts the upload, stores it under a generated name and queues a job. The worker takes the job, parses the file inside a constrained environment and writes extracted data to a result. The application then reads the result, never the original workbook.

The split matters because it separates failure domains. If the worker is killed for using too much memory, the web application is unaffected. If the file is hostile, it ran in a process with nothing to steal: no database credentials, no network and no write access outside a scratch directory. And the data that crosses back is plain rows, which you validate as ordinary input.

Do not drive desktop Excel

Some teams read uploads by automating a real Excel instance on a server, through COM or similar. Do not do that for untrusted files. A desktop Excel opens the workbook the way a person would: it evaluates formulas, follows links, may prompt for macro or link permissions with nobody to answer and can hang indefinitely on a modal dialog. Microsoft's own guidance says that server-side automation of Office is not supported. Use a library that reads the file format, such as openpyxl, or a converter run headless, and treat both as parsers of hostile input.

Constrain the worker process

On Linux, a container is the easiest way to get most of the constraints at once.

Shell
docker run --rm \
  --network none \
  --read-only \
  --tmpfs /tmp:rw,size=256m,noexec,nosuid \
  --memory 1g --memory-swap 1g \
  --cpus 1 \
  --pids-limit 64 \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --user 10001:10001 \
  -v /srv/uploads/ab12cd34.xlsx:/in/file.xlsx:ro \
  -v /srv/results/ab12cd34:/out \
  sheet-worker python /app/extract.py /in/file.xlsx /out/rows.jsonl

Each flag has a job. --network none removes the network, so a file cannot make the worker reach out, and an XML parser that tried to fetch a URL would fail. --read-only and a small tmpfs keep writes to a scratch area that disappears. The memory and CPU flags cap the damage of a bomb. --pids-limit stops a fork storm. Dropping capabilities and running as an unprivileged user leaves nothing to escalate. The input is mounted read-only and the output directory is the only place the worker can write. The Python resource module offers the same limits for a plain subprocess. The Docker documentation on runtime constraints lists the options.

If you do not use containers, a job queue running a subprocess with resource.setrlimit for memory and CPU time, a dedicated unprivileged account and a firewall rule for that account gets part of the way. Containers are easier to get right and to review, which matters for a control that has to keep working.

Add a timeout from outside

A memory limit stops a bomb that grows. It does not stop a parser that loops. Put a wall-clock timeout around the job from the outside, in the queue or the orchestrator, and kill the whole process group when it fires.

Python
import subprocess

try:
    subprocess.run(
        ["python", "extract.py", "file.xlsx", "rows.jsonl"],
        timeout=60, check=True, capture_output=True,
    )
except subprocess.TimeoutExpired:
    mark_failed("timeout")
except subprocess.CalledProcessError as e:
    mark_failed(f"exit {e.returncode}")

Sixty seconds is a number to derive from your real files: take the slowest legitimate workbook, double it and use that. Record the limit in configuration with a comment that says where it came from.

Read as little as possible

The worker should extract what you need and nothing more. In openpyxl that means read-only mode, which streams rows without building the full object tree, and values only.

Python
from openpyxl import load_workbook

MAX_ROWS = 200_000
wb = load_workbook("/in/file.xlsx", read_only=True, data_only=True)
ws = wb["Orders"]
with open("/out/rows.jsonl", "w", encoding="utf-8") as out:
    for i, row in enumerate(ws.iter_rows(values_only=True)):
        if i > MAX_ROWS:
            raise SystemExit("too many rows")
        out.write(json.dumps(row, default=str) + "\n")
wb.close()

data_only=True returns the cached results of formulas rather than the formulas themselves, which is what a loader wants (what happens when code reads formulas). The row cap makes the work bounded whatever the sheet claims. Reading by sheet name from an allowlist is better than iterating every sheet: if the template promises a sheet called Orders, fail when it is absent.

Install defusedxml in the worker image. openpyxl uses it automatically when present, which closes the entity expansion hole that the library's own documentation warns about. Pin the versions of the libraries, and rebuild the image when security fixes ship, because the parser is the part of your system that faces the outside.

Return data, not files

The worker's output is a text file of rows. The application reads it, validates types and values against the template, and loads it into a staging table with parameters (staging Excel data in a table first). The original workbook never leaves the worker's mount, is not served back to anyone, and is not opened by anything else. If a human needs to see the file, show them the extracted rows in your own interface.

JSON Lines or Parquet are suitable result formats. Avoid returning another xlsx: a rewritten workbook can still carry things.

Log and clean up

Record, for every file: a hash of the content, the size, the sheet names found, the row counts, the time taken and the outcome. When a file is rejected, log the reason code, not just "failed". Delete the original and the scratch output once the job ends, or move rejected originals to a quarantine location with restricted access and a retention limit, in case you need to investigate.

A short, structured log line works:

Text
2026-10-29T10:14:07Z upload=ab12cd34 sha256=9f2c... size=1843201 sheets=Orders rows=18452 result=ok ms=3120

Keep alerts for patterns, such as many timeouts from one sender or a sudden rise in size rejections.

Test the box

Prove the constraints work by feeding the worker files that should fail, and asserting that they do so cleanly. A file that expands beyond the memory limit should end the job with a clear exit, not a hung queue. A file with a path traversal entry name should be rejected. A file that tries to reference an external entity should either fail to parse or return no external content. Tests like these belong beside the fixtures described in Excel zip bombs and oversized XLSX uploads, and the same worker should pass them on every change to the image.

Also test the happy path with a large legitimate file, so the limits do not reject real work. A worker that fails on the customer's normal 40 MB workbook gets its limits removed within a week, and then you have no limits.

When a sandbox is more than you need

A sandbox is more than you need when the only source is a script you control and the file is produced by your own system, since trusting your own output is reasonable. It is more than you need for a one-off analysis of a file a colleague gave you, where a size check and a normal virtual environment are enough. And it is more than you need if you can avoid the file: an API, a database export or a CSV removes the container format and most of its risks. The sandbox earns its cost when strangers can put files in front of your code, which is the case for any public upload. For the smaller internal case, the guidance in reading large Excel files in read-only mode covers limits without any isolation.

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.