PHPOffice/PhpSpreadsheet · critical · PhpOffice\PhpSpreadsheet\Calculation\Exception

#VALUE!

#VALUE!

Error message

#VALUE!

What it means

This is the second, post-conversion pass of the same guard: scan() re-runs the forbidden-pattern match (<!DOCTYPE for spreadsheet readers, <!ENTITY for the Html reader, NUL bytes tolerated between characters) after toUtf8() has normalized the payload to UTF-8. A hit only at this stage (XmlScanner.php:98-100) means the DOCTYPE/ENTITY construct was invisible in the raw bytes and only became ASCII-readable after charset conversion or after toUtf8() stripped the encoding declaration - the classic shape of an encoding-obfuscated XXE/XEE payload that defeats single-pass, byte-level filters.

Source

Thrown at src/PhpSpreadsheet/Calculation/DateTimeExcel/Date.php:101

        // Execute function
        $excelDateValue = SharedDateHelper::formattedPHPToExcel($year, $month, $day);

        return Helpers::returnIn3FormatsFloat($excelDateValue);
    }

    /**
     * Convert year from multiple formats to int.
     */
    private static function getYear(mixed $year, int $baseYear): int
    {
        if ($year === null) {
            $year = 0;
        } elseif (is_scalar($year)) {
            $year = StringHelper::testStringAsNumeric((string) $year);
        }
        if (!is_numeric($year)) {
            throw new Exception(ExcelError::VALUE());
        }
        $year = (int) $year;

        if ($year < ($baseYear - 1900)) {
            throw new Exception(ExcelError::NAN());
        }
        if ((($baseYear - 1900) !== 0) && ($year < $baseYear) && ($year >= 1900)) {
            throw new Exception(ExcelError::NAN());
        }

        if (($year < $baseYear) && ($year >= ($baseYear - 1900))) {
            $year += 1900;
        }

        return (int) $year;
    }

    /**

View on GitHub (pinned to 65b080eef4)

Solutions

  1. Treat this signal as hostile by default: reject and quarantine the upload, and log the file for review - DOCTYPE/ENTITY hidden behind an encoding is not produced by legitimate spreadsheet tools.
  2. If a trusted source genuinely emits it, decode to UTF-8 externally (mb_convert_encoding with the true source charset), strip <!DOCTYPE/<!ENTITY blocks, and load the sanitized copy.
  3. Do not attempt to weaken XmlScanner or swap the pattern - the double check is what catches encoding-obfuscated payloads.
  4. For user-supplied HTML/SVG (Html reader), preprocess with an HTML purifier that drops DTD/ENTITY constructs before PhpSpreadsheet sees the input.

Example fix

// before
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load('upload.xlsx');
// throws: Detected use of ENTITY in XML ... (payload hidden in non-UTF-8 encoding)

// after - decode, sanitize, then load a rewritten copy
$xml = file_get_contents('upload.xlsx'); // for zip-based xlsx, extract the offending part instead
$xml = mb_convert_encoding($xml, 'UTF-8', mb_detect_encoding($xml) ?: 'UTF-8');
$xml = preg_replace('/<!DOCTYPE[^>]*(\[[^]]*\])?>/s', '', $xml) ?? $xml;
$tmp = tempnam(sys_get_temp_dir(), 'xlsx');
file_put_contents($tmp, $xml);
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($tmp);
Defensive patterns

Strategy: try-catch

Validate before calling

// Decode to UTF-8 first, then check for markers the raw scan could not see
function sanitizedCopyForTrustedSources(string $path): string
{
    $raw = (string) file_get_contents($path);
    $charset = mb_detect_encoding($raw, ['UTF-8', 'UTF-16LE', 'UTF-16BE'], true) ?: 'UTF-8';
    $xml = $charset === 'UTF-8' ? $raw : mb_convert_encoding($raw, 'UTF-8', $charset);
    return preg_replace('/<!DOCTYPE[^>]*(\[[^]]*\])?>/s', '', $xml) ?? $xml;
}
// Only use for trusted, non-adversarial inputs; for uploads, reject instead.

Try / catch

use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;

try {
    $spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($path);
} catch (ReaderException $e) {
    if (str_contains($e->getMessage(), 'ENTITY in XML')) {
        // Marker appeared only after charset conversion => encoding-obfuscated payload.
        // Quarantine + security-log the file; never retry the same bytes.
    }
    throw $e;
}

Prevention

When it happens

Trigger: Loading a file in a non-UTF-8, non-ASCII-compatible encoding (so the marker is not byte-0x3C '<' in the raw stream) whose converted form contains <!DOCTYPE/<!ENTITY; payloads crafted so the first raw-byte check passes but mb_convert_encoding() to UTF-8 reveals the entity; documents where the encoding attribute removal inside toUtf8() exposes or completes the construct. Same entry points as error 403: IOFactory::load(), reader->load(), scan(), scanFile().

Common situations: Deliberate XXE attempts that encode the payload in UTF-16 or exotic charsets to slip naive scanners; regression/pen-test fixtures for CVE-style PhpSpreadsheet bypasses; rarely, a genuinely benign non-UTF-8 file that carries a DTD and survives conversion.

Related errors


AI-assisted analysis of PHPOffice/PhpSpreadsheet@65b080eef4 (2026-08-17). Data as JSON: /api/errors/26af253cb822afcb. Report an issue: GitHub.