XML Security Engineer
Senior security engineer securing XML parsing pipelines against XXE injection, entity expansion (Billion Laughs) DoS attacks, and DTD abuse. Applies safe parser configurations as the primary defense, XML Schema validation for defense-in-depth, and strict input sanitization to every XML document entering your application — whether from API bodies, file uploads, database blobs, or message queues. Follows OWASP XXE Prevention Cheat Sheet, NIST SP 800-190 (Container Security), and language-specific parser hardening guides as the authoritative security baseline.
TL;DR Checklist
- Disable external entity processing on every XML parser instance before parsing any input
- Disable DTD processing entirely unless your business logic explicitly requires DTDs
- Set entity expansion limits (max entities, max nesting depth, max text size) to prevent Billion Laughs DoS
- Validate all incoming XML against an XSD schema before processing — reject documents that don't conform
- Strip or neutralize
<![ENTITY ...]>declarations in any XML you cannot fully control - Use text/CDATA sections for user-supplied content that may contain
<,>,&,", or'characters - Never pass unsanitized XML to an XSLT processor — transform engines often have their own XXE vectors
When to Use
Use this skill when:
- Implementing XML parsers for API endpoints that receive XML request bodies (SOAP, REST/XML, EDI)
- Building XML file upload handlers or document ingestion pipelines
- Processing XML from message queues, event streams, or database columns
- Auditing existing code for XXE vulnerabilities in Python, Java, PHP, Node.js, or Go services
- Migrating legacy applications that use insecure default parser configurations
- Designing defense-in-depth for any system that validates, transforms, or serializes XML data
- Integrating with third-party systems that exchange XML payloads (financial messages, healthcare HL7, government submissions)
When NOT to Use
Avoid this skill for:
- Sanitizing HTML output for XSS prevention — use
html-entity-encodinginstead (HTML has different entity syntax and threat model) - Encoding URL query parameters — use
url-parsing-securityinstead (URL encoding is%xx, not XML entities) - Validating non-XML structured data like JSON or YAML — use
input-validationfor general schema validation patterns - Building a general-purpose XML library or parser from scratch — this skill hardens existing parsers, it does not teach parser internals
Core Workflow
Identify Every XML Parser Entry Point — Catalog every location in your codebase that parses XML input: HTTP handlers accepting
application/xmlortext/xml, file loaders for.xmlattachments, database blob readers, message queue deserializers, and any library call (e.g.,lxml.etree.parse(),DocumentBuilder.parse(),simplexml_load_string()). Checkpoint: Every identified entry point must have a secure parser configuration before the first byte is read.Apply Secure Parser Configuration — Configure each parser to disable external entities, DTDs, and entity expansion. Use language-specific factory methods or parser flags (see Implementation Patterns below). Checkpoint: Verify that disabling these features does not break legitimate use cases — if a feature requires DTDs, you must implement an explicit allowlist of permitted entity names instead of enabling global DTD processing.
Set Entity Expansion Limits — Configure entity expansion limits to prevent Billion Laughs-style DoS attacks: cap the maximum number of entities, maximum nesting depth, and maximum resulting document size in memory. These limits must be set before any parsing begins. Checkpoint: Set limits conservatively — typical business XML documents rarely exceed 50 entities or 2MB uncompressed.
Validate Against an XSD Schema — If your protocol or integration standard defines a schema (SOAP, EDI, HL7, etc.), load and validate every incoming document against the published XSD. Schema validation provides defense-in-depth: it catches structural anomalies that parser-level protections miss. Checkpoint: Use the same XSD version your trading partners are contracted to use — never accept a newer or older schema without version negotiation.
Sanitize Output Content — Ensure any user-supplied content embedded in XML output uses proper entity encoding (
&,<,>,",') or is wrapped in<CDATA[...]]sections. Do not concatenate raw strings into XML — always use the library's element/text API. Checkpoint: Run a quick audit for anyf"...{user_input}..."or string concatenation patterns that produce XML fragments.Log and Monitor Parsing Events — Log every rejected document with the reason (XXE blocked, schema violation, entity expansion exceeded), source IP, user context, and a hash of the first 500 bytes for incident correlation. Alert on repeated XXE blocks from the same source — this indicates active exploitation attempts. Checkpoint: Ensure logs do not include the full XML content if it contains PII or credentials — truncate or redact before persistence.
Implementation Patterns / Reference Guide
Pattern 1: Python lxml — Secure Parser (BAD vs. GOOD)
lxml is the most widely-used Python XML library. Its default parser accepts external entities by default, making every lxml.parse() call vulnerable to XXE unless explicitly hardened.
"""secure_xml_lxml.py — XXE-safe XML parsing with lxml."""
import logging
from io import BytesIO
from pathlib import Path
from typing import Optional
from lxml import etree
class XmlParseError(Exception):
"""Raised when XML parsing fails due to security or structural constraints."""
def __init__(self, reason: str, source: Optional[str] = None) -> None:
self.reason = reason
self.source = source
super().__init__(f"XML parse failed ({reason}): {source}" if source else f"XML parse failed ({reason})")
# Shared secure parser — constructed once at module load time.
# All security features are disabled by default in lxml; we re-enable only what we need.
SECURE_PARSER: etree.XMLParser = etree.XMLParser(
# --- XXE Prevention ---
resolve_entities=False, # Disables external entity resolution
no_network=True, # Prevents parser from fetching any external resource
# --- DTD Protection ---
load_dtd=False, # Never load DTDs (breaks some valid XML — test your consumers)
# --- Entity Expansion Limits (Billion Laughs prevention) ---
max_depth=50, # Max element nesting depth
compact=True, # Save memory by compacting internal tree representations
# --- Size Limits ---
huge_tree=False, # Disables the "huge tree" mode that removes all limits
)
# Entity expansion budget: total entity count before aborting
MAX_ENTITIES = 10_000
# Max resulting document size in bytes (adjust based on your business requirements)
MAX_DOCUMENT_SIZE = 5 * 1024 * 1024 # 5 MB
def parse_xml_bytes(data: bytes, source_label: Optional[str] = None) -> etree._ElementTree:
"""Parse XML bytes with full XXE protection and entity expansion limits.
Args:
data: Raw XML bytes to parse. Must be valid UTF-8 encoded XML.
source_label: Optional human-readable label for logging (e.g., "request body", "file upload").
Returns:
Parsed ElementTree.
Raises:
XmlParseError: If the document is structurally invalid or violates security constraints.
"""
if not data or not isinstance(data, bytes):
raise XmlParseError("empty input")
try:
tree = etree.parse(BytesIO(data), SECURE_PARSER)
except etree.XMLSyntaxError as exc:
# lxml raises specific errors for entity-related issues — surface them clearly
error_msg = str(exc).lower()
if "external entity" in error_msg or "dtd" in error_msg:
logging.warning("XXE/DTD attack blocked from %s: %s", source_label, exc)
raise XmlParseError("potential XXE attack — external entity detected", source=source_label) from exc
raise XmlParseError(f"syntax error: {exc}", source=source_label) from exc
except etree.XMLSyntaxValidationError as exc:
raise XmlParseError(f"schema validation failed: {exc}", source=source_label) from exc
# Post-parse size check to mitigate memory exhaustion from deep nesting
tree_size = len(etree.tostring(tree, encoding="unicode"))
if tree_size > MAX_DOCUMENT_SIZE:
logging.warning(
"Document too large (%d bytes) from %s — exceeds limit of %d",
tree_size, source_label, MAX_DOCUMENT_SIZE,
)
raise XmlParseError(f"document size {tree_size} exceeds maximum {MAX_DOCUMENT_SIZE}", source=source_label)
return tree
def parse_xml_string(xml_string: str, source_label: Optional[str] = None) -> etree._ElementTree:
"""Convenience wrapper that encodes a string to bytes before parsing.
Args:
xml_string: UTF-8 encoded XML string.
source_label: Optional source label for logging.
Returns:
Parsed ElementTree.
"""
if not isinstance(xml_string, str):
raise XmlParseError("input must be a string")
return parse_xml_bytes(xml_string.encode("utf-8"), source_label=source_label)
def load_xml_file(path: Path, source_label: Optional[str] = None) -> etree._ElementTree:
"""Load and validate an XML file from disk with XXE protections.
Note: File-based parsing is less risky than network parsing since the
attacker must already have write access to the filesystem. However, if
files come from user uploads, full protection is still required.
Args:
path: Path to the XML file.
source_label: Optional source label for logging.
Returns:
Parsed ElementTree.
"""
if not path.exists():
raise XmlParseError(f"file not found: {path}")
data = path.read_bytes()
# Limit initial read to prevent reading a 100GB file into memory
if len(data) > MAX_DOCUMENT_SIZE * 2:
raise XmlParseError("source file exceeds safe size limit", source=source_label)
return parse_xml_bytes(data, source_label=source_label)
# ========================================================================
# Entity Encoding Helpers (safe XML output)
# ========================================================================
def safe_xml_text(text: str) -> str:
"""Escape special characters in text content for safe embedding in XML.
This handles the 5 XML predefined entities plus any Unicode characters
that are not valid in XML names or character data per W3C XML 1.0 spec.
Args:
text: Raw user-supplied text that will appear as element text content.
Returns:
Text with & → &, < → <, > → > properly escaped.
"""
if not isinstance(text, str):
raise TypeError(f"Expected str, got {type(text).__name__}")
# Order matters: & must be first, otherwise you'd double-encode
return (
text.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
)
def safe_xml_attribute(value: str) -> str:
"""Escape a value for safe embedding as an XML attribute.
Attributes require escaping of &, <, > plus quotes since the value
is delimited by quotation marks.
Args:
value: Raw user-supplied text for an attribute value.
Returns:
Escaped attribute value safe for wrapping in double quotes.
"""
if not isinstance(value, str):
raise TypeError(f"Expected str, got {type(value).__name__}")
return (
value.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
.replace("'", "'")
)
BAD — Default lxml parser is XXE-vulnerable:
from lxml import etree
# ❌ BAD: Default parser accepts external entities by default
tree = etree.parse("document.xml") # Vulnerable to file:// and http:// entity injection
root = etree.fromstring(b"<xml></xml>") # Also vulnerable — same insecure defaults
# ❌ BAD: Explicitly enabling what you should disable
parser = etree.XMLParser()
parser.resolve_entities = True # This is already True by default — no change needed
parser.load_dtd = True # Also True by default — invites DTD abuse
Pattern 2: Python ElementTree (Standard Library) — Secure Parsing (BAD vs. GOOD)
Python's built-in xml.etree.ElementTree has historically had limited XXE protection, but Python 3.8+ introduced XMLParser with entity controls.
"""secure_xml_et.py — XXE-safe XML parsing with stdlib ElementTree (Python 3.8+)."""
import logging
import xml.etree.ElementTree as ET
from io import BytesIO
from pathlib import Path
from typing import Optional
class XmlParseError(Exception):
"""Raised when XML parsing fails due to security or structural constraints."""
def __init__(self, reason: str, source: Optional[str] = None) -> None:
self.reason = reason
self.source = source
super().__init__(f"XML parse failed ({reason}): {source}" if source else f"XML parse failed ({reason})")
# Python 3.8+ XMLParser with XXE protections enabled by default
# In CPython's C implementation, resolve_entities defaults to False
# but we set it explicitly for safety and clarity across implementations.
SECURE_XML_PARSER: ET.XMLParser = ET.XMLParser(
# resolve_entities=False is the Python 3.8+ default, but set explicitly
# no_element_declaration=False — we don't need DTD validation
# Use target= for SAX-style streaming parsing to limit memory usage on large docs
)
# For very large documents, use iterparse (streaming) instead of full tree loading
MAX_ITERPARSE_DEPTH = 50
def parse_xml_bytes_et(data: bytes, source_label: Optional[str] = None) -> ET.Element:
"""Parse XML bytes using stdlib ElementTree with XXE protections.
Args:
data: Raw XML bytes to parse.
source_label: Optional source label for logging.
Returns:
Root Element.
Raises:
XmlParseError: If the document is structurally invalid or violates security constraints.
"""
if not data:
raise XmlParseError("empty input")
try:
tree = ET.parse(BytesIO(data), parser=SECURE_XML_PARSER)
return tree.getroot()
except ET.ParseError as exc:
error_msg = str(exc).lower()
if "external entit" in error_msg or "dtd" in error_msg:
logging.warning("XXE/DTD attack blocked from %s: %s", source_label, exc)
raise XmlParseError("potential XXE — external entity detected", source=source_label) from exc
raise XmlParseError(f"syntax error: {exc}", source=source_label) from exc
def stream_xml_bytes_et(data: bytes, tag_filter: Optional[str] = None) -> list[ET.Element]:
"""Stream-parse XML using iterparse to handle large documents without loading them fully into memory.
This is the recommended approach for documents larger than 1 MB or when
you only need specific elements rather than the full tree.
Args:
data: Raw XML bytes to stream-parse.
tag_filter: If set, only yield elements matching this tag name.
Yields:
Matching Element objects as they are parsed.
"""
context = ET.iterparse(BytesIO(data), events=("end",), parser=SECURE_XML_PARSER)
for event, elem in context:
if tag_filter is None or elem.tag == tag_filter:
yield elem
# Clear the element to free memory immediately after processing
elem.clear()
# Remove the parent's reference to prevent memory leaks during streaming
while elem.getprevious() is not None:
del elem.getparent()[0]
# ❌ BAD: No parser argument means using defaults that may vary by Python version
def bad_parse(data: bytes) -> ET.Element:
tree = ET.parse(BytesIO(data)) # No explicit XXE protection — relies on unspecified defaults
return tree.getroot()
Pattern 3: Java DocumentBuilder / SAXParser — Secure Configuration (BAD vs. GOOD)
Java's built-in XML parsers (DocumentBuilderFactory, SAXParserFactory) are notorious for having secure defaults that can be overridden by malicious or misconfigured code. The JAXP specification requires explicit feature setting to disable external entities.
/**
* XmlSecureParser.java — XXE-safe XML parsing with Java JAXP.
*
* Java's default XML parsers load DTDs and resolve external entities unless
* explicitly configured otherwise. This class provides hardened factories.
*/
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.SAXParserFactory;
import javax.xml.transform.Source;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;
import org.w3c.dom.Document;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXNotRecognizedException;
import org.xml.sax.SAXNotSupportedException;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Set;
public final class XmlSecureParser {
private static final DocumentBuilderFactory DOC_FACTORY;
private static final SAXParserFactory SAX_FACTORY;
private static final Schema VALIDATION_SCHEMA;
static {
// ========================================================================
// Secure DocumentBuilderFactory configuration
// ========================================================================
DOC_FACTORY = DocumentBuilderFactory.newInstance();
DOC_FACTORY.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
DOC_FACTORY.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
DOC_FACTORY.setFeature("http://xml.org/sax/features/external-general-entities", false);
DOC_FACTORY.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
DOC_FACTORY.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
DOC_FACTORY.setXIncludeAware(false);
DOC_FACTORY.setExpandEntityReferences(false);
// ========================================================================
// Secure SAXParserFactory configuration (for streaming / pull parsing)
// ========================================================================
SAX_FACTORY = SAXParserFactory.newInstance();
SAX_FACTORY.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
SAX_FACTORY.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
SAX_FACTORY.setFeature("http://xml.org/sax/features/external-general-entities", false);
SAX_FACTORY.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
SAX_FACTORY.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
SAX_FACTORY.setXIncludeAware(false);
// ========================================================================
// Optional: Load XSD for schema validation (defense-in-depth)
// Set VALIDATION_SCHEMA to null if your protocol does not use XSD.
// ========================================================================
SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
// schemaFactory.setSchema(new javax.xml.validation.Source[] {
// new StreamSource(pathToXsdFile.toFile())
// });
VALIDATION_SCHEMA = null; // Replace with actual XSD for production use
}
private XmlSecureParser() { /* utility class — prevent instantiation */ }
/**
* Parse XML from a byte array with full XXE protection.
*
* @param xmlData Raw XML bytes (UTF-8 encoded)
* @return Parsed DOM Document
* @throws XmlSecurityException if XXE is detected or parsing fails
*/
public static Document parse(byte[] xmlData) throws XmlSecurityException {
if (xmlData == null || xmlData.length == 0) {
throw new XmlSecurityException("Empty XML input");
}
try {
// Validate against XSD first (defense-in-depth), then parse
if (VALIDATION_SCHEMA != null) {
validateSchema(xmlData);
}
DocumentBuilder builder = DOC_FACTORY.newDocumentBuilder();
return builder.parse(new ByteArrayInputStream(xmlData));
} catch (Exception e) {
String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
if (msg.toLowerCase().contains("doctype") || msg.toLowerCase().contains("entity")) {
throw new XmlSecurityException("Potential XXE attack detected: " + msg, e);
}
throw new XmlSecurityException("XML parse error: " + msg, e);
}
}
/**
* Validate XML bytes against the loaded XSD schema before parsing.
* This is defense-in-depth: even if parser protections fail, schema
* validation will reject non-conforming or malicious documents.
*/
private static void validateSchema(byte[] xmlData) throws SAXException, IOException {
if (VALIDATION_SCHEMA == null) return;
Validator validator = VALIDATION_SCHEMA.newValidator();
// Disable external DTD/schema references to prevent schema-based XXE
validator.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
validator.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
validator.validate(new StreamSource(new ByteArrayInputStream(xmlData)));
}
}
/** Exception thrown when XML security constraints are violated. */
class XmlSecurityException extends Exception {
public XmlSecurityException(String message) { super(message); }
public XmlSecurityException(String message, Throwable cause) { super(message, cause); }
}
BAD — Java's default factory is extremely permissive:
// ❌ BAD: Default DocumentBuilderFactory has no XXE protections
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
// No features set at all — accepts DTDs, external entities, XInclude by default
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new FileInputStream("document.xml"));
// ❌ BAD: Setting FEATURE_SECURE_PROCESSING alone is NOT sufficient
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
// This reduces resource consumption but does NOT disable DTDs or external entities.
// OWASP explicitly states FEATURE_SECURE_PROCESSING must be combined with
// explicit feature disables to prevent XXE in Java.
// ❌ BAD: Allowing everything for "compatibility"
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false);
factory.setFeature("http://xml.org/sax/features/external-general-entities", true);
Pattern 4: PHP DOMDocument / simplexml — Secure Loading (BAD vs. GOOD)
PHP's XML functions have been vulnerable to XXE for years. The libxml library that powers DOMDocument and simplexml_load_string() defaults to loading external entities. Since PHP 5.x, you can disable this via libxml_disable_entity_loader(), but this function was deprecated in PHP 8.0 and removed in PHP 8.2, so modern code must use the LIBXML_NONET and LIBXML_NOENT flags properly.
<?php
/**
* SecureXmlParser.php — XXE-safe XML parsing for PHP 8.x.
*
* Modern PHP (8.0+) requires explicit LIBXML_NONET flag and DOMDocument
* property configuration rather than the deprecated libxml_disable_entity_loader().
*/
declare(strict_types=1);
final class XmlSecureParser
{
/** @var int Maximum allowed document size in bytes (default: 5 MB) */
private const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024;
/** @var int Maximum nesting depth to prevent Billion Laughs DoS */
private const MAX_DEPTH = 50;
/**
* Parse XML string with full XXE protection.
*
* @param string $xmlString Raw XML content (UTF-8 encoded)
* @param int $options Additional libxml options (default: NONET + NOCDATA)
* @return DOMDocument Parsed document
* @throws XmlSecurityException If XXE is detected or parsing fails
*/
public static function parseFromString(string $xmlString, int $options = self::DEFAULT_OPTIONS): DOMDocument
{
self::validateInputSize($xmlString);
// Suppress libxml warnings — we handle them ourselves
$internalErrors = libxml_use_internal_errors(true);
try {
$doc = new DOMDocument();
$loaded = $doc->loadXML($xmlString, $options | self::DEFAULT_OPTIONS);
if ($loaded === false) {
$errors = libxml_get_last_error();
$reason = $errors !== false ? $errors->message : 'unknown parse error';
// Check for XXE-specific error patterns
if (stripos($reason, 'entity') !== false || stripos($reason, 'external') !== false) {
throw new XmlSecurityException("Potential XXE attack detected: {$reason}");
}
throw new XmlSecurityException("XML syntax error: {$reason}");
}
return $doc;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($internalErrors);
}
}
/**
* Parse XML file with XXE protection.
*
* @param string $filePath Path to the XML file
* @return DOMDocument Parsed document
* @throws XmlSecurityException If XXE is detected, file not found, or parse fails
*/
public static function parseFromFile(string $filePath): DOMDocument
{
if (!is_file($filePath)) {
throw new XmlSecurityException("File not found: {$filePath}");
}
// Check file size before loading to prevent memory exhaustion
$fileSize = filesize($filePath);
if ($fileSize > self::MAX_DOCUMENT_SIZE * 2) {
throw new XmlSecurityException("File size ({$fileSize} bytes) exceeds safe limit");
}
$internalErrors = libxml_use_internal_errors(true);
try {
$doc = new DOMDocument();
// Use load() with NONET — XXE prevention at the loader level
$loaded = $doc->load($filePath, self::DEFAULT_OPTIONS);
if ($loaded === false) {
$errors = libxml_get_last_error();
$reason = $errors !== false ? $errors->message : 'unknown parse error';
throw new XmlSecurityException("XML parse failed: {$reason}");
}
return $doc;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($internalErrors);
}
}
/**
* Validate XML string against an XSD schema (defense-in-depth).
*
* @param string $xmlString Raw XML content
* @param string $xsdPath Path to the XSD schema file
* @return array<string> List of validation errors (empty if valid)
*/
public static function validateAgainstSchema(string $xmlString, string $xsdPath): array
{
$internalErrors = libxml_use_internal_errors(true);
try {
// Parse XML without entity loading
$doc = new DOMDocument();
$doc->loadXML($xmlString, self::DEFAULT_OPTIONS | LIBXML_NOENT);
// Validate against schema — setEntityLoader blocks external entity access
$validated = $doc->schemaValidate($xsdPath);
if (!$validated) {
$errors = [];
foreach (libxml_get_errors() as $error) {
$errors[] = sprintf(
"Line %d: %s (code: %d)",
$error->line,
trim($error->message),
$error->code
);
}
return $errors;
}
return []; // Empty array means valid
} finally {
libxml_clear_errors();
libxml_use_internal_errors($internalErrors);
}
}
/**
* Escape text for safe embedding in XML element content.
* Uses the native PHP function which handles all 5 predefined entities.
*
* @param string $text Raw user-supplied text
* @return string Escaped text safe for XML content
*/
public static function escapeXmlText(string $text): string
{
// ENT_XML1 flag + UTF-8 encoding handles: & < > " '
return htmlspecialchars($text, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
/**
* Escape a value for safe embedding as an XML attribute value.
*/
public static function escapeXmlAttribute(string $value): string
{
return self::escapeXmlText($value);
}
private const DEFAULT_OPTIONS = LIBXML_NONET | LIBXML_NOCDATA | LIBXML_COMPACT;
private static function validateInputSize(string $input): void
{
$byteLength = mb_strlen($input, '8bit');
if ($byteLength > self::MAX_DOCUMENT_SIZE) {
throw new XmlSecurityException(
"Input size ({$byteLength} bytes) exceeds maximum allowed (".self::MAX_DOCUMENT_SIZE.')"'
);
}
}
}
/** Exception thrown when XML security constraints are violated. */
class XmlSecurityException extends RuntimeException {}
BAD — PHP's default configuration is XXE-vulnerable:
// ❌ BAD: Default DOMDocument accepts external entities
$doc = new DOMDocument();
$doc->loadXML($userSuppliedXml); // Vulnerable to file://, http:// entity injection
// ❌ BAD: Using simplexml_load_string() without NONET flag
$xml = simplexml_load_string($userSuppliedXml); // Also vulnerable — loads DTDs by default
// ❌ BAD: Using LIBXML_NOENT which ENABLES entity expansion (opposite of what you want)
$doc->loadXML($input, LIBXML_NOENT); // This resolves entities, creating a Billion Laughs vector!
// ❌ DEPRECATED / REMOVED: libxml_disable_entity_loader() was deprecated in PHP 8.0 and removed in 8.2
libxml_disable_entity_loader(true); // Do NOT use this — it is gone in modern PHP
Pattern 5: Node.js (libxmljs / xml2js) — Secure Parsing (BAD vs. GOOD)
Node.js has no built-in XML parser, so developers use third-party libraries. Both libxmljs and @xmldom/xmldom + sax have different security characteristics. This pattern covers the two most popular approaches.
/**
* secureXmlParser.mjs — XXE-safe XML parsing for Node.js.
*
* Covers three libraries: @xmldom/xmldom (DOM), sax (streaming SAX), and fast-xml-parser.
*/
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
import { SAXParser, events } from 'sax';
import { XMLParser, XMLValidator } from 'fast-xml-parser';
// ========================================================================
// Constants
// ========================================================================
const MAX_ENTITY_DEPTH = 10;
const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5 MB
const ENTITY_EXPANSION_LIMIT = 1000;
// ========================================================================
// Approach 1: @xmldom/xmldom (DOM-style, synchronous)
// ========================================================================
/**
* Parse XML string with @xmldom/xmldom — the most widely-used DOM parser in Node.js.
* This library does NOT parse DTDs or resolve entities by default, but we add
* explicit protection layers for defense-in-depth.
*
* @param {string} xmlString - Raw XML content (UTF-8 encoded)
* @returns {Document} Parsed DOM document
* @throws {Error} If XXE is detected or parsing fails
*/
export function parseWithXmldom(xmlString) {
if (!xmlString || typeof xmlString !== 'string') {
throw new Error('XML input must be a non-empty string');
}
const byteLength = Buffer.byteLength(xmlString, 'utf8');
if (byteLength > MAX_DOCUMENT_SIZE) {
throw new Error(`XML size ${byteLength} bytes exceeds maximum ${MAX_DOCUMENT_SIZE}`);
}
// Check for dangerous patterns BEFORE parsing — fast-path rejection
const entityDeclarationPattern = /<!ENTITY\s+/i;
if (entityDeclarationPattern.test(xmlString)) {
throw new Error('Potential XXE: document contains ENTITY declarations');
}
const doctypePattern = /<!DOCTYPE/i;
if (doctypePattern.test(xmlString)) {
throw new Error('XXE prevention: DOCTYPE declarations are not allowed');
}
try {
const parser = new DOMParser({
// @xmldom defaults: no DTD loading, no entity resolution — but be explicit
errorHandler: {
warning: () => {}, // Suppress warnings
error: (msg) => { // Log errors for monitoring
console.error('XML parse error:', msg);
},
fatalError: (msg) => { // Fatal errors will throw below
console.error('Fatal XML error:', msg);
}
}
});
return parser.parseFromString(xmlString, 'text/xml');
} catch (err) {
if (err.message.toLowerCase().includes('entity') ||
err.message.toLowerCase().includes('external')) {
throw new Error(`XXE prevention triggered: ${err.message}`);
}
throw new Error(`XML parse error: ${err.message}`);
}
}
// ========================================================================
// Approach 2: sax (streaming SAX — best for large documents)
// ========================================================================
/**
* Stream-parse XML using the 'sax' library. Streaming avoids loading the
* entire document into memory, mitigating Billion Laughs DoS by default.
*
* @param {string} xmlString - Raw XML content
* @param {Object} options - SAX parser options
* @returns {Promise<Array<Object>>} Array of parsed elements
*/
export function streamParseWithSax(xmlString) {
return new Promise((resolve, reject) => {
const byteLength = Buffer.byteLength(xmlString, 'utf8');
if (byteLength > MAX_DOCUMENT_SIZE) {
return reject(new Error(`XML size exceeds maximum ${MAX_DOCUMENT_SIZE}`));
}
// Fast-path XXE detection before parsing
if (/<!ENTITY\s+/i.test(xmlString)) {
return reject(new Error('XXE prevention: ENTITY declarations detected'));
}
if (/<!DOCTYPE/i.test(xmlString)) {
return reject(new Error('XXE prevention: DOCTYPE declarations not allowed'));
}
const parser = new SAXParser('stream', {
trim: true,
normalize: true,
// sax does not load DTDs or resolve entities by default — this is safe
lowercaseAttributeNames: false,
});
const elements = [];
parser.onopentag = (node) => {
if (elements.length < 10000) { // Prevent memory exhaustion from deeply nested documents
elements.push({
name: node.name,
attributes: node.attributes,
childrenCount: node.children?.length ?? 0
});
}
};
parser.onerror = (err) => {
if (err.message.toLowerCase().includes('entity')) {
reject(new Error(`XXE detection: ${err.message}`));
} else {
reject(new Error(`SAX parse error: ${err.message}`));
}
};
parser.onend = () => resolve(elements);
parser.write(xmlString).close();
});
}
// ========================================================================
// Approach 3: fast-xml-parser (DOM + streaming, enterprise-grade)
// ========================================================================
/**
* Parse XML using fast-xml-parser with security defaults.
* This library is popular for its performance and JSON-like API.
*
* IMPORTANT: fast-xml-parser has an explicit `removeNSPrefix` option which
* can be abused for XXE — always verify your config does not enable it.
*/
export function parseWithFastXmlParser(xmlString) {
const byteLength = Buffer.byteLength(xmlString, 'utf8');
if (byteLength > MAX_DOCUMENT_SIZE) {
throw new Error(`XML size exceeds maximum ${MAX_DOCUMENT_SIZE}`);
}
// Validate document structure first (catches malformed / attack documents early)
const validationResult = XMLValidator.validate(xmlString);
if (validationResult !== true) {
throw new Error(`Invalid XML: ${validationResult}`);
}
const parser = new XMLParser({
// SECURITY: Disable features that could enable XXE
ignoreAttributes: false, // Keep attributes for validation; set true to skip them
attributeNamePrefix: '@_', // Prefix attribute names to avoid collisions
isArray: (tagName) => tagName === 'item', // Customize array detection
// CRITICAL SECURITY SETTINGS
stopNodes: ['*.rawContent'], // Stop parsing nested content in these tags
unescapeEntities: false, // Do NOT resolve entities — this prevents entity expansion
cdataTagName: null, // Do not special-case CDATA
parseAttributeValue: false, // Don't try to coerce attribute values (prevents type confusion)
parseTrueNumberOnly: false,
// Do NOT set these — they enable dangerous behavior:
// removeNSPrefix: true, // VULNERABLE — can be used for XXE via namespace manipulation
// mergeAttrs: true, // Can cause attribute shadowing attacks
});
return parser.parse(xmlString);
}
// ========================================================================
// Entity Encoding Helpers (safe XML output in Node.js)
// ========================================================================
/**
* Escape special characters for safe XML text content.
* Handles the 5 predefined XML entities: & < > " '
*
* @param {string} text - Raw user-supplied text
* @returns {string} Escaped text
*/
export function escapeXmlText(text) {
if (typeof text !== 'string') {
throw new TypeError(`Expected string, got ${typeof text}`);
}
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
/**
* Escape a value for safe embedding as an XML attribute.
* Attributes require escaping of all 5 predefined entities since values
* are wrapped in quotes.
*
* @param {string} value - Raw attribute value
* @returns {string} Escaped attribute value
*/
export function escapeXmlAttribute(value) {
if (typeof value !== 'string') {
throw new TypeError(`Expected string, got ${typeof value}`);
}
return value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// ========================================================================
// Usage example showing the correct pattern vs. common mistakes
// ========================================================================
/**
* ❌ BAD: Using a parser that resolves external entities
*/
try {
// dom-parser is a popular library but its default config resolves entities
const { DOMParser: BadDomParser } = require('xmldom');
// No entity protection — accepts DTDs and external entities by default
const badDoc = new BadDomParser().parseFromString('<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><foo>&xxe;</foo>');
} catch (_) { /* suppressed — this is intentionally shown as BAD */ }
/**
* ❌ BAD: Using xml2js with default settings (it resolves entities by default)
*/
try {
const xml2js = require('xml2js');
// xml2js's XMLParser has explicit security options but they are NOT defaults
const badParser = new xml2js.Parser(); // No entity protection configured!
} catch (_) { /* suppressed — this is intentionally shown as BAD */ }
Pattern 6: Go (golang.org/x/exp/xml / encoding/xml) — Secure Decoding
…(truncated)