DEV Community

smithveg
smithveg

Posted on Originally published at smithveg-stack.github.io

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, an OCR utility, or another document-processing binary. The code is often only a few lines long. It works, so 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) . ' -'
);
Enter fullscreen mode Exit fullscreen mode

Or perhaps you use Symfony Process:

use Symfony\Component\Process\Process;

$process = new Process([
    '/usr/local/bin/some-pdf-tool',
    'input.pdf',
]);

$process->run();
Enter fullscreen mode Exit fullscreen mode

The second version is already better from a command-injection perspective because the arguments are separated rather than concatenated into a shell command.

But there is another question:

What can that child process see if something goes wrong inside it?

That question matters because document parsers are complicated pieces of software processing files that an attacker may completely control.

A PDF can contain compressed streams, fonts, images, object graphs, metadata, embedded files, malformed structures, unusual encodings and many other things.

The parser has to deal with all of them.

2. The hidden security boundary

Imagine your Laravel queue worker runs as:

www-data
Enter fullscreen mode Exit fullscreen mode

Then it starts a PDF processor under the same Unix identity.

Unless you deliberately restrict it, that child process may inherit access to whatever www-data can access.

Depending on the server, that may include:

  • application source code
  • configuration files
  • environment variables
  • credentials available to the process
  • other files owned by the same account
  • other job directories
  • network access
  • writable temporary directories
  • CPU, file descriptors and disk resources

The PDF processor probably does not need any of that.

It may need only:

/work/input.pdf
Enter fullscreen mode Exit fullscreen mode

and somewhere to write:

/work/output.pdf
Enter fullscreen mode Exit fullscreen mode

That difference became the design question for me:

Can the document-processing process see only the files required for one job, instead of everything the application account can reach?

That is the problem I wanted to solve.

3. Why input validation alone is not enough

Input validation still matters.

You should validate things such as:

  • file type
  • extension
  • MIME type
  • file size
  • page count where appropriate
  • command options
  • filenames and paths

But validation and sandboxing solve different problems.

Validation asks:

Should I accept this input?

Sandboxing asks:

If the processor is compromised anyway, what can it reach?

A perfectly valid PDF can still trigger a bug in a parser.

Likewise, a newly discovered vulnerability may affect a file that passes every validation rule you currently have.

So I treat the sandbox as defence-in-depth, not as a substitute for:

  • validation
  • patching
  • least-privilege Unix accounts
  • sensible server configuration
  • process timeouts
  • monitoring

The goal is not to prove that a document processor is safe.

The goal is to reduce the attack surface and limit the blast radius if it is not.

4. Building a smaller execution boundary

For Linux, I chose Bubblewrap.

Bubblewrap is a small sandboxing tool that uses Linux namespaces and mount controls to construct a restricted environment for a process.

The model I wanted was simple:

PHP / Laravel application
        |
        | starts job
        v
Secure Runner
        |
        | creates Bubblewrap sandbox
        v
Document-processing binary
        |
        +-- sees /work
        +-- sees its executable
        +-- no inherited environment
        +-- no network by default
        +-- limited resources
Enter fullscreen mode Exit fullscreen mode

The PHP application itself is not inside the sandbox.

Only the spawned document-processing process is.

That distinction is important.

One workspace per job

Each processing job gets a private directory.

Inside the sandbox it is mounted as:

/work
Enter fullscreen mode Exit fullscreen mode

The processor does not need the application's source tree.

It does not need the parent jobs directory.

It does not need neighbouring users' files.

It only gets the workspace for that job.

No network by default

The sandbox uses separate namespaces, and network access is disabled by default.

For most PDF operations there is no legitimate reason for the binary processing a local file to connect to the Internet.

If network access is actually required, it should be an explicit decision rather than an accidental inheritance.

Clear the environment

A child process normally inherits environment variables from its parent.

That may include information the document processor has no reason to know.

The sandbox therefore starts with a cleared environment and only passes explicitly allowed variables.

This changes the model from:

inherit everything except what we remember to remove

to:

inherit nothing except what we deliberately add.

No shell

Executable and arguments are passed as an argv array through proc_open().

Conceptually:

executable
argument 1
argument 2
argument 3
Enter fullscreen mode Exit fullscreen mode

rather than:

"executable argument1 argument2 argument3"
Enter fullscreen mode Exit fullscreen mode

There is no shell command string to interpret.

This does not replace input validation, but it removes an unnecessary command-parsing layer.

Time and resource limits

A hostile document does not need remote code execution to cause trouble.

It might simply make a parser consume resources.

The runner therefore supports controls including:

  • wall-clock timeout
  • CPU limit through prlimit
  • file-size limit
  • open-file limit
  • output-size cap
  • disabled core dumps

If the process exceeds its deadline, the sandbox and its descendants are terminated.

This is useful for accidental runaway processing as well as deliberately hostile input.

Fail closed

One requirement mattered more than convenience:

If Bubblewrap is unavailable, do not quietly run the command without it.

The runner throws an exception instead.

That means a deployment problem becomes a failed processing job, rather than an invisible downgrade from sandboxed execution to normal execution.

For security controls, I strongly prefer that behaviour.

5. The sandbox has limits

This part is just as important as the feature list.

The sandbox does not automatically protect the entire PHP application.

It does not sandbox:

  • the parent PHP process
  • the Laravel queue worker
  • unrelated PHP code
  • PHP work performed in-process before or after the runner
  • the host kernel
  • Bubblewrap itself

For example, if you decode a hostile image using an in-process PHP extension before invoking the sandbox, that decoding step is outside this boundary.

The sandbox also cannot protect you from vulnerabilities in the Linux kernel or Bubblewrap that defeat the isolation mechanism itself.

And the output file should still be treated as untrusted.

If a tool creates:

output.pdf
Enter fullscreen mode Exit fullscreen mode

that does not magically mean the file is safe for every other parser that may later open it.

Another current limitation is memory control.

Without using cgroups or another external mechanism, memory is not capped by default.

There is also no seccomp syscall filter applied by default.

This is why I describe the project as a tool that reduces the attack surface, not a universal security boundary.

6. A PHP example

The reusable part of this work became PDF-X Secure Runner.

It is installable through Composer:

composer require pdf-x/secure-runner
Enter fullscreen mode Exit fullscreen mode

A basic example looks like this:

<?php

use PdfX\SecureRunner\{
    JobWorkspace,
    ResourceLimits,
    SandboxConfig,
    SecureRunner
};

$workspace = JobWorkspace::create('/var/lib/myapp/jobs');

try {
    $workspace->write(
        'in.pdf',
        $uploadedBytes
    );

    $runner = new SecureRunner(
        SandboxConfig::strict()
    );

    $result = $runner->run(
        executable: '/usr/local/bin/pdfcpu',
        arguments: [
            'info',
            $workspace->sandboxPath('in.pdf'),
        ],
        workspace: $workspace,
        limits: new ResourceLimits(
            timeoutSeconds: 60
        ),
    );

    if ($result->isSuccessful()) {
        echo $result->stdout;
    }
} finally {
    $workspace->cleanup();
}
Enter fullscreen mode Exit fullscreen mode

The application writes the input into the job workspace.

Inside the sandbox, the processor sees that workspace as /work.

So:

$workspace->sandboxPath('in.pdf')
Enter fullscreen mode Exit fullscreen mode

provides the path from the sandbox's point of view.

The binary does not need access to the real host path.

For produced files, the application can resolve outputs back through the workspace rather than blindly trusting arbitrary paths returned by the child process.

The workspace logic also rejects symlinks and paths escaping the job directory.

Static and dynamic binaries

A statically linked binary may need almost nothing except its executable and the job workspace.

A dynamically linked binary needs its runtime libraries.

For that case the configuration can explicitly expose the required system libraries:

$config = SandboxConfig::strict()
    ->withSystemLibraries();
Enter fullscreen mode Exit fullscreen mode

There is a security trade-off here.

Every additional read-only path you expose becomes visible to the sandboxed process.

So the default should remain as narrow as practical.

Passwords and secrets

Another small but important point: avoid putting secrets in command arguments where possible.

Arguments may be visible in process listings on the host.

If a tool accepts a password or secret over standard input, prefer that mechanism.

The runner supports passing stdin separately from the argv array.

7. Testing the boundary

Sandbox code is easy to write incorrectly.

So I wanted more than unit tests around PHP objects.

The package includes:

vendor/bin/pdfx-sandbox-check
Enter fullscreen mode Exit fullscreen mode

The self-check uses generated canary files to test properties such as:

  • Bubblewrap availability
  • workspace visibility
  • parent-directory isolation
  • sibling-job isolation
  • environment leakage
  • network access
  • writes outside the workspace
  • symlink escape behaviour
  • timeout and process cleanup

It reports PASS or FAIL.

It does not inspect real secrets.

It does not try to prove the system is universally secure.

Its job is narrower:

Does this host appear to enforce the containment behaviour the application expects?

That matters because Linux distributions and hosting environments can differ in their user-namespace policies.

A configuration that works on one server may fail on another.

For that reason, the self-test should be run on every host where the package is deployed.

The project also runs its Linux containment suite in CI with Bubblewrap installed.

On platforms without Bubblewrap, such as my macOS development machine, the sandbox itself cannot run. The library is designed to fail closed in that situation.

8. Why I open-sourced it

This started while I was building PDF-X, an online PDF-processing service.

The original question was specific:

How should I run document-processing binaries against files uploaded by strangers?

But the answer was not really specific to PDF-X.

Any PHP application may eventually need to execute an external tool against un

Top comments (0)