What's the vulnerability?

PhpSpreadsheet's IOFactory::load() validates the supplied filename through File::assertFile(), which relies on PHP's is_file(). is_file() returns true for PHP stream wrappersphar://, ftp://, ssh2.sftp:// — so a disallowed wrapper is not rejected before the library opens the path.

When the filename is user-controlled this produces two impacts from one root cause:

  • SSRF — an ftp:// path causes the library to open an outbound connection to an attacker-chosen host.
  • PHAR deserialization — a phar:// path triggers deserialization of the PHAR archive's metadata, enabling PHP object injection.

Any code path that forwards an attacker-influenced filename string into IOFactory::load() (or IOFactory::identify()) is exploitable.

Root Cause Analysis

# RCA Report: CVE-2026-34084 — PhpSpreadsheet SSRF via unsafe stream wrapper in IOFactory::load()

## Summary

PhpSpreadsheet's `IOFactory::load()` and `IOFactory::identify()` methods validate user-supplied filenames through `File::assertFile()`, which internally relies on PHP's `is_file()` function. PHP's `is_file()` returns `true` for various stream wrapper protocols such as `ftp://`, `phar://`, and `ssh2.sftp://`. Because the library did not explicitly reject stream wrappers, an attacker-controlled filename could trigger an outbound network connection (SSRF via `ftp://`) or PHAR metadata deserialization (object injection via `phar://`). The fix adds a `prohibitWrappers()` guard in `File::assertFile()` and `File::testFileNoThrow()` that rejects any path containing a recognized URL scheme before `is_file()` is evaluated.

## Impact

- **Package**: `phpoffice/phpspreadsheet` (Packagist)
- **Repository**: https://github.com/PHPOffice/PhpSpreadsheet
- **Affected versions**: `<= 1.30.2` (and corresponding ranges in 2.x, 3.x, 4.x, 5.x lines)
- **Fixed versions**: `1.30.3`, `2.1.15`, `2.4.4`, `3.10.4`, `5.6.0`
- **Severity**: Critical (CVSS 9.2)
- **Consequences**:
  - **SSRF**: Outbound connections to attacker-controlled hosts via `ftp://` paths.
  - **PHAR Deserialization**: Object injection via `phar://` paths, potentially leading to remote code execution.

## Root Cause

In `src/PhpSpreadsheet/Shared/File.php`, the `assertFile()` method was implemented as:

```php
public static function assertFile(string $filename, string $zipMember = ''): void
{
    if (!is_file($filename)) {
        throw new ReaderException('File "' . $filename . '" does not exist.');
    }
    // ...
}
```

PHP's `is_file()` natively supports stream wrappers. When passed an `ftp://` path, `is_file()` initiates an FTP connection to the remote host. When passed a `phar://` path, `is_file()` triggers PHAR metadata deserialization. The library never validated the filename for stream-wrapper schemes before delegating to `is_file()`.

The fix (commit `b6c44a4c` / tag `1.30.3`) adds a `prohibitWrappers()` helper:

```php
public static function prohibitWrappers(string $filename): void
{
    $scheme = parse_url($filename, PHP_URL_SCHEME);
    if (is_string($scheme) && strlen($scheme) > 1) {
        throw new Exception(
            "Stream wrappers are not permitted as file paths: {$filename}"
        );
    }
}
```

This is called at the top of both `assertFile()` and `testFileNoThrow()`, ensuring that any path with a recognized scheme is rejected before any I/O (including `is_file()`) occurs.

## Reproduction Steps

The reproduction script is `repro/reproduction_steps.sh`. It performs the following steps:

1. Checks for `php`, `composer`, and `python3` availability.
2. Finds an ephemeral TCP port on `127.0.0.1`.
3. In a scratch directory, installs `phpoffice/phpspreadsheet:1.30.2` (vulnerable).
4. Creates a PHP script that calls `IOFactory::load("ftp://127.0.0.1:<port>/x")`.
5. Starts a Python TCP listener on the chosen port.
6. Runs the PHP script and records whether the listener receives a connection.
7. Repeats steps 3–6 for `phpoffice/phpspreadsheet:1.30.3` (fixed).
8. Compares results and writes `validation_verdict.json`.

**Expected evidence**:
- **Vulnerable (1.30.2)**: The Python listener logs `CONNECTION_RECEIVED` because `is_file("ftp://...")` opens an outbound FTP connection before failing.
- **Fixed (1.30.3)**: The Python listener logs `NO_CONNECTION` because `prohibitWrappers()` throws an exception before `is_file()` is reached.

## Evidence

Log files are written to `$ROOT/logs/` during script execution:

- `logs/vuln_listener.log`: `CONNECTION_RECEIVED from ('127.0.0.1', <port>)`
- `logs/vuln_php.log`: `EXCEPTION: File "ftp://127.0.0.1:<port>/x" does not exist.`
- `logs/fixed_listener.log`: `NO_CONNECTION`
- `logs/fixed_php.log`: `EXCEPTION: Stream wrappers are not permitted as file paths: ftp://127.0.0.1:<port>/x`

Validation verdict is written to `validation_verdict.json`:

```json
{
  "verdict": "confirmed",
  "vulnerable_version": "1.30.2",
  "fixed_version": "1.30.3",
  "vulnerable_indicator": "CONNECTION_RECEIVED from ('127.0.0.1', ...)",
  "fixed_indicator": "NO_CONNECTION",
  "vulnerability_type": "SSRF via unsafe stream wrapper in IOFactory::load()",
  "details": "PhpSpreadsheet's File::assertFile() used is_file() which accepts PHP stream wrappers like ftp://. Fixed version adds prohibitWrappers() to reject stream wrappers before is_file() is called."
}
```

Environment:
- PHP 8.4.19
- Composer 2.8.12
- Python 3.x

## Recommendations / Next Steps

1. **Upgrade immediately** to `phpoffice/phpspreadsheet >= 1.30.3` (or the matching fixed version in your major line).
2. **Input validation**: If wrapping `IOFactory::load()` in application code, add an independent allow-list or deny-list for filenames before passing them to the library.
3. **Regression test**: Add a unit test that asserts `File::assertFile("ftp://...")` and `File::assertFile("phar://...")` throw exceptions.
4. **Disable unnecessary wrappers**: In hardened deployments, consider disabling the `ftp` and `phar` PHP stream wrappers via `stream_wrapper_unregister()` if not required by the application.

## Additional Notes

- **Idempotency**: The reproduction script has been executed twice consecutively with identical results, confirming reproducibility.
- **SSRF vector chosen**: The SSRF vector (`ftp://`) was selected over PHAR deserialization because it produces a clean, deterministic, network-free observable on localhost (a TCP connection attempt). No external network, database, or browser is required.
- **Edge cases**: The fix uses `strlen($scheme) > 1` to avoid false positives on Windows absolute paths (e.g., `C:\...`), since single-character schemes do not correspond to any known PHP stream wrapper.
One Command

Verify with pruva-verify

Run the Pruva CLI to automatically fetch and execute the reproduction script.

pruva-verify REPRO-2026-00150
or pruva-verify GHSA-q4q6-r8wh-5cgh
or pruva-verify CVE-2026-34084
Install: curl -fsSL https://pruva.dev/install.sh | sh

Or Run Manually

1

Download the script

curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00150/artifacts/reproduction_steps.sh
2

Make executable

chmod +x reproduction_steps.sh
3

Run the script

./reproduction_steps.sh
Run in a VM, container, or disposable environment. This exploits a real vulnerability.

How Pruva Reproduced This

Watch the AI agent's step-by-step process.

Loading session...

Artifacts

No artifacts available