Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐘
🐘 Packagist
Not in CISA KEV
MEDIUM severity

CVE-2026-108360 — phpspreadsheet

MEDIUMFix: PHPOffice/PhpSpreadsheet@2999938

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

Also known asGHSA-2phh-27fw-q4gc
Published
Updated
Affected
5 pkgs
Patched
5 / 5
Exploits
None indexed
Exploitation data as of Oct 27, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

EPSS Exploitation Probability

via FIRST.org ↗
0.2%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs13th percentile — riskier than 13% of all scored CVEsHighest risk

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

5 pkgs affected
🐘phpoffice/phpspreadsheet🐘phpoffice/phpspreadsheet🐘phpoffice/phpspreadsheet🐘phpoffice/phpspreadsheet🐘phpoffice/phpspreadsheet

Real-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):/) permits http/https (the redirect-following schemes) and runs on the initial URL only.
  • Opt-in gate if (!$allowExternal) at :128 (requires setAllowExternalImages(true); default false, 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-152 sets user_agent + header (+ ssl.crypto_method for https) but omits follow_location. git grep follow_location on tag 5.9.0 yields a single hit — Service.php:45 (the CVE-2026-59931 fix) — confirming it was never added to Drawing.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

  1. Attacker server https://trusted-whitelisted.example/redir.png returns 302 Location: http://169.254.169.254/latest/meta-data/.
  2. evil.xlsx with xl/drawings/_rels/drawing1.xml.rels: <Relationship ... Type=".../image" Target="https://trusted-whitelisted.example/redir.png" TargetMode="External"/>.
  3. 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 via Reader\Html (Html.php:1237) with the same reader settings.

Attack Chain

  1. Entry: crafted evil.xlsx, xl/drawings/_rels/*.rels TargetMode="External", Target="https://trusted.example/redir.png" (whitelisted host). Guard: none (legal external-image relationship). Bypass proof: Target stored verbatim -> $url at Reader/Xlsx.php:1544.
  2. Config: victim loads with setAllowExternalImages(true)->setIsWhitelisted($cb) where $cb allows trusted.example. Guard: Drawing.php:128 opt-in (satisfied) + :131 whitelist. Bypass proof: initial URL is whitelisted -> returns true; check runs once on $path.
  3. Fetch/redirect: file_get_contents (:154) GETs the URL; whitelisted host returns 302 Location: http://169.254.169.254/.... Guard: stream context (:140-152). Bypass proof: no follow_location key -> PHP follows the redirect (default follow_location=1, max_redirects=20) with no re-validation.
  4. 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 at Service.php:45; Drawing.php has none.
  • CVE-2026-59931's own regression harness (ServerTest.php + redirect.php: a whitelisted host 302-redirects to direct.php; only after follow_location => 0 does the fetch stop following it) demonstrates the exact behavior. That fix is present in Service.php and absent in Drawing.php.
  • git grep isWhitelisted 5.9.0 -- src/ confirms the callback is applied only at Drawing.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

5 total 5 fixed
EcosystemPackageVulnerable rangeFix
🐘Packagistphpoffice/phpspreadsheet≥ 4.0.0&&< 5.10.05.10.0composer require phpoffice/phpspreadsheet:^5.10.0
🐘Packagistphpoffice/phpspreadsheet≥ 3.3.0&&< 3.10.83.10.8composer require phpoffice/phpspreadsheet:^3.10.8
🐘Packagistphpoffice/phpspreadsheet≥ 2.2.0&&< 2.4.82.4.8composer require phpoffice/phpspreadsheet:^2.4.8
🐘Packagistphpoffice/phpspreadsheet≥ 2.0.0&&< 2.1.192.1.19composer require phpoffice/phpspreadsheet:^2.1.19
🐘Packagistphpoffice/phpspreadsheetall versions1.30.7composer require phpoffice/phpspreadsheet:^1.30.7

Detection & mitigation playbook

Open-source dependency
  1. Detect

    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.

  2. 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.

  3. 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

## 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
O3 Security · Impact-Aware SCA

Is CVE-2026-108360 in your dependencies?

Find it across Packagist, including transitive dependencies.

CVE-2026-108360: phpspreadsheet SSRF — Fixed in 5.10.0