typo3/phar-stream-wrapper
A PHP stream wrapper that hardens phar:// file operations by intercepting metadata deserialization, helping prevent object-injection attacks when handling untrusted archives. Useful for legacy and modern PHP security hardening.
The package is a security shim, not a feature library. Most Laravel projects already have it in vendor/ because Composer (2.x) depends on it, so check first:
composer show typo3/phar-stream-wrapper
composer why typo3/phar-stream-wrapper
If it is present transitively, you still get no protection in your own app: Composer registers the interceptor only for its own process. Your application must register it itself.
Minimal setup — register the wrapper as early as possible in the request lifecycle, right after the autoloader:
// public/index.php
require __DIR__.'/../vendor/autoload.php';
use TYPO3\PharStreamWrapper\Manager;
use TYPO3\PharStreamWrapper\PharStreamWrapper;
Manager::initialize(
(new Manager())->withAssertion(new \TYPO3\PharStreamWrapper\Assertion())
);
PharStreamWrapper::register();
Files to read first, in order:
PharStreamWrapper — the stream wrapper itself; register(), unregister(), restore().Manager — holds the active assertion/behavior and is consulted on every phar operation.Assertion / Assertable — the policy object that decides whether a given phar:// path + command is allowed.Behavior — the behavior flag constants the assertion maps to (read, write, rename, delete, invoke).Interceptor / ConjunctionInterceptor / ExtensionInterceptor — optional pre-assertion hooks.First real use case: an upload endpoint where a malicious file named avatar.jpg (with a forged phar manifest) plus any file_exists(), is_file(), getimagesize(), or Storage::exists() call on a user-controlled phar:// path triggers the well-known PHP metadata deserialization chain. The wrapper makes those calls fail before the phar is touched.
Global default-deny in a service provider. The wrapper is global static state, so the cleanest Laravel integration is a dedicated provider registered in bootstrap/providers.php (or config/app.php on older versions):
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use TYPO3\PharStreamWrapper\Manager;
use TYPO3\PharStreamWrapper\PharStreamWrapper;
final class PharStreamWrapperServiceProvider extends ServiceProvider
{
public function register(): void
{
if (! config('security.phar.block_untrusted', true)) {
return;
}
Manager::initialize(
(new Manager())->withAssertion(
new TrustedArchiveAssertion(config('security.phar.trusted', []))
)
);
PharStreamWrapper::register();
}
}
register() runs before boot() and before most of the request, which is acceptable for upload/import style threats. For maximum coverage put the two calls directly in public/index.php; anything that can trigger a phar operation before the provider runs is unprotected.
Custom Assertable for a whitelist. The built-in Assertion is pattern-based and easy to misconfigure; for a small app, an explicit allowlist is clearer and cheaper to reason about:
namespace App\Security;
use TYPO3\PharStreamWrapper\Assertable;
final class TrustedArchiveAssertion implements Assertable
{
/** @param string[] $trustedPrefixes absolute paths, no scheme */
public function __construct(private array $trustedPrefixes) {}
public function assert(string $path, string $command): bool
{
if (! str_starts_with($path, 'phar://')) {
return true;
}
foreach ($this->trustedPrefixes as $prefix) {
if (str_starts_with($path, 'phar://'.$prefix)) {
return true;
}
}
return false;
}
}
Config values in config/security.php:
'phar' => [
'block_untrusted' => env('PHAR_BLOCK_UNTRUSTED', true),
'trusted' => [
base_path('vendor/acme/tool.phar'),
],
],
Use the Behavior flags when you need partial access. If you ship your own .phar and need read/invoke access to it, the idiomatic route is the built-in Assertion with behavior flags rather than a hand-rolled class. The exact set of constants (BEHAVIOR_READ, BEHAVIOR_RENAME, BEHAVIOR_DELETE, BEHAVIOR_INVOKE, …) is version-dependent — confirm against vendor/typo3/phar-stream-wrapper/src/Behavior.php in your pinned version before copying snippets from blog posts.
Unregister in tests and long-lived workers. The wrapper is process-global:
protected function tearDown(): void
{
\TYPO3\PharStreamWrapper\PharStreamWrapper::restore();
parent::tearDown();
}
Under Octane/Swoole, registrations survive across requests; do not flip the trust policy per request without also resetting the manager.
Layer with other defenses. This package does not sanitize paths. Keep validating URLs/paths for schemes, keep using realpath()/basename() on uploads, and keep storing uploads with non-executable extensions. The interceptor is the last line of defense.
file_exists() is worthless. Order matters more than the policy.false from the wrapper methods, and some paths throw TYPO3\PharStreamWrapper\Exception. If file_exists() suddenly returns false for a legitimate path, log the denied $path/$command inside your assert() before assuming a filesystem bug..phar extension; .jpg, .zip, and extensionless files can be phars. Do not rely on ExtensionInterceptor alone.unregister() vs restore(). register() replaces the native phar wrapper; unregister() removes the scheme entirely; restore() hands control back to PHP's native wrapper. Use restore() for cleanup, not unregister(), or later Phar operations in the same process may fail in confusing ways.vendor/.Storage bypasses nothing but also covers nothing. The wrapper only sees operations that actually go through phar://. Laravel's local/S3 drivers use fopen/file_get_contents and are unaffected unless the path itself is phar://.assert() is invoked on every phar-wrapped filesystem call. Keep it to string prefix checks; avoid fnmatch, realpath, or config lookups inside it.Assertion uses pattern matching; Windows path separators, case sensitivity, and URL-encoding of the phar:// path can all change matching results. Normalize before comparing.config/ is code you wrote. Publish no vendor files; wrap the package in your own provider and config key so you can toggle it in staging.Assertable in a decorator that logs $path and $command and delegates to the real policy. This is the fastest way to find which legitimate operation your policy is blocking.Assertable (policy) and a custom Interceptor (pre-check, composable with ConjunctionInterceptor). Prefer Assertable for app-level policy and Interceptor only when you need to reject paths before they reach the assertion layer.How can I help you explore Laravel packages today?