all posts

Sandboxing PDF & Document Processing for AI Agents

Ajay Kumar··9 min read

Your AI agent ingests documents. Users upload a PDF and the agent extracts the text, indexes it for retrieval, pulls the line items off an invoice, parses a resume, or reads a contract and answers questions about it. It's one of the most common and most useful things an agent does — and it means your agent's pipeline is, several times a second, feeding attacker-controlled bytes into some of the most notoriously exploitable software on the planet.

Document parsers are an RCE minefield with decades of provenance. poppler, ghostscript, MuPDF, LibreOffice's import filters, image and font libraries, OCR engines — the CVE history reads like a museum of memory-corruption bugs: heap overflows, use-after-frees, integer overflows in the font rasterizer, type-confusion in the JavaScript engine that, yes, lives inside the PDF spec. A crafted PDF can trigger remote code execution in the parser, SSRF through embedded remote content and external entity references, XXE in the XML that office formats are built on, or simply fork-bomb a converter into taking your box down. And your agent hands these files to those parsers automatically, often in a process that also holds tool access and credentials.

I'm Ajay, and I build PandaStack, a Firecracker microVM platform, so I think a lot about where to put the wall when you run risky code on somebody else's input. This post is about drawing that wall around document parsing: run every parse of a user-uploaded file inside a throwaway microVM, so a malicious document that pops the parser lands in a disposable machine with no credentials, no egress, and a short fuse — instead of inside your agent's process, next to everything it can touch.

Why a document is code you didn't audit

It's tempting to treat a PDF as data — inert bytes you read the text out of. It isn't. PDF is a full document programming language with embedded JavaScript, an object model, fonts that are themselves little programs the rasterizer executes, and the ability to reference remote resources. Office formats are worse in their own way: macros, external entity references, and a nest of XML that XXE lives in. The parser that turns any of this into text is a large C/C++ codebase doing untrusted-input handling, and 'large C/C++ codebase doing untrusted-input handling' is the exact phrase that precedes most memory-corruption CVEs.

  • Memory-corruption RCE. A malformed object stream, a hostile embedded font, a crafted image, or an integer overflow in a length field turns a parse into arbitrary code execution inside the parser process. The attacker now runs code with whatever that process holds.
  • SSRF via remote content. PDFs and office docs can reference external resources — a remote image, a fetched URL, an external XML entity. A parser that resolves them will happily make requests from your infrastructure, including to the cloud metadata endpoint (169.254.169.254) that hands out the host's role.
  • XXE and entity expansion. XML-based formats can declare external entities (read local files, hit internal URLs) and recursive entities (the 'billion laughs' expansion) that exhaust memory. Both are classic, both still land against parsers that didn't disable external entities.
  • Resource exhaustion. A decompression bomb, a deeply nested object graph, or a converter that spawns a subprocess per page can pin CPU, exhaust memory, or fork-bomb the host. No RCE required — just a document that costs far more to parse than to craft.
  • Dropped payloads. A parser RCE that lands in your long-lived worker can write a backdoor, read the environment, and quietly persist. The exploit is a moment; the compromise is however long that worker lives.
Keeping poppler patched is necessary and insufficient. You're patching against yesterday's CVEs while accepting today's uploads, and the parser runs in whatever process called it — usually one holding your agent's tools and secrets. Patch the parser, yes. But also assume it will be beaten, and make 'beaten' mean 'a disposable VM with nothing in it' rather than 'my agent's process, compromised.'

Parse each document in a throwaway microVM

The move is to stop parsing in-process. Every time your agent needs to extract from a user-uploaded file, hand the file to a fresh Firecracker microVM, run the parser there, read back the extracted text, and destroy the VM. The parser boots inside its own guest kernel, confined by KVM hardware virtualization — an RCE in poppler is now an RCE in a machine that has no credentials, whose network can't reach anything, and which you're about to delete anyway. The blast radius of the worst PDF on the internet is one disposable guest and a wasted couple hundred milliseconds.

Three properties do the work. First, the guest holds nothing worth stealing: no agent credentials, no tool tokens, no other users' documents — just the one file you're parsing right now. Second, the guest's network namespace can be egress-off or allowlisted to nothing, which kills SSRF and remote-content exfiltration outright: the parser can resolve a remote reference all it wants, but the packet has nowhere to go, and the metadata endpoint is unreachable. Third, teardown is total — the parsed document, any payload the exploit dropped, and every byte it touched vanish with the VM.

The reason this is practical rather than a nice idea is that the VM is cheap. PandaStack creates by restoring a baked snapshot of an already-booted machine, so the restore step is around 49ms and an end-to-end create is p50 179ms and p99 about 203ms. A true cold boot happens only on the first spawn of a template (around 3s). For a document that takes a second to parse, a couple hundred milliseconds of isolation is a trade you'll make every time.

A worked example: extract text from an uploaded PDF

Here's the concrete shape. The user uploaded a PDF; the agent needs its text for a RAG index. We create a fresh microVM with egress off, write the raw bytes in, run the extractor, read the text back, and kill the VM. If the PDF is a weaponized file, it detonates inside a machine with nothing in it and no way out.

from pandastack import Sandbox

def extract_text(pdf_bytes: bytes, doc_id: str) -> str:
    """Parse ONE untrusted document in its own microVM. If the file is
    malicious, the RCE lands in a disposable guest with no creds and no
    egress -- then we delete it."""
    with Sandbox.create(
        template="code-interpreter",
        ttl_seconds=120,                     # backstop: a hung parser reaps itself
        metadata={"doc_id": doc_id, "purpose": "pdf-extract"},
        # NOTE: create this sandbox with egress OFF (allowlist: none). SSRF and
        # remote-content fetches then have nowhere to go, and 169.254.169.254
        # is unreachable.
    ) as sbx:
        # The untrusted file. We never trust it and never parse it in-process.
        sbx.filesystem.write("/work/input.pdf", pdf_bytes)

        # Run the extractor INSIDE the guest. timeout_seconds is the circuit
        # breaker for decompression bombs and fork-bomb converters -- it kills
        # THIS vm only.
        result = sbx.exec(
            "pdftotext -q /work/input.pdf /work/out.txt && cat /work/out.txt",
            timeout_seconds=60,
        )
        if result.exit_code != 0:
            # A parse failure is a normal outcome for a hostile file. Fail
            # closed: return nothing, log the doc_id, move on.
            raise RuntimeError(f"parse failed for {doc_id}: {result.stderr}")

        return result.stdout
    # VM gone here: the PDF, any payload it dropped, and the extracted text on
    # the guest disk all vanish with the machine.

The load-bearing choices: the sandbox is created with egress off, so the SSRF and remote-content vectors die at the network layer; `timeout_seconds` and `ttl_seconds` cap the exhaustion vectors, so a decompression bomb or a fork-bomb converter gets reaped instead of taking your host; the file is written into the guest and parsed there, never in your agent's process; and `metadata` tags the run so a repeatedly-failing document traces back to one upload. When the `with` block exits, the VM and everything in it are gone. For office documents, swap the command for a headless LibreOffice conversion or an XXE-hardened XML path — same wall, different parser inside it.

Fail closed on parse errors. A hostile document is supposed to make the parser choke; a non-zero exit is a normal, expected outcome, not a bug to retry-until-it-works. Treat a failed parse as 'this file gets no text and gets flagged,' not as a reason to relax the sandbox. The whole point is that the failure is contained and boring.

Parse in-process vs. parse in a microVM

  • RCE containment — In-process: a poppler/ghostscript exploit runs in your agent's process, with its credentials and tool access. microVM: the exploit runs in a disposable guest with a separate kernel, no creds, and nothing to escalate to but the hypervisor.
  • SSRF / remote content — In-process: the parser can reach your metadata endpoint and internal network. microVM: egress off means the request has nowhere to go; the metadata service is unreachable.
  • Resource bombs — In-process: a decompression bomb or fork-bomb converter takes the worker (and every request on it) down. microVM: ttl + timeout reap one VM and nothing else.
  • Cleanup after a bad file — In-process: a dropped payload persists for the life of the worker. microVM: teardown is total and free; there is nothing to clean.
  • Cost — In-process: fast, and one bad file away from a very expensive day. microVM: p50 179ms per create, paid once per document, buys you a hardware boundary.

Scaling it across an ingestion pipeline

A document pipeline is bursty and embarrassingly parallel — a hundred uploads want a hundred parsers, and each parser should be its own island. Because each PandaStack agent pre-allocates 16,384 /30 subnets, every VM gets its own network namespace, so per-guest egress control is the default rather than a bottleneck. Fan out one VM per document, let idle capacity cost nothing between bursts, and rely on ttl to reap anything that wedges. If you're parsing many documents of the same type, snapshot a VM that already has your extraction toolchain warmed and fork it per file (same-host fork 400–750ms, cross-host 1.2–3.5s) so each parse starts from a known-good image without a full create.

Putting it together

Document ingestion is a feature your agent can't do without and a parser it can't fully trust. The danger isn't your code; it's that a PDF is executable, an office doc is executable, and the libraries that read them have a decades-long CVE history you're re-rolling the dice on with every upload. Don't parse in-process, where an RCE inherits your agent's credentials and network. Parse each user-uploaded file in its own Firecracker microVM: egress off to kill SSRF and remote-content exfiltration, ttl and timeout to cap the exhaustion bombs, no credentials in the guest, and teardown to erase whatever the file dropped. Keep your parsers patched too — but make 'the exploit worked' mean 'a disposable VM you were about to delete' instead of 'my agent, compromised.' The weaponized PDF still shows up. It just detonates in an empty room with the door locked.

Frequently asked questions

Why is parsing a PDF or office document a security risk at all?

Because these formats are effectively executable and the libraries that read them are large C/C++ codebases with a long CVE history. PDF has embedded JavaScript, an object model, and fonts that the rasterizer runs as programs; office formats have macros and external XML entities. A crafted file can trigger memory-corruption RCE in the parser, SSRF via embedded remote content, XXE that reads local files or hits internal URLs, or a decompression/entity bomb that exhausts your host. When your AI agent auto-parses user uploads, it's feeding attacker-controlled bytes into exactly this software, usually in a process that also holds credentials and tool access.

How does a microVM contain a malicious document?

You parse each file in its own throwaway Firecracker microVM instead of in your agent's process. The parser runs inside a separate guest kernel confined by KVM hardware virtualization, so an RCE lands in a machine that holds no agent credentials, no tool tokens, and no other users' files. Create the guest with egress off, and SSRF plus remote-content fetches have nowhere to go and the metadata endpoint is unreachable. Cap it with a ttl and an exec timeout so decompression and fork bombs get reaped. Then destroy the VM, which erases the document and any payload the exploit dropped. The worst file on the internet costs you one disposable guest.

Doesn't spinning up a VM per document make ingestion too slow?

Not meaningfully. PandaStack creates by restoring a baked snapshot of an already-booted machine, so the restore step is around 49ms and an end-to-end create is p50 179ms (p99 about 203ms); a true cold boot happens only on the first spawn of a template (around 3s). For a document that takes roughly a second to parse, a couple hundred milliseconds of isolation is a trade worth making every time. For high-volume same-type parsing, fork a snapshot that already has your toolchain warmed (same-host fork 400–750ms) to skip most of even that.

Can't I just disable JavaScript and external entities in the parser instead?

Do that too — disabling PDF JavaScript, turning off external XML entities, and running headless converters with network access denied are all good hardening. But they're mitigations inside the same process that also holds your credentials, and they don't help against the memory-corruption bugs that don't need any of those features to trigger. Defense in depth means hardening the parser and containing it: harden the config, then run it in a microVM with no creds and no egress so that when a bug you didn't know about lands, it lands somewhere empty and disposable.

What should happen when a document fails to parse in the sandbox?

Fail closed. A hostile file is supposed to make the parser choke, so a non-zero exit is a normal, expected outcome — not a bug to retry until it succeeds or a reason to relax the sandbox. Treat a failed parse as 'this document gets no extracted text and gets flagged for review,' log the document id from the sandbox metadata so repeated failures trace back to one upload, and let the VM tear down. The exploit attempt is contained and boring, which is exactly the goal; you never want a malicious file to be able to talk you into a less-isolated retry path.

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.