Sandboxing PDF Processing in PHP with Bubblewrap
What happens when your PHP application processes an untrusted PDF with an external binary?
Most of us never think about it. A user uploads a file, a controller stores it, a queued job runs a command-line tool on it, and the result goes back to the user. The tool might be a PDF engine, an image converter, an office-document converter or an OCR utility. The code is often a few lines long, it works, and it goes to production.
This article is about the part that is easy to overlook: the security boundary around that one command. It applies whether or not you ever use the library I describe at the end.
1. The innocent-looking command
Here is the shape of code that exists in a lot of applications:
$path = storage_path('app/uploads/'.$upload->stored_name);
$output = shell_exec('pdftotext '.escapeshellarg($path).' -');
Escaping the argument deals with the most obvious problem, shell injection through the file name. It says nothing about what the program can do once it starts, and the program is the part that is parsing a file an attacker chose.
PDF, image and office parsers are large, old, performance-tuned code that handles a huge range of malformed input. They are written in languages where memory bugs are possible, and they are pointed at hostile files all day. Parser vulnerabilities are routine. Assume that one day a file will make one of them do something its authors never intended.
2. The hidden security boundary
When PHP starts a child process, the child runs as the same Unix user as your PHP process. By default it also inherits the environment and the working directory, and it shares the same filesystem view and the same network. So a compromised parser can typically reach whatever your application account can:
- your source code and configuration files, including the
.envfile sitting under the project root; - environment variables that your process passed on, which often include secrets;
- other users’ uploads, other jobs’ temporary files, and other applications on the same host, if the account can read them;
- the network: internal services, cloud metadata endpoints, the outside world;
- CPU, memory and disk, with nothing stopping it from using all of them for as long as it likes.
None of this needs a sophisticated exploit. Once code is running as that user, it just calls open(). The question is then not “can the parser be exploited?” but “how much damage can an exploited parser do?”
3. Why input validation alone is not enough
Validation matters. Check the file type, the size, the page count, and reject what you do not expect. But validation is performed by code that also has to parse the input, and the goal of a parser exploit is to look like a valid file to everything except the bug. Validation reduces how much hostile input reaches the tool. It does not tell you what happens when some does.
So think in layers. Validate inputs, keep tools patched, run as an unprivileged account, and also limit what the tool can reach if it is compromised anyway. That last layer is what this article is about. It is defence-in-depth, not a replacement for any of the others.
4. The containment model
The design question is simple: can we give the document processor only what it needs? That comes down to:
- one temporary job directory;
- one executable;
- no network by default;
- no inherited environment;
- limits on time and resources;
- and if we cannot set all of that up, do not run the tool at all.
On Linux, Bubblewrap (bwrap) is a small, unprivileged sandboxing tool built on kernel namespaces. It is what Flatpak uses. It lets a normal user start a process with its own view of the filesystem and its own namespaces. Here is how the pieces fit together.
A per-job workspace. Each job gets a fresh directory with a random name and 0700 permissions. Inside the sandbox that directory is mounted at /work, and it is the only writable location. The parent directory, sibling jobs and your application directory are not mounted, so they do not exist from the child’s point of view. The executable itself is mounted read-only. For a statically linked tool such as pdfcpu, that is all it needs.
No network by default. The sandbox unshares all namespaces, including the network one. The process gets no network interface at all. Network access is possible, but it is an explicit opt-in.
A cleared environment. The sandbox starts with an empty environment. Only variables you list explicitly are passed in. Your application’s secrets stay behind, because the child never receives them.
An argv array, never a shell. The executable and arguments are passed separately to proc_open() in array form, so no shell is involved and nothing is built by string concatenation. A file name like ; rm -rf / is just an odd file name.
A wall-clock timeout. The runner enforces a deadline and caps how much output it will read. If either limit is hit, it kills the sandbox with SIGKILL. Because the child lives in its own PID namespace and dies with its parent, descendants do not linger.
Resource controls. Where prlimit from util-linux is available, the runner applies limits on CPU time, file size and open file descriptors, and always disables core dumps so a crashing parser cannot write memory to disk. An address-space cap is available too, but it is off by default because garbage-collected runtimes reserve far more than they use.
Fail closed. If Bubblewrap is missing or cannot create a sandbox, for example because unprivileged user namespaces are disabled, the runner throws an exception. It does not fall back to running the tool unsandboxed. A security control that silently turns itself off is worse than none, because you keep believing it is there.
Output and symlink checks. The child can plant a symlink in its workspace that points at something interesting on the host. Because only the workspace is mounted, a link to a host path simply dangles inside the sandbox. On the PHP side, files the tool produced are read through a helper that refuses symlinks and anything that resolves outside the workspace, so a planted link cannot trick your code into reading or serving a file it should not.
5. What the sandbox does not protect
This is the part I most want to be clear about, because overstating it would be the real security bug.
- Your parent PHP process is not sandboxed. Only the spawned tool is. If your application has a vulnerability, this does nothing for it.
- Your Laravel queue worker is not sandboxed. It runs as it always did, and it is the one starting the sandbox.
- The host kernel is shared. A vulnerability in Bubblewrap, in user namespaces or in the kernel itself can defeat the sandbox.
- In-process PHP work is not covered. If you decode images with GD or Imagick inside your PHP process, that code runs outside any sandbox. Move it into a separate
phpprocess launched through the runner. - Output files are untrusted. The tool may have been compromised, so what it writes may be hostile. Treat it as untrusted input to the next step.
- Memory is not capped by default, and no seccomp filter is applied by default.
It also needs a Linux host where the account can create unprivileged user namespaces. Some distributions restrict that. Ubuntu 24.04, for example, limits it through AppArmor, so you may need to adjust policy, which is a decision to make deliberately for your own servers.
The honest summary: this reduces the attack surface and limits what the child process can reach. It does not make document processing safe in an absolute sense.
6. In code
The library is called PDF-X Secure Runner. Install it with Composer:
composer require pdf-x/secure-runner
You need PHP 8.1 or newer, and Linux with Bubblewrap for real containment. prlimit is recommended. Then:
use PdfX\SecureRunner\{JobWorkspace, ResourceLimits, SandboxConfig, SecureRunner};
$workspace = JobWorkspace::create('/var/lib/myapp/jobs'); // private 0700 directory, must already exist
try {
$workspace->write('in.pdf', $uploadedBytes);
$runner = new SecureRunner(SandboxConfig::strict()); // strict = static binaries need nothing else
$result = $runner->run(
executable: '/usr/local/bin/pdfcpu', // absolute path to your own installed tool
arguments: ['info', $workspace->sandboxPath('in.pdf')], // paths as the sandbox sees them (/work/...)
workspace: $workspace,
limits: new ResourceLimits(timeoutSeconds: 60),
);
if ($result->isSuccessful()) {
echo $result->stdout;
}
} finally {
$workspace->cleanup();
}
A few details. sandboxPath() returns the path as the sandbox sees it, so /work/in.pdf rather than a host path. isSuccessful() is false on a non-zero exit, a timeout or an output-limit hit, and $result->timedOut tells you which. Dynamically linked tools such as shells and interpreters also need the system libraries, which you add with SandboxConfig::strict()->withSystemLibraries(). The pdfcpu binary is not bundled: install your own and verify it. If a tool writes a result file, read it with $workspace->resolveOutput('out.pdf') rather than a raw path.
The repository also has a generic Laravel queue-job example. The library itself is framework-independent.
7. Testing the boundary
A sandbox you have not tested is a hope. The package ships a self-check:
vendor/bin/pdfx-sandbox-check
It creates its own throwaway canary files and runs harmless probes inside the sandbox, and it never reads a real .env. It checks that Bubblewrap is available and that a command can run in a workspace. It then checks that the parent directory and a sibling job are invisible, that the parent’s environment does not leak, that the network is unavailable, that writes outside the workspace fail, that a symlink escape fails, and that a timeout kills the process. It prints PASS or FAIL per check.
The same checks exist as containment tests that run on a real Linux GitHub Actions runner with Bubblewrap installed. That job sets a flag which turns “Bubblewrap unavailable” into a failure, so the tests cannot pass by quietly skipping. On a Mac the containment tests skip, because there is no Bubblewrap there, and the unit tests still run.
A passing self-check tells you that this environment and this configuration behaved as tested. It is not a universal guarantee, so run it on each host you deploy to.
8. Why I open-sourced it
This came out of security engineering work while building PDF-X, an online PDF processing service. The sandboxing part turned out to be the reusable piece: nothing in it is specific to PDF-X or to PDFs. Any PHP or Laravel application that hands untrusted files to a command-line tool has the same problem.
So I extracted it into a separate, standalone library with its own tests and documentation, and no application code from PDF-X. I would rather other people can review it, break it and improve it than keep it as an internal detail. If you find a weakness, SECURITY.md explains how to report it privately.