pdf-x / secure-runner
Secure sandboxed document processing for PHP/Linux using Bubblewrap.
Requires
- php: >=8.1
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Secure sandboxed document processing for PHP/Linux using Bubblewrap.
Run document-processing tools against untrusted files while reducing their access to your application filesystem, environment, network and system resources.
It is a small, framework-independent PHP library: you hand it an executable, its arguments and a private job workspace; it runs that one process inside a Bubblewrap sandbox and returns the result. If the sandbox cannot be created, it throws. It never falls back to running the tool unsandboxed.
Why this exists
PHP and Laravel applications routinely call external tools (PDF engines, image converters, office converters) to process files that users upload. Those parsers are large and receive hostile input, and a bug in one can give an attacker code execution inside that process.
If the tool is started directly under the application's Unix account, the process inherits everything that account can reach:
- application source code and configuration files;
- environment variables, including secrets;
- other jobs' files and other applications on the host;
- the network;
- as much CPU, disk and time as it likes.
Secure Runner puts a restricted boundary around just that process, so a compromised parser sees a single empty job directory instead of your application. This reduces the attack surface; it is a defence-in-depth control, not a substitute for patching, least-privilege accounts and input validation.
How it works
flowchart TB
subgraph host["Host: your PHP / Laravel application (NOT sandboxed)"]
app["Application code"] --> runner["SecureRunner"]
ws[("Job workspace<br/>private 0700 directory")]
end
runner -->|"argv array, no shell"| bwrap
subgraph box["Bubblewrap sandbox: no network, empty environment, PID/IPC/UTS namespaces"]
bwrap["bwrap"] --> tool["Document-processing binary<br/>(e.g. pdfcpu)"]
tool --- work["/work = only the job workspace<br/>+ the executable, read-only"]
end
ws -. "mounted at /work" .-> work
tool -->|"writes results to /work"| out["resolveOutput(): regular files inside the workspace only"]
out --> app
Loading
The application process itself, the queue worker and the kernel are outside the sandbox. Only the spawned tool is inside it.
Features
Each item below is implemented and covered by the unit or containment tests.
- Per-job workspace isolation: each job gets a random owner-only directory, mounted at
/work; the parent and sibling jobs are not mounted. - Network off by default: all namespaces are unshared; network is an explicit opt-in (
withNetwork(true)). - Scrubbed environment: the inherited environment is cleared; only variables you list are passed.
- Explicit executable and arguments: an argv array goes straight to
proc_open(). No shell, no string concatenation. - Wall-clock timeout and output cap: on breach the sandbox is killed with SIGKILL.
- Resource controls: CPU time, file size and open files via
prlimit, an optional address-space cap, and core dumps always disabled. - Subprocess cleanup: PID namespace plus
--die-with-parenttake every descendant down with the sandbox. - Symlink escape protection: only the workspace is mounted, so links to host paths dangle;
resolveOutput()refuses symlinks and anything outside the workspace. - Fail-closed: a missing or unusable Bubblewrap throws
SandboxUnavailableException. Nothing runs. - Linux containment self-test:
bin/pdfx-sandbox-checkverifies the above on the host using generated canary files.
Security boundary
Secure Runner isolates the spawned document-processing process. It does not automatically sandbox:
- your parent PHP application or web server;
- your Laravel (or other) queue worker;
- PHP code you execute in-process before or after calling the runner, including PHP image decoding such as GD (run it as a separate
phpprocess through the runner instead); - the host kernel. A vulnerability in Bubblewrap, user namespaces or the kernel can defeat the sandbox;
- the content of the documents or the files a tool produces. Treat outputs as untrusted.
Memory is not capped by default (no cgroups without root), and no seccomp filter is applied by default. Read docs/security-model.md before relying on it.
Requirements
| PHP | 8.1 or newer (CI runs 8.1, 8.2, 8.3, 8.4) with proc_open enabled |
| OS for real containment | Linux |
| Bubblewrap | bwrap installed, with unprivileged user namespaces allowed on the host |
prlimit (util-linux) |
recommended, for CPU/file/fd limits. Optional unless you call withRequiredResourceLimits() |
| Composer | to install the library |
macOS/Windows: you can develop against the library and run the unit tests, but the sandbox itself cannot run there. Containment tests skip locally (and CI fails if they skip on Linux).
Installation
composer require pdf-x/secure-runner
On Debian/Ubuntu, install the Linux tools with sudo apt install bubblewrap util-linux, then verify the host with the self-test.
To work on the library itself, clone it instead:
git clone https://github.com/smithveg-stack/pdf-x-secure-runner.git
cd pdf-x-secure-runner
composer install
Quick start
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 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(); }
Dynamically linked tools (shells, interpreters) also need the system libraries: SandboxConfig::strict()->withSystemLibraries().
Read output files with $workspace->resolveOutput('out.pdf'), never with a raw path.
Examples
examples/basic.php: a harmless command in the sandbox.examples/pdfcpu.php: run your own installed pdfcpu. pdfcpu is not bundled and is not maintained by this project; you must install and verify the binary yourself.examples/laravel-example.php: a generic Laravel queue job. The library is framework-independent; Laravel is only an integration example and is not a dependency.
Self-test
Check every host you deploy to:
php bin/pdfx-sandbox-check # from a clone vendor/bin/pdfx-sandbox-check # from a project that installed the package
It uses only generated canary files and reports PASS/FAIL for: Bubblewrap availability, running in a workspace, parent-directory and
sibling-job invisibility, environment leakage, network access, writes outside the workspace, symlink escape, and timeout / process cleanup.
It never reads a real .env or any real secret. A pass shows that this environment and configuration behaved as tested, not a universal guarantee.
Testing and CI
composer install vendor/bin/phpunit --testsuite unit # runs anywhere vendor/bin/phpunit --testsuite containment # Linux + bwrap; skips when unavailable
The GitHub Actions workflow runs unit tests on PHP 8.1 to 8.4, then a separate Linux job installs Bubblewrap and runs
the self-check and the containment suite with PDFX_REQUIRE_BWRAP=1. That setting makes an unavailable Bubblewrap a failure, so containment
tests cannot silently pass by being skipped.
Documentation
- Architecture
- Security model
- Integration guide
- Security policy (how to report vulnerabilities)
- Contributing
- Changelog
Project origin
PDF-X Secure Runner was developed from security engineering work performed while building PDF-X, an online PDF processing service (https://pdf-x.co). This repository is a separate, standalone library and contains no PDF-X application code.
License
Apache License 2.0. See LICENSE.