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

Locale file not found

Error message

Locale file not found

What it means

XmlScanner::scan() pattern-matches every payload for a forbidden marker - '<!DOCTYPE' for spreadsheet readers, '<!ENTITY' for the Html reader (see getInstance(), XmlScanner.php:22-27) - allowing NUL bytes between each character so '\0'-obfuscation cannot slip through. The first check at line 93-95 runs on the raw bytes before any charset conversion; a match means the document contains a DOCTYPE/ENTITY construct and the load is aborted with 'Detected use of ENTITY in XML...' to prevent XXE (external entity injection) and XEE (entity-expansion / billion-laughs) attacks, since libxml's own protections are not considered sufficient.

Source

Thrown at src/PhpSpreadsheet/Calculation/CalculationLocale.php:111

    }

    /**
     * Get the currently defined locale code.
     */
    public function getLocale(): string
    {
        return self::$localeLanguage;
    }

    protected function getLocaleFile(string $localeDir, string $locale, string $language, string $file): string
    {
        $localeFileName = $localeDir . str_replace('_', DIRECTORY_SEPARATOR, $locale)
            . DIRECTORY_SEPARATOR . $file;
        if (!file_exists($localeFileName)) {
            //    If there isn't a locale specific file, look for a language specific file
            $localeFileName = $localeDir . $language . DIRECTORY_SEPARATOR . $file;
            if (!file_exists($localeFileName)) {
                throw new Exception('Locale file not found');
            }
        }

        return $localeFileName;
    }

    /** @return array<int, array<int, string>> */
    public function getFalseTrueArray(): array
    {
        if (!empty(self::$falseTrueArray)) {
            return self::$falseTrueArray;
        }
        if (count(self::$validLocaleLanguages) == 1) {
            self::loadLocales();
        }
        $falseTrueArray = [['FALSE'], ['TRUE']];
        foreach (self::$validLocaleLanguages as $language) {
            if (str_starts_with($language, 'en')) {

View on GitHub (pinned to 65b080eef4)

Solutions

  1. If the file is trusted, remove the <!DOCTYPE ...> block (and any internal subset with ENTITY definitions) before loading - most spreadsheet XML does not need a DTD.
  2. If the file is untrusted, do not bypass the check: treat the exception as an attack indicator, log and reject the upload.
  3. Sanitize programmatically only for trusted sources: preg_replace('/<!DOCTYPE[^>]*(\[[^]]*\])?>/s', '', $xml) into a temp file, then load that.
  4. Keep PhpSpreadsheet updated - the scanner default pattern and libxml flags are periodically tightened as XXE bypasses are found.

Example fix

// before
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load('report2003.xml');
// throws: Detected use of ENTITY in XML ... (file has a <!DOCTYPE declaration)

// after - strip DTD constructs from a TRUSTED file before loading
$xml = file_get_contents('report2003.xml');
$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

// Pre-scan raw bytes for the scanner's own pattern before loading
function containsBlockedMarker(string $path, string $marker = '<!DOCTYPE'): bool
{
    $raw = (string) file_get_contents($path);
    $pattern = '/\0*' . implode('\0*', mb_str_split($marker, 1, 'UTF-8')) . '\0*/';
    return (bool) preg_match($pattern, $raw);
}

if (containsBlockedMarker($path)) {
    // trusted source: strip DTD into a sanitized temp copy; untrusted: reject here
}

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')) {
        // Do NOT strip-and-retry for untrusted input: this is the XXE/XEE guard.
        // Quarantine the file, alert, and return a validation error to the sender.
    }
    throw $e;
}

Prevention

When it happens

Trigger: Loading a file whose raw bytes contain the reader's pattern: an Excel 2003 XML / SpreadsheetML or generic .xml file carrying a <!DOCTYPE declaration; an XLSX whose embedded XML was hand-crafted to include <!ENTITY; for the HTML reader, any input containing '<!ENTITY' - typically inline SVG or crafted HTML; also payloads with interleaved NUL bytes ('<\0!\0DOCTYPE') which the \0*-joined regex still catches.

Common situations: Legitimately generated XML from third-party exporters that include a DOCTYPE line (false positive); user uploads specifically probing for XXE against PhpSpreadsheet; SVG or entity-heavy HTML fed to the Html reader; files edited by hand where a DTD reference was inserted.

Related errors


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