{"record":{"id":"5d0d15e43f176e9a","repo":"PHPOffice/PhpSpreadsheet","slug":"locale-file-not-found","errorCode":null,"errorMessage":"Locale file not found","messagePattern":"Locale file not found","errorType":"exception","errorClass":"PhpOffice\\PhpSpreadsheet\\Calculation\\Exception","httpStatus":null,"severity":"critical","filePath":"src/PhpSpreadsheet/Calculation/CalculationLocale.php","lineNumber":111,"sourceCode":"    }\n\n    /**\n     * Get the currently defined locale code.\n     */\n    public function getLocale(): string\n    {\n        return self::$localeLanguage;\n    }\n\n    protected function getLocaleFile(string $localeDir, string $locale, string $language, string $file): string\n    {\n        $localeFileName = $localeDir . str_replace('_', DIRECTORY_SEPARATOR, $locale)\n            . DIRECTORY_SEPARATOR . $file;\n        if (!file_exists($localeFileName)) {\n            //    If there isn't a locale specific file, look for a language specific file\n            $localeFileName = $localeDir . $language . DIRECTORY_SEPARATOR . $file;\n            if (!file_exists($localeFileName)) {\n                throw new Exception('Locale file not found');\n            }\n        }\n\n        return $localeFileName;\n    }\n\n    /** @return array<int, array<int, string>> */\n    public function getFalseTrueArray(): array\n    {\n        if (!empty(self::$falseTrueArray)) {\n            return self::$falseTrueArray;\n        }\n        if (count(self::$validLocaleLanguages) == 1) {\n            self::loadLocales();\n        }\n        $falseTrueArray = [['FALSE'], ['TRUE']];\n        foreach (self::$validLocaleLanguages as $language) {\n            if (str_starts_with($language, 'en')) {","sourceCodeStart":93,"sourceCodeEnd":129,"githubUrl":"https://github.com/PHPOffice/PhpSpreadsheet/blob/65b080eef4d9fd11a5796135ab145883e5c3d6a6/src/PhpSpreadsheet/Calculation/CalculationLocale.php#L93-L129","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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.","If the file is untrusted, do not bypass the check: treat the exception as an attack indicator, log and reject the upload.","Sanitize programmatically only for trusted sources: preg_replace('/<!DOCTYPE[^>]*(\\[[^]]*\\])?>/s', '', $xml) into a temp file, then load that.","Keep PhpSpreadsheet updated - the scanner default pattern and libxml flags are periodically tightened as XXE bypasses are found."],"exampleFix":"// before\n$spreadsheet = \\PhpOffice\\PhpSpreadsheet\\IOFactory::load('report2003.xml');\n// throws: Detected use of ENTITY in XML ... (file has a <!DOCTYPE declaration)\n\n// after - strip DTD constructs from a TRUSTED file before loading\n$xml = file_get_contents('report2003.xml');\n$xml = preg_replace('/<!DOCTYPE[^>]*(\\[[^]]*\\])?>/s', '', $xml) ?? $xml;\n$tmp = tempnam(sys_get_temp_dir(), 'xlsx');\nfile_put_contents($tmp, $xml);\n$spreadsheet = \\PhpOffice\\PhpSpreadsheet\\IOFactory::load($tmp);","handlingStrategy":"try-catch","validationCode":"// Pre-scan raw bytes for the scanner's own pattern before loading\nfunction containsBlockedMarker(string $path, string $marker = '<!DOCTYPE'): bool\n{\n    $raw = (string) file_get_contents($path);\n    $pattern = '/\\0*' . implode('\\0*', mb_str_split($marker, 1, 'UTF-8')) . '\\0*/';\n    return (bool) preg_match($pattern, $raw);\n}\n\nif (containsBlockedMarker($path)) {\n    // trusted source: strip DTD into a sanitized temp copy; untrusted: reject here\n}","typeGuard":null,"tryCatchPattern":"use PhpOffice\\PhpSpreadsheet\\Reader\\Exception as ReaderException;\n\ntry {\n    $spreadsheet = \\PhpOffice\\PhpSpreadsheet\\IOFactory::load($path);\n} catch (ReaderException $e) {\n    if (str_contains($e->getMessage(), 'ENTITY in XML')) {\n        // Do NOT strip-and-retry for untrusted input: this is the XXE/XEE guard.\n        // Quarantine the file, alert, and return a validation error to the sender.\n    }\n    throw $e;\n}","preventionTips":["Ask upstream systems to omit DTDs/DOCTYPE from spreadsheet XML - spreadsheet formats do not require them.","Sanitize untrusted XML/HTML with a purifier (strip DOCTYPE and ENTITY definitions) before it reaches PhpSpreadsheet.","Never work around this guard by patching XmlScanner or downgrading the package; the block exists because libxml defenses alone are insufficient.","Alert on this exception in import endpoints - frequent hits usually mean someone is probing for XXE.","Keep the package updated so scanner pattern fixes land as they are released."],"tags":["php","phpspreadsheet","xml","xxe","xee","doctype","security","entity-expansion"],"backgroundTag":"xxe-attack-blocked","analyzedSha":"65b080eef4d9fd11a5796135ab145883e5c3d6a6","analyzedAt":"2026-08-17T05:40:41.646Z","schemaVersion":2},"datasetVersion":"2026-08-17T09:17:11.063Z"}