CVE-2026-108360 is a medium-severity (CVSS 6.4) CWE-863 vulnerability in phpoffice/phpspreadsheet. A fix is available for phpoffice/phpspreadsheet — see the affected versions and patch details below.
PhpSpreadsheet: SSRF whitelist bypass via unre-validated HTTP redirect in Drawing::setPath external image loading
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
How urgent is this, really
CVE-2026-108360 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.
Where this sits among everything scored
Of 396,966 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.
Real-World Exposure
phpoffice/phpspreadsheet🐘phpoffice/phpspreadsheet🐘phpoffice/phpspreadsheet🐘phpoffice/phpspreadsheet🐘phpoffice/phpspreadsheetReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.
Description
Summary
PhpSpreadsheet's Worksheet\Drawing::setPath() validates an external-image URL against an operator-supplied isWhitelisted callback once, on the initial URL only, then fetches it with file_get_contents() using a stream context that does not set follow_location => 0. A whitelisted host that returns an HTTP 302 Location: redirect to an internal target is therefore followed by PHP's HTTP wrapper (default follow_location=1, max_redirects=20) with no re-validation of the redirect hop — defeating the whitelist and yielding a blind SSRF from the server.
This is an unfixed variant of CVE-2026-59931 (GHSA-6hq5-7373-42rg, the WEBSERVICE redirect-whitelist-bypass): the follow_location => 0 fix (commit 7ef7b25e) was applied ONLY to Calculation/Web/Service.php; Worksheet/Drawing.php was never touched.
Root Cause
src/PhpSpreadsheet/Worksheet/Drawing.php:
- Scheme allowlist at
:125(/^(http|https|file|ftp|s3):/) permitshttp/https(the redirect-following schemes) and runs on the initial URL only. - Opt-in gate
if (!$allowExternal)at:128(requiressetAllowExternalImages(true); defaultfalse,BaseReader.php:55). - Whitelist check
if ($isWhitelisted !== null && !$isWhitelisted($path))at:131— validates the INITIAL URL only; never re-applied after a redirect. - Stream context built at
:140-152setsuser_agent+header(+ssl.crypto_methodfor https) but omitsfollow_location.git grep follow_locationon tag 5.9.0 yields a single hit —Service.php:45(the CVE-2026-59931 fix) — confirming it was never added toDrawing.php. - Sink
file_get_contents($path, false, $ctx)at:154.
Impact
Blind SSRF from the server: reach and trigger internal GET-driven endpoints, coarse internal reachability/port probing, and reach cloud-metadata endpoints. The fetched body is written to a temp file, checked with isImage() (mime image/*, or application/octet-stream with bin/emf extension), only image dimensions are stored, and the temp file is unlinked — so the response body is not exfiltrated (non-image responses fail isImage()). This is a blind GET primitive, not full response read.
Proof of Concept
- Attacker server
https://trusted-whitelisted.example/redir.pngreturns302 Location: http://169.254.169.254/latest/meta-data/. evil.xlsxwithxl/drawings/_rels/drawing1.xml.rels:<Relationship ... Type=".../image" Target="https://trusted-whitelisted.example/redir.png" TargetMode="External"/>.- Victim:
$r = new Xlsx(); $r->setAllowExternalImages(true)->setIsWhitelisted(fn($p)=>str_contains($p,'trusted-whitelisted.example')); $r->load('evil.xlsx');-> the server-side GET is forced to the internal target despite the whitelist. HTML-reader variant:<img src="https://trusted-whitelisted.example/redir.png">loaded viaReader\Html(Html.php:1237) with the same reader settings.
Attack Chain
- Entry: crafted
evil.xlsx,xl/drawings/_rels/*.relsTargetMode="External",Target="https://trusted.example/redir.png"(whitelisted host). Guard: none (legal external-image relationship). Bypass proof: Target stored verbatim ->$urlatReader/Xlsx.php:1544. - Config: victim loads with
setAllowExternalImages(true)->setIsWhitelisted($cb)where$cballowstrusted.example. Guard:Drawing.php:128opt-in (satisfied) +:131whitelist. Bypass proof: initial URL is whitelisted -> returns true; check runs once on$path. - Fetch/redirect:
file_get_contents(:154) GETs the URL; whitelisted host returns302 Location: http://169.254.169.254/.... Guard: stream context (:140-152). Bypass proof: nofollow_locationkey -> PHP follows the redirect (defaultfollow_location=1,max_redirects=20) with no re-validation. - Impact: server issues a blind GET to the internal target (body temp-filed,
isImage()-checked, dimensions stored, temp file unlinked — body not exfiltrated).
Bypass Evidence
git grep follow_location 5.9.0 -- src/-> single hit atService.php:45;Drawing.phphas none.- CVE-2026-59931's own regression harness (
ServerTest.php+redirect.php: a whitelisted host 302-redirects todirect.php; only afterfollow_location => 0does the fetch stop following it) demonstrates the exact behavior. That fix is present inService.phpand absent inDrawing.php. git grep isWhitelisted 5.9.0 -- src/confirms the callback is applied only atDrawing.php:131(initial$path), never after the redirect.
Affected Versions
>= 5.5.0, <= 5.9.0. The isWhitelisted control being bypassed was introduced in 5.5.0 (commit 50a7552f); present and unfixed on the latest release (5.9.0) and on HEAD (05e99ebf). No post-release fix to Drawing.php.
Suggested Fix
Add 'follow_location' => 0 (and/or 'max_redirects' => 0) to $ctxArray['http'] in Drawing::setPath() (:140-148), mirroring the CVE-2026-59931 fix at Service.php:45; alternatively follow redirects manually and re-run $isWhitelisted on each hop.
Reported by zx (Jace) — GitHub: @manus-use
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐘Packagist | phpoffice/phpspreadsheet | ≥ 4.0.0&&< 5.10.0 | 5.10.0composer require phpoffice/phpspreadsheet:^5.10.0 |
| 🐘Packagist | phpoffice/phpspreadsheet | ≥ 3.3.0&&< 3.10.8 | 3.10.8composer require phpoffice/phpspreadsheet:^3.10.8 |
| 🐘Packagist | phpoffice/phpspreadsheet | ≥ 2.2.0&&< 2.4.8 | 2.4.8composer require phpoffice/phpspreadsheet:^2.4.8 |
| 🐘Packagist | phpoffice/phpspreadsheet | ≥ 2.0.0&&< 2.1.19 | 2.1.19composer require phpoffice/phpspreadsheet:^2.1.19 |
| 🐘Packagist | phpoffice/phpspreadsheet | all versions | 1.30.7composer require phpoffice/phpspreadsheet:^1.30.7 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for phpoffice/phpspreadsheet, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update phpoffice/phpspreadsheet to 5.10.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-108360 is resolved across your whole dependency graph.
Workarounds
Restrict outbound requests from the affected component to an allowlist of hosts, block access to link-local and internal address ranges at the network layer, and require authentication on internal services so a forged request cannot reach them unauthenticated.
Frequently Asked Questions
Is CVE-2026-108360 in your dependencies?
Find it across Packagist, including transitive dependencies.