{"id":"GHSA-2phh-27fw-q4gc","summary":"PhpSpreadsheet: SSRF whitelist bypass via unre-validated HTTP redirect in Drawing::setPath external image loading","details":"## Summary\nPhpSpreadsheet'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 =\u003e 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.\n\nThis is an unfixed variant of CVE-2026-59931 (GHSA-6hq5-7373-42rg, the WEBSERVICE redirect-whitelist-bypass): the `follow_location =\u003e 0` fix (commit `7ef7b25e`) was applied ONLY to `Calculation/Web/Service.php`; `Worksheet/Drawing.php` was never touched.\n\n## Root Cause\n`src/PhpSpreadsheet/Worksheet/Drawing.php`:\n- Scheme allowlist at `:125` (`/^(http|https|file|ftp|s3):/`) permits `http`/`https` (the redirect-following schemes) and runs on the initial URL only.\n- Opt-in gate `if (!$allowExternal)` at `:128` (requires `setAllowExternalImages(true)`; default `false`, `BaseReader.php:55`).\n- Whitelist check `if ($isWhitelisted !== null && !$isWhitelisted($path))` at `:131` — validates the INITIAL URL only; never re-applied after a redirect.\n- 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`.\n- Sink `file_get_contents($path, false, $ctx)` at `:154`.\n\n## Impact\nBlind 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 `unlink`ed — so the response body is **not** exfiltrated (non-image responses fail `isImage()`). This is a blind GET primitive, not full response read.\n\n## Proof of Concept\n1. Attacker server `https://trusted-whitelisted.example/redir.png` returns `302 Location: http://169.254.169.254/latest/meta-data/`.\n2. `evil.xlsx` with `xl/drawings/_rels/drawing1.xml.rels`: `\u003cRelationship ... Type=\".../image\" Target=\"https://trusted-whitelisted.example/redir.png\" TargetMode=\"External\"/\u003e`.\n3. Victim: `$r = new Xlsx(); $r-\u003esetAllowExternalImages(true)-\u003esetIsWhitelisted(fn($p)=\u003estr_contains($p,'trusted-whitelisted.example')); $r-\u003eload('evil.xlsx');` -\u003e the server-side GET is forced to the internal target despite the whitelist.\nHTML-reader variant: `\u003cimg src=\"https://trusted-whitelisted.example/redir.png\"\u003e` loaded via `Reader\\Html` (`Html.php:1237`) with the same reader settings.\n\n## Attack Chain\n1. 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 -\u003e `$url` at `Reader/Xlsx.php:1544`.\n2. Config: victim loads with `setAllowExternalImages(true)-\u003esetIsWhitelisted($cb)` where `$cb` allows `trusted.example`. Guard: `Drawing.php:128` opt-in (satisfied) + `:131` whitelist. Bypass proof: initial URL is whitelisted -\u003e returns true; check runs once on `$path`.\n3. 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 -\u003e PHP follows the redirect (default `follow_location=1`, `max_redirects=20`) with no re-validation.\n4. Impact: server issues a blind GET to the internal target (body temp-filed, `isImage()`-checked, dimensions stored, temp file unlinked — body not exfiltrated).\n\n## Bypass Evidence\n- `git grep follow_location 5.9.0 -- src/` -\u003e single hit at `Service.php:45`; `Drawing.php` has none.\n- CVE-2026-59931's own regression harness (`ServerTest.php` + `redirect.php`: a whitelisted host 302-redirects to `direct.php`; only after `follow_location =\u003e 0` does the fetch stop following it) demonstrates the exact behavior. That fix is present in `Service.php` and absent in `Drawing.php`.\n- `git grep isWhitelisted 5.9.0 -- src/` confirms the callback is applied only at `Drawing.php:131` (initial `$path`), never after the redirect.\n\n## Affected Versions\n`\u003e= 5.5.0, \u003c= 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`.\n\n## Suggested Fix\nAdd `'follow_location' =\u003e 0` (and/or `'max_redirects' =\u003e 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.\n\n---\nReported by **zx (Jace)** — GitHub: @manus-use","aliases":["CVE-2026-108360"],"modified":"2026-10-27T15:47:09.137167638Z","published":"2026-10-27T15:38:39Z","database_specific":{"nvd_published_at":"2026-10-22T18:17:26Z","cwe_ids":["CWE-863","CWE-918"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-10-27T15:38:39Z"},"references":[{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-2phh-27fw-q4gc"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-108360"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/299993894dce37aa3b64aa23e9a3f4bb77ab0e66"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/94590951d76a250a5fc1ecf69a0b3f7439a9af49"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/980b5c294493856f1a25754f1e8a1d2185b93738"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/9a0b48622ebfe93fa48cb9bc09ce57303bb07af9"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/commit/da13f40e8760d599b0e8a296017d4b028b20e86c"},{"type":"PACKAGE","url":"https://github.com/PHPOffice/PhpSpreadsheet"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/1.30.7"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/2.1.19"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/2.4.8"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/3.10.8"},{"type":"WEB","url":"https://github.com/PHPOffice/PhpSpreadsheet/releases/tag/5.10.0"}],"affected":[{"package":{"name":"phpoffice/phpspreadsheet","ecosystem":"Packagist","purl":"pkg:composer/phpoffice/phpspreadsheet"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"4.0.0"},{"fixed":"5.10.0"}]}],"versions":["4.0.0","4.1.0","4.2.0","4.3.0","4.3.1","4.4.0","4.5.0","5.0.0","5.1.0","5.2.0","5.3.0","5.4.0","5.5.0","5.6.0","5.7.0","5.8.0","5.8.1","5.9.0"],"database_specific":{"last_known_affected_version_range":"\u003c= 5.9.0","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-2phh-27fw-q4gc/GHSA-2phh-27fw-q4gc.json"}},{"package":{"name":"phpoffice/phpspreadsheet","ecosystem":"Packagist","purl":"pkg:composer/phpoffice/phpspreadsheet"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"3.3.0"},{"fixed":"3.10.8"}]}],"versions":["3.10.0","3.10.1","3.10.2","3.10.3","3.10.4","3.10.5","3.10.6","3.10.7","3.3.0","3.4.0","3.5.0","3.6.0","3.7.0","3.8.0","3.9.0","3.9.1","3.9.2","3.9.3"],"database_specific":{"last_known_affected_version_range":"\u003c= 3.10.7","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-2phh-27fw-q4gc/GHSA-2phh-27fw-q4gc.json"}},{"package":{"name":"phpoffice/phpspreadsheet","ecosystem":"Packagist","purl":"pkg:composer/phpoffice/phpspreadsheet"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"2.2.0"},{"fixed":"2.4.8"}]}],"versions":["2.2.0","2.2.1","2.2.2","2.3.0","2.3.10","2.3.2","2.3.3","2.3.4","2.3.5","2.3.6","2.3.7","2.3.8","2.3.9","2.4.0","2.4.1","2.4.2","2.4.3","2.4.4","2.4.5","2.4.6","2.4.7"],"database_specific":{"last_known_affected_version_range":"\u003c= 2.4.7","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-2phh-27fw-q4gc/GHSA-2phh-27fw-q4gc.json"}},{"package":{"name":"phpoffice/phpspreadsheet","ecosystem":"Packagist","purl":"pkg:composer/phpoffice/phpspreadsheet"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"2.0.0"},{"fixed":"2.1.19"}]}],"versions":["2.0.0","2.1.0","2.1.1","2.1.10","2.1.11","2.1.12","2.1.13","2.1.14","2.1.15","2.1.16","2.1.17","2.1.18","2.1.3","2.1.4","2.1.5","2.1.6","2.1.7","2.1.8","2.1.9"],"database_specific":{"last_known_affected_version_range":"\u003c= 2.1.18","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-2phh-27fw-q4gc/GHSA-2phh-27fw-q4gc.json"}},{"package":{"name":"phpoffice/phpspreadsheet","ecosystem":"Packagist","purl":"pkg:composer/phpoffice/phpspreadsheet"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"1.30.7"}]}],"versions":["1.0.0","1.0.0-beta","1.0.0-beta2","1.1.0","1.10.0","1.10.1","1.11.0","1.12.0","1.13.0","1.14.0","1.14.1","1.15.0","1.16.0","1.17.0","1.17.1","1.18.0","1.19.0","1.2.0","1.2.1","1.20.0","1.21.0","1.22.0","1.23.0","1.24.0","1.24.1","1.25.0","1.25.1","1.25.2","1.26.0","1.27.0","1.27.1","1.28.0","1.29.0","1.29.1","1.29.10","1.29.11","1.29.12","1.29.2","1.29.4","1.29.5","1.29.6","1.29.7","1.29.8","1.29.9","1.3.0","1.3.1","1.30.0","1.30.1","1.30.2","1.30.3","1.30.4","1.30.5","1.30.6","1.4.0","1.4.1","1.5.0","1.5.1","1.5.2","1.6.0","1.7.0","1.8.0","1.8.1","1.8.2","1.9.0"],"database_specific":{"last_known_affected_version_range":"\u003c= 1.30.6","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-2phh-27fw-q4gc/GHSA-2phh-27fw-q4gc.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N"}]}