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.
src/PhpSpreadsheet/Worksheet/Drawing.php:
:125 (/^(http|https|file|ftp|s3):/) permits http/https (the redirect-following schemes) and runs on the initial URL only.if (!$allowExternal) at :128 (requires setAllowExternalImages(true); default false, BaseReader.php:55).if ($isWhitelisted !== null && !$isWhitelisted($path)) at :131 — validates the INITIAL URL only; never re-applied after a redirect.: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.file_get_contents($path, false, $ctx) at :154.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.
https://trusted-whitelisted.example/redir.png returns 302 Location: http://169.254.169.254/latest/meta-data/.evil.xlsx with xl/drawings/_rels/drawing1.xml.rels: <Relationship ... Type=".../image" Target="https://trusted-whitelisted.example/redir.png" TargetMode="External"/>.$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.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.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.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.isImage()-checked, dimensions stored, temp file unlinked — body not exfiltrated).git grep follow_location 5.9.0 -- src/ -> single hit at Service.php:45; Drawing.php has none.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.>= 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.
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
{
"cwe_ids": [
"CWE-863",
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-27T15:38:39Z",
"nvd_published_at": "2026-10-22T18:17:26Z",
"severity": "MODERATE"
}