## Ticket: CVE-2026-34084 — PhpSpreadsheet SSRF / unsafe stream wrapper in `IOFactory::load()`

**Advisory**: GHSA-q4q6-r8wh-5cgh — https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-q4q6-r8wh-5cgh
**CVE**: CVE-2026-34084 | **CWE-502** (Deserialization) / **CWE-918** (SSRF)
**Severity**: Critical — CVSS 9.2
**Package**: `phpoffice/phpspreadsheet` (Packagist) | **Repository**: https://github.com/PHPOffice/PhpSpreadsheet

### Impact

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 wrappers** — `phar://`, `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.

### Affected / fixed versions

Affected: `phpoffice/phpspreadsheet` `<= 1.30.2` (with additional affected
ranges in the 2.x, 3.x, 4.x and 5.x lines).
Fixed: **`1.30.3`** (also `2.1.15`, `2.4.4`, `3.10.4`, `5.6.0`).

Reproduce on a vulnerable build (**`1.30.2`**) and verify the fix on
**`1.30.3`**.

### Where to look

The repository is https://github.com/PHPOffice/PhpSpreadsheet . Inspect the
diff between the tags to confirm the root cause and the guard:

```bash
git clone https://github.com/PHPOffice/PhpSpreadsheet.git
cd PhpSpreadsheet && git diff 1.30.2 1.30.3
```

The fix tightens `File::assertFile()` so disallowed stream wrappers (`phar://`,
`ftp://`, `ssh2.sftp://`) are rejected rather than passed through by the
`is_file()` check.

### Reproduction approach

For a clean, deterministic observable, use the **SSRF vector**. No external
network, database, or browser is required — the connection difference is
observable on localhost.

1. In a scratch directory, install the **vulnerable** build:
   `composer require phpoffice/phpspreadsheet:1.30.2`
2. Start a local TCP listener on `127.0.0.1:<port>`.
3. Call `IOFactory::load()` (or `IOFactory::identify()`) with an
   `ftp://127.0.0.1:<port>/x` path and observe whether the local listener
   receives an inbound connection.
4. Repeat with the **fixed** build:
   `composer require phpoffice/phpspreadsheet:1.30.3`.

### Expected result

| Build | `IOFactory::load('ftp://127.0.0.1:<port>/x')` | Observable |
|-------|------------------------------------------------|------------|
| `phpoffice/phpspreadsheet:1.30.2` (vulnerable) | `is_file()` accepts the `ftp://` wrapper | **local listener receives a connection** (SSRF) |
| `phpoffice/phpspreadsheet:1.30.3` (fixed) | disallowed wrapper rejected before any I/O | **no connection**; call fails with a validation error |

- **Vulnerable indicator**: the local listener on `127.0.0.1:<port>` receives a
  connection — the library made an outbound request to an attacker-chosen host.
  The same `File::assertFile()` / `is_file()` gap also lets a `phar://` path
  reach PHAR metadata deserialization.
- **Fixed indicator**: the disallowed stream wrapper is rejected before any
  connection is opened; the listener receives nothing.

### Expected artifacts

- `reproduction_steps.sh` — installs both versions, starts a local TCP
  listener, and runs the `IOFactory::load()` SSRF PoC for each build.
- `validation_verdict.json` — `verdict: confirmed`, with the vulnerable and
  fixed indicators captured.
- Logs capturing the listener output and PHP script output for `1.30.2` and
  `1.30.3`.
