all posts

A Spreadsheet Is a Programming Language Your Users Don't Call One

Ajay Kumar··9 min read

Nobody puts "we shipped a programming language" on the roadmap. They put "customers can write their own formulas." Same sentence. A formula language has variables, function application, conditionals, a form of iteration, a dependency graph re-sorted on every keystroke, and a standard library of several hundred entries — some of which make network requests. If your product has a formula bar, you are executing user-authored programs on your infrastructure. The equals sign does not change where the cycles are spent.

I'm Ajay; I build PandaStack, a Firecracker microVM platform, so read this as opinionated. The claim: these workloads come in three escalating tiers of danger, most teams have defended only the first, and by tier three — the day you shell out to a headless office suite — "container" has quietly stopped answering the question people think it answers.

Three tiers, each worse than the last

Tier 1: formulas your own engine evaluates

You built a formula engine or embedded one — HyperFormula, a formulas.js-style function library, a parser someone forked in 2021. The user edits cells; you recalculate the affected subtree. Most of what goes wrong here is resource exhaustion rather than compromise, and it goes wrong constantly, usually without anyone meaning harm. A ten-thousand-cell sheet where each row references the twenty above it is not an attack; it is what a financial model looks like on a Tuesday. Recalculation scales with edges, not cells, and the edge count is where the surprise lives.

Add volatile functions — RAND, NOW, OFFSET, INDIRECT — which mark themselves dirty on every pass and drag their dependent subtree along, so a workbook using a few of them recalculates most of itself whenever anything changes. Turn on iterative calculation for a circular model, as serious modelling tools must, and you have handed the user a while-loop with a convergence threshold they chose. On Node this is specifically nasty: recalculation is synchronous CPU in one event loop, so the pinned core does not slow a request down, it stops the process answering anything. Health check fails, pod restarts, user reloads, model recalculates from scratch. The finance team's workbook is a distributed system with no tests.

There is a worse version, worth thirty seconds of grepping. Some engines implement user-defined functions by assembling a string and handing it to eval or new Function. If a formula a customer typed reaches a general-purpose evaluator in your host language, you have tier three wearing a nicer shirt.

Tier 2: the workbook they uploaded, parsed inside your API process

An .xlsx file is a zip archive containing XML parts. That sentence names two independent attack surfaces, and teams reliably notice only one.

The zip half is decompression bombs. A modest upload expands to something the host cannot hold, and the naive parser discovers this by allocating until the OOM killer intervenes — at which point the kernel reaps the largest process, which is your API server holding every other tenant's requests. The defence is checking the declared uncompressed size and the compression ratio per entry, capping the total, and streaming rather than buffering — before your spreadsheet library sees the bytes, because most libraries will happily do the wrong thing on your behalf.

The XML half is the document-parser catalogue: external entity resolution reaching local files or internal hostnames, entity expansion turning a few hundred bytes into gigabytes of strings, DTD fetches that make your parser an outbound HTTP client. Most mainstream parsers now disable external entities by default — verify that against your library's current docs rather than trusting any blog post, this one included. Legacy .xls is worse in the way old things are, and you cannot pick a parser from the filename, because the filename is attacker-supplied.

Tier 3: real macros, and the day you shell out to LibreOffice

Eventually someone needs fidelity: a preview thumbnail, a PDF matching what the customer sees, a workbook with pivot tables your engine politely declines to model. The pragmatic answer — the one that works, that I would reach for too — is soffice --headless --convert-to.

Look at what that puts in the request path. A full office suite: an enormous C++ codebase accumulated across decades, its own scripting runtime, import filters for dozens of legacy formats nobody has audited recently, font shaping, image decoders — all reachable by anyone with a trial account and an upload button. You would never deliberately expose a desktop application to the internet. Convert-to-PDF is how you do it by accident, in one line, in a sprint labelled "export improvements."

Do the obvious hardening — disable macros, give every invocation its own private user-profile directory rather than sharing one, drop privileges, pass an explicit filter — and check each option against LibreOffice's current documentation, because flags and defaults drift between versions. But be honest about what that list is: a denylist, maintained by you, against a program whose full behaviour nobody on your team has read. It also hangs. A headless office suite showing a modal dialog is the purest stuck process: alive, responsive to signals, waiting for a click that never comes.

A container is a polite suggestion to the kernel. cgroups will cap the memory and namespaces will hide most of the filesystem, but every container on the node talks to one shared kernel, and the thing you are isolating is a decades-old C++ codebase parsing attacker-supplied binary formats. That is precisely the case where "shared kernel" is the phrase you don't want in the postmortem.

Why your API process is the wrong place for any of this

The three tiers have different mechanics but fail in the same place, for four reasons. Your API process is the worst possible host for hostile document work, and not through any mistake of yours — it is wrong by construction:

  • It holds things worth stealing: database credentials, cloud tokens, signing keys, a network position inside your VPC. A parser bug there is not a crash, it is a credential exfiltration.
  • It is shared across tenants. One workbook's memory exhaustion is every other customer's 503, and the OOM killer picks its victim by size, not by guilt.
  • It is long-lived, so it accumulates: temp files, an office suite's profile directory, tenant A's output still on disk while tenant B's conversion runs.
  • Its failure is coupled to your availability. The recovery primitive you want against runaway user code is "destroy the machine," and you cannot use it, because the machine also serves your login endpoint.

The fix is not a better parser or a stricter grammar. It is moving the parse and the recalculation onto a machine that has nothing on it and that you are happy to delete mid-sentence.

The design: a baked snapshot with the calc engine already installed

Straight about what my own product does and does not do: PandaStack runs no managed spreadsheet service, and there is no /v1/xlsx endpoint. What it runs is Firecracker microVMs — own guest kernel under KVM, own network namespace, own copy-on-write root filesystem — with snapshot-restore on create, fork, and filesystem and exec APIs. The office suite and the calc engine are yours; you install them once into a template and bake a snapshot, so no upload pays for an apt install.

#!/usr/bin/env bash
# Run ONCE, inside a scratch sandbox, to produce the template snapshot that
# every later conversion restores from. The install cost is paid here --
# not on a customer's upload.
set -euo pipefail

export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends libreoffice-calc-nogui fonts-dejavu fonts-liberation python3-openpyxl

# Warm it once. The first soffice launch builds a user profile, unpacks
# configuration and touches a lot of disk; do that now so the restored
# guest never does it in front of a user.
mkdir -p /work
printf 'a,b\n1,2\n' > /work/warm.csv
soffice --headless -env:UserInstallation=file:///work/.profile-warm --convert-to xlsx --outdir /work /work/warm.csv
rm -rf /work/warm.csv /work/warm.xlsx /work/.profile-warm

# What is NOT installed is the template's real value: no cloud SDK, no
# kubeconfig, no service-account file, nothing that knows how to reach a
# metadata endpoint. An escape lands somewhere boring, which is the point.

The first boot of a fresh template — before any snapshot exists — is about 3 seconds, paid once. After that every create restores the baked snapshot: roughly 179ms p50 and 203ms p99 end to end, of which the restore step itself is around 49ms. There is no warm pool of idle machines; the snapshot is the warm pool, and it costs storage rather than compute. That number is what turns "a VM per workbook" from a thought experiment into a line item.

import hashlib
from pandastack import Sandbox

CONVERT_BUDGET_S = 45


class ConvertFailed(Exception):
    pass


def convert_workbook(tenant_id: str, data: bytes) -> bytes:
    """Convert ONE tenant's uploaded workbook on a machine of its own.

    Nothing about this file is trusted: not its extension, not its zip
    structure, not its formulas. The only thing we rely on is that the
    guest has no credentials, no egress and a fixed lifetime.
    """
    sbx = Sandbox.create(
        # A custom template baked from "base" with LibreOffice already
        # installed and warmed -- see the bake script above.
        template="calc",
        # Platform-enforced backstop. If this dispatcher is deployed over,
        # OOMs or panics mid-conversion, the VM still dies on schedule.
        ttl_seconds=120,
        metadata={
            "tenant": tenant_id,
            "sha256": hashlib.sha256(data).hexdigest(),
        },
    )
    try:
        sbx.filesystem.write("/work/book.xlsx", data)

        # Two nested deadlines on purpose: timeout(1) inside the guest so a
        # wedged soffice is reaped cleanly, and the exec timeout outside it
        # for when the guest itself has stopped cooperating.
        r = sbx.exec(
            "cd /work && timeout -s KILL 40 soffice --headless "
            "-env:UserInstallation=file:///work/.profile "
            "--convert-to csv book.xlsx",
            timeout_seconds=CONVERT_BUDGET_S,
        )
        if r.exit_code != 0:
            # 124 = timed out, 137 = SIGKILL, usually the OOM killer.
            # Either way the blast radius is this machine and this upload.
            raise ConvertFailed(r.exit_code, r.stderr[-2000:])

        return sbx.filesystem.read("/work/book.csv")
    finally:
        # Explicit teardown for the common path; the TTL covers the rest.
        sbx.destroy()

Notice how narrow the interface is: bytes in through a filesystem write, bytes out through a filesystem read, nothing else crossing. No host directory is mounted, so there is no shared /tmp to race on, and no environment variable carries a credential. Full compromise on the first instruction wins the attacker one workbook they already owned, with no network and two minutes to live.

Hard budgets, and telling a big model from a malicious one

The genuinely hard question here is not security, it is product: how do you tell a legitimate ninety-second recalculation from a deliberately pathological one? From inside the calculation, you mostly cannot. A dense dependency graph and a decompression bomb both look like "CPU is busy." So stop classifying and start bounding.

  1. Wall clock, enforced outside the guest. A runaway recalculation, a modal dialog and a fork bomb all die on the same schedule, with no cooperation from code that has stopped cooperating.
  2. CPU time, measured separately. A job burning a full core for 60 seconds is a different animal from one blocked on nothing for 60 seconds, and the second usually means a hang.
  3. A hard memory ceiling. Guest RAM is fixed by the template's baked snapshot, so an expansion bomb OOMs inside its own machine.
  4. An output cap, checked before you hand bytes back. A conversion that emitted 900 MB of CSV has failed even though it exited zero — an error, never a silent truncation.
  5. A per-tenant concurrency limit, applied before you spend anything, so one account cannot occupy the fleet by being consistently expensive.

Then let the product handle the ambiguity instead of guessing. Give interactive recalculation a short budget, and when a workbook blows through it, promote it to a background job and say so. "This model is big enough that we'll compute it in the background" is an honest answer. What users hate is not slowness; it is a spinner that lies.

// Escalating budgets, not a classifier. We never try to decide whether a
// slow workbook is "malicious" -- only how much of this tenant's budget it
// is allowed to consume before it becomes someone's background job.
import { Sandbox } from "@pandastack/sdk";

const INTERACTIVE_MS = 3_000;
const BATCH_MS = 120_000;

type Mode = "interactive" | "batch";
type Outcome =
  | { status: "ok"; csv: Buffer }
  | { status: "queued" }
  | { status: "promote" }
  | { status: "too_large"; detail: string };

export async function recalc(
  tenantId: string,
  book: Buffer,
  mode: Mode,
): Promise<Outcome> {
  const budgetS = Math.ceil(
    (mode === "interactive" ? INTERACTIVE_MS : BATCH_MS) / 1000,
  );

  // Concurrency is charged BEFORE any compute is spent, per tenant.
  if (!(await limiter.tryAcquire(tenantId, mode))) return { status: "queued" };

  const sbx = await Sandbox.create({
    template: "calc",
    ttlSeconds: budgetS + 60,
    metadata: { tenant: tenantId, mode },
  });

  try {
    await sbx.filesystem.write("/work/book.xlsx", book);
    const r = await sbx.exec("cd /work && /opt/calc/recalc /work/book.xlsx", {
      timeoutSeconds: budgetS,
    });

    if (r.exit_code === 124 || r.exit_code === 137) {
      // Out of time or out of memory. In the interactive tier that is not
      // a failure the user should see -- it is a routing decision.
      return mode === "interactive"
        ? { status: "promote" }
        : { status: "too_large", detail: r.stderr.slice(-500) };
    }

    return { status: "ok", csv: await sbx.filesystem.read("/work/out.csv") };
  } finally {
    await sbx.destroy();
    limiter.release(tenantId, mode);
  }
}

The useful signals are comparative: repeated timeouts from one account inside an hour, or a workbook whose runtime is wildly out of line with its cell and formula counts. That ratio is the real tell for a bomb, because genuine models scale roughly with their size and bombs do not. Use it to rate-limit, not to hard-block — the false positive is the customer with a legitimately enormous model, and they are usually your best one.

No egress by default, and why that alone kills WEBSERVICE()

Spreadsheets make HTTP requests. Excel and LibreOffice both expose functions that fetch a URL and parse the response; workbooks carry external data links and linked images that resolve on open. A spreadsheet is a programming language with a built-in HTTP client, and it has had one for longer than many of the people reading this have been writing code.

So the attack writes itself: upload a workbook whose cells fetch your cloud metadata endpoint, an internal hostname, or a collector with the interesting parts of the sheet in the query string. Your converter opens it, resolves the links, posts the result — server-side request forgery with a file extension, no memory-safety bug required. Because each microVM has its own network namespace, the answer is structural rather than configurational: deny egress there and a fetch function returns an error into a cell instead of a 200 into someone's logs. That one control also removes the exfiltration half of tiers two and three — a parser bug with code execution cannot phone home, and a macro cannot fetch its second stage.

Enforce "no network" at the network namespace, not in the application's own configuration. A setting inside the guest is a setting the guest can rewrite. The correctness of your egress policy should never depend on a file that hostile code can open for writing.

The other bug entirely: formula injection on the way out

Everything so far protects your servers. This one protects your customer, fires on a machine you will never see, and is unaffected by how well you isolated anything — which is why teams fix one of these and believe they fixed both.

You export a CSV. One value starts with an equals sign, because a user typed it into a form field eight months ago. The victim opens the file in Excel or LibreOffice, and the reader does what readers do with a leading equals sign: it evaluates the cell. The ugliest historical payloads used DDE syntax, the =cmd|'/c calc'!A1 shape, to launch a local process; modern readers warn about that, and the warning is a dialog, and users click through dialogs, because that is what a dialog is for. The value was never a formula. It was a string in your database for months, and became one the instant your exporter wrote it into a format where a leading equals sign means "execute this."

# Formula injection is an OUTPUT bug. It has nothing to do with how well
# you isolated the parse -- a perfectly sandboxed pipeline will happily
# emit a weaponised CSV, because from its point of view it did its job.

RISKY_PREFIXES = ("=", "+", "-", "@", "\t", "\r")


def csv_safe(value: object) -> str:
    """Neutralise a value that is about to be written into a CSV cell."""
    s = "" if value is None else str(value)
    # Strip leading whitespace first: readers skip it before deciding
    # whether the cell is a formula, so " =SUM(1)" is still a formula.
    if s.lstrip().startswith(RISKY_PREFIXES):
        # A leading apostrophe is the conventional escape. Pick one
        # convention, apply it everywhere, and write a test that opens
        # the output with a real reader.
        return "'" + s
    return s


# For .xlsx, do it properly instead: write an explicitly string-typed cell
# rather than letting the library infer a formula cell from the content.
# openpyxl sketch -- verify the current API against its own docs.
def write_string_cell(ws, row: int, col: int, value: object) -> None:
    cell = ws.cell(row=row, column=col)
    cell.value = "" if value is None else str(value)
    cell.data_type = "s"   # string, never "f"

This applies to workbooks you generate too, not only raw CSV downloads. And do not rely on the reader: you control neither which application opens your file, nor its version, nor which warnings that organisation's policy has disabled.

Determinism, and reproducing "the number is wrong"

The worst ticket in this category is not a security incident. It is: "cell K42 says 1,284,900 and it should say 1,284,901." Nobody can reproduce it. The customer is certain. Your engineer runs the same workbook locally and gets the right answer, which proves only that their laptop is not production.

It is hard because the answer depends on more of the environment than anyone documents: engine version, floating-point handling, locale, timezone database, the collation used by sort and lookup, the convergence threshold for iterative calculation, and — if a volatile function is in the graph — the wall clock. Half of those are inputs nobody wrote down as inputs. A baked template snapshot pins all of them at once: a template generation is a version number for your entire calculation environment.

Forking makes the reproduction cheap. Restore the workbook, fork the VM at the moment before recalculation — 400–750ms same-host, 1.2–3.5s cross-host — and you have as many identical copies of that exact state as you want. Run it ten times to see whether the result is stable; run one under a debugger while another runs clean; diff against a template baked from the previous engine version. "Is it us or their model?" becomes a bisect rather than an argument.

Four places to run a user's workbook

Softest boundary to hardest. If you are evaluating a specific engine, container runtime or office suite, check its isolation semantics against its own docs — defaults move between versions.

  • Availability under a heavy recalculation — In-process engine: a synchronous recalculation pins the event loop and the whole service degrades. Worker thread: the main loop survives, but threads share a heap and a machine, so N heavy models still starve the pool. Container per job: cgroups cap that job's CPU and RAM, though page cache, IO and kernel locks are shared. microVM per tenant-job: fixed vCPU and RAM per guest plus a per-tenant concurrency cap.
  • A parser crash or OOM on an uploaded file — In-process: the OOM killer picks the largest process, which is your API server holding every other tenant's requests. Worker thread: a native segfault takes the whole process down regardless of which thread caused it. Container per job: contained, if you set memory limits; a kernel-level fault still affects the node. microVM per tenant-job: contained to a throwaway guest, and the retry starts from a pristine snapshot.
  • Running a headless office suite on hostile input — In-process: piping uploads into soffice from your API host is the same exposure with extra steps. Worker thread: no boundary; the suite runs as your app user with your app's credentials. Container per job: real against accidents, soft against attackers — namespaces and seccomp over a kernel shared with every tenant on the node. microVM per tenant-job: own guest kernel under KVM, so an escape needs a hypervisor break.
  • Exfiltration via WEBSERVICE, external data links or a macro's second stage — In-process: your app's full network position, metadata endpoint included. Worker thread: identical; the namespace is shared with the parent. Container per job: depends entirely on network policy you remembered to write. microVM per tenant-job: its own network namespace, egress denied by default, outside anything the guest can edit.
  • Cost and start latency — In-process: microseconds, paid for in blast radius. Worker thread: microseconds, plus a pool sized for peak. Container per job: milliseconds with a cached image; seconds to tens of seconds on a cold pull. microVM per tenant-job: about 179ms p50 and 203ms p99 from a baked snapshot, roughly 3 seconds for the one-time cold boot before that snapshot exists, and 400–750ms for a same-host fork.

Density is rarely limited by plumbing. A single PandaStack agent pre-allocates 16,384 /30 subnets, so per-sandbox networking is not the ceiling; host memory and CPU are. One honest constraint: a template's RAM is baked into its snapshot, so "how much memory does a conversion get" is a template decision — a modest one for interactive recalculation, a larger one for batch — not a per-request parameter.

What I'd actually build, cheapest first

  1. Grep for eval, new Function and exec anywhere near your formula path. If a user-typed string reaches a general-purpose evaluator, fix that today; nothing else here matters as much.
  2. Neutralise formula injection in the serialiser, for CSV and generated workbooks alike. Small diff, protects your customers rather than your servers, independent of everything else.
  3. Put ratio and size checks in front of the zip decompression, before your spreadsheet library sees a byte. Cap per entry and in total, and stream rather than buffer.
  4. Move parsing and conversion out of the API process. Even a host-side worker with a wall-clock kill decouples "this upload was hostile" from "the product is down."
  5. Bake a template with the calc engine and office suite installed and warmed, then run each tenant's job in its own microVM with no egress, no credentials, a wall-clock budget and a TTL backstop.
  6. Record the template generation and input hash with every computed result, so reproducing a disputed number is a restore-and-fork rather than an investigation.

And be willing to stop early. If your formula language is genuinely closed — you wrote the evaluator, no eval path, no external data functions, no macros, bounded cell count — then a worker thread with a hard timeout, a memory cap and a concurrency limit is a defensible place to end. The calculus changes the moment a file format enters the picture. You are no longer defending an interpreter you designed; you are defending a parser you inherited, against the most attacker-controllable input in your product, in the process that holds every customer's credentials.

Your users are writing programs. They just don't call them that, and neither, apparently, does your architecture diagram.

Frequently asked questions

Is it really dangerous to parse an uploaded .xlsx file in my API process?

Yes, and the danger is mostly about location rather than any single bug. An .xlsx is a zip archive of XML parts, so you inherit both decompression bombs and the document-parser catalogue: external entity resolution reaching local files or internal hostnames, entity expansion turning a small file into gigabytes of strings, and DTD fetches that make your parser an HTTP client. Legacy .xls and .ods add older, less-audited filter code. All of that runs at the privilege level of your business logic, in the process holding your database pool and service credentials, next to other tenants' in-flight requests. Move the parse to a disposable machine with no credentials and no egress, and check your parser's current entity-handling defaults against its own documentation.

How do I stop one customer's giant spreadsheet model from taking down my service?

Bound it from outside rather than trying to classify it from inside. Enforce a wall-clock kill on the host, so a runaway recalculation, a wedged office suite and a fork bomb all die on the same schedule without cooperation. Track CPU time separately from wall clock, because a pegged core and a hang are different failures. Fix guest memory at the VM level so an expansion bomb OOMs inside its own machine. Cap output size and treat exceeding it as an error, not a truncation. Finally, cap concurrent jobs per tenant before spending any compute. In the product, give interactive recalculation a short budget and promote anything slower to a background job — a large model is a legitimate thing customers have.

Is running LibreOffice headless in a container safe enough for user uploads?

It is a real boundary against accidents and a soft one against a determined attacker. soffice --headless is a full office suite: an enormous C++ codebase with its own scripting runtime, import filters for dozens of legacy formats, font shaping and image decoders. A container gives you namespaces, cgroups and seccomp, but every container on the node shares one host kernel, so a kernel-level bug crosses tenants, and the node's metadata endpoint and shared mounts are frequently reachable. A Firecracker microVM gives the guest its own kernel under KVM hardware virtualization, so an escape requires a hypervisor break. Either way, disable macro execution, give every invocation a private user-profile directory, and verify the flags against LibreOffice's current documentation.

What is CSV formula injection, and does a sandbox prevent it?

No, a sandbox does nothing for it, which is why it is worth treating as a separate bug. Formula injection is an output problem: a value that was an ordinary string in your database becomes executable the moment your exporter writes it into a file format where a leading equals sign, plus, minus, at-sign, tab or carriage return means the cell is a formula. It then fires in the victim's spreadsheet application, on their machine, with their data and network — as a hyperlink, a fetch function, or historically a DDE payload that launches a local process behind a warning dialog people click through. Fix it in the serialiser: strip leading whitespace, prefix risky values, and for .xlsx write explicitly string-typed cells rather than letting the library infer a formula cell.

Doesn't a microVM per workbook make spreadsheet processing far too slow?

Less than you would expect, because nothing boots. On PandaStack every create restores a pre-baked snapshot: about 179ms p50 and 203ms p99 end to end, with the restore step itself around 49ms. The one-time cold boot before a snapshot exists is about 3 seconds and is paid once per template, not per upload — which also means the office suite and calc engine are installed at bake time rather than per request. On a path that already involves parsing a workbook and converting it, a sub-200ms preamble disappears into the work. For batches, fork a warmed baseline instead: 400–750ms on the same host, 1.2–3.5s cross-host, sharing one VM across a single tenant's jobs and never across tenants.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.