Skip to content

CWE-91: XML Injection (aka Blind XPath Injection) - Java

Overview

XML Injection in Java applications occurs when untrusted user input is used to construct XML documents or queries without validation or escaping. By injecting special characters (<, >, &, ', "), an attacker can add elements the application was never written to emit, or widen an XPath predicate until it matches every record in the document.

Primary defense: Build XML with the W3C DOM API (DocumentBuilder, Element.setTextContent()) or an annotation-based mapper (JAX-B, Jackson XML) instead of string concatenation - these treat values as text content and escape them automatically. Where a builder API isn't practical, escape every interpolated value with StringEscapeUtils.escapeXml11() from Apache Commons Text. For XPath, do not concatenate at all: javax.xml.xpath supports true variable binding through XPath.setXPathVariableResolver(), which is the equivalent of a PreparedStatement placeholder. If the same application also parses untrusted XML, harden the parser as well - FEATURE_SECURE_PROCESSING alone is not that fix, since it caps entity expansion and attribute counts rather than stopping a DOCTYPE from being processed. The setting that does is disallow-doctype-decl, and CWE-611 has the full recipe.

Common vulnerability scenarios: Spring Boot/JAX-RS endpoints that build XML responses or SOAP envelopes with string concatenation, XML configuration files written from user preferences, and XPath queries built by concatenating a search term into the expression.

Java XML APIs:

  • org.w3c.dom (javax.xml.parsers) - W3C DOM API, recommended for general use
  • javax.xml.stream (StAX) - streaming API for large documents
  • Jackson XML / JAX-B - annotation-driven object-to-XML mapping
  • javax.xml.xpath - XPath queries
  • org.apache.commons.text.StringEscapeUtils - manual XML escaping utility

Common Vulnerable Patterns

String Concatenation into XML

// VULNERABLE - user input concatenated directly into an XML string
public String createUserXml(String username, String email) {
    String xml = "<?xml version=\"1.0\"?>\n" +
                 "<user>\n" +
                 "  <username>" + username + "</username>\n" +
                 "  <email>" + email + "</email>\n" +
                 "</user>";
    return xml;
}

// Attack: username = "</username><admin>true</admin><username>"
// Result: <username></username><admin>true</admin><username></username>
// Creates an unintended <admin> element

Why this is vulnerable: No XML special characters are escaped, so a value containing </username> closes the current element early and any markup that follows becomes part of the document structure instead of text content. The same flaw shows up wherever concatenated values land: SOAP envelopes (<UserId>" + userId + "</UserId> lets an attacker inject <Role>admin</Role>), XML config files, and attribute values (custom=\"" + attrValue + "\" - a value containing an unescaped " closes the attribute early and lets the attacker append new attributes). DocumentBuilder.parse() accepts whatever bytes it's given; it doesn't retroactively escape a string built before parsing.

XPath Query Injection

// VULNERABLE - user input concatenated into an XPath expression
XPath xpath = XPathFactory.newInstance().newXPath();
String xpathExpr = "//user[name='" + username + "']";
XPathExpression expression = xpath.compile(xpathExpr);
NodeList results = (NodeList) expression.evaluate(doc, XPathConstants.NODESET);

// Attack: username = "' or '1'='1"
// XPath becomes: //user[name='' or '1'='1']  -> returns all users

Why this is vulnerable: This is a different sink from markup injection - the attacker isn't adding XML elements, they're changing the boolean logic of the query itself. Escaping XML entities does nothing here because the injection point is inside an XPath string literal, not XML markup.

Secure Patterns

DOM API (Primary Defense)

// SECURE - W3C DOM API escapes text content automatically
import org.w3c.dom.*;
import javax.xml.parsers.*;
import javax.xml.transform.*;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.StringWriter;
import java.util.regex.Pattern;

private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9._-]{1,100}$");
private static final Pattern EMAIL_PATTERN =
    Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");

public String createUserXml(String username, String email) throws Exception {
    if (!USERNAME_PATTERN.matcher(username).matches())
        throw new IllegalArgumentException("Invalid username");
    if (!EMAIL_PATTERN.matcher(email).matches())
        throw new IllegalArgumentException("Invalid email");

    Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
    Element root = doc.createElement("user");
    doc.appendChild(root);

    Element usernameElem = doc.createElement("username");
    usernameElem.setTextContent(username);  // escaped automatically
    root.appendChild(usernameElem);

    Element emailElem = doc.createElement("email");
    emailElem.setTextContent(email);  // escaped automatically
    root.appendChild(emailElem);

    Transformer transformer = TransformerFactory.newInstance().newTransformer();
    StringWriter writer = new StringWriter();
    transformer.transform(new DOMSource(doc), new StreamResult(writer));
    return writer.toString();
}

// createUserXml("<script>alert('xss')</script>", "test@example.com")
// -> IllegalArgumentException: Invalid username. USERNAME_PATTERN rejects the
//    value before the DOM API sees it, so nothing is emitted at all.
//
// The escaping is the control that matters for a field with no such format
// constraint. Given a free-text bio element:
//    bioElem.setTextContent("</bio><admin>true</admin>");
// -> <bio>&lt;/bio&gt;&lt;admin&gt;true&lt;/admin&gt;</bio>

Why this works: Element.setTextContent() treats its argument as character data, not markup, so Transformer escapes it for the context it lands in - &, < and > in element text, and additionally " in an attribute set through setAttribute(), which is where an unescaped quote would end the attribute early. A value containing </username><admin>true</admin> becomes inert text rather than new elements. The DOM API builds a tree internally and Transformer serializes it safely, so there is never a raw string for an attacker to break out of. The regex validation adds defense-in-depth. This pattern loads the whole document into memory; for very large or streamed XML, use StAX instead.

StAX for Large or Streamed XML

// SECURE - StAX streams output and escapes automatically
private static final Pattern XML_NAME = Pattern.compile("^[A-Za-z_][A-Za-z0-9._-]*$");

XMLStreamWriter writer = XMLOutputFactory.newInstance().createXMLStreamWriter(stringWriter);
writer.writeStartDocument("UTF-8", "1.0");
writer.writeStartElement("response");
for (Map.Entry<String, String> entry : data.entrySet()) {
    // The name is not escaped by anything downstream - check it, don't write it
    if (!XML_NAME.matcher(entry.getKey()).matches()) {
        throw new IllegalArgumentException("Invalid XML element name: " + entry.getKey());
    }
    writer.writeStartElement(entry.getKey());
    writer.writeCharacters(entry.getValue());  // escaped automatically
    writer.writeEndElement();
}
writer.writeEndElement();
writer.writeEndDocument();

Why this works: writeCharacters()/writeAttribute() escape content the same way DOM does, but write incrementally instead of building a full tree - use this for XML too large to hold comfortably in memory. The XML_NAME check is not defense-in-depth here, it is the only control on that argument: writeStartElement() takes a name, and no XML API escapes names, so a map key sourced from a request or a database is an unguarded write into document structure. The JDK's writer does not reject a malformed one either: the key evil><admin>true</admin><x is written out verbatim, closing <evil> and adding an <admin> element. The DOM API is stricter here - Document.createElement() throws DOMException for a name that is not a valid XML name - so this is a gap specific to the streaming writer. writeCharacters() also escapes text content but not CDATA sections, so avoid writeCData() with user input (an attacker-controlled ]]> still terminates the section early).

Apache Commons Text Escaping (Fallback for Manual Construction)

// SECURE - explicit escaping when a builder API isn't practical
import org.apache.commons.text.StringEscapeUtils;

String safeUsername = StringEscapeUtils.escapeXml11(username);
String safeEmail = StringEscapeUtils.escapeXml11(email);
String xml = "<user><username>" + safeUsername + "</username><email>" + safeEmail + "</email></user>";
<!-- Maven dependency -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-text</artifactId>
    <version>1.15.0</version>
</dependency>

Why this works: escapeXml11() escapes all five XML special characters plus the control characters XML 1.1 permits, making it safe to concatenate the result into a string (use escapeXml10() for strict XML 1.0 documents). It is more error-prone than a builder API because every concatenated value needs its own explicit call - miss one attribute a few lines down and that value stays unescaped. Reach for this only when integrating with code that requires an XML string rather than a tree API.

Object Mapping with JAX-B

// SECURE - JAX-B maps annotated objects to XML
@XmlRootElement(name = "user")
@XmlAccessorType(XmlAccessType.FIELD)
class User {
    @XmlElement private String username;
    @XmlElement private String email;
    // constructor, getters/setters omitted
}

JAXBContext context = JAXBContext.newInstance(User.class);
Marshaller marshaller = context.createMarshaller();
StringWriter writer = new StringWriter();
marshaller.marshal(new User(username, email), writer);

Why this works: JAX-B escapes field values during marshalling, and because only declared fields are ever emitted, an attacker cannot inject an arbitrary <admin> element - there's no code path that writes a field the User class doesn't declare. Note: JAX-B was removed from the JDK in Java SE 11; add the jakarta.xml.bind-api and a runtime implementation (e.g. jaxb-runtime) as explicit dependencies. In Spring Boot REST controllers, Jackson XML (@JacksonXmlRootElement, @JacksonXmlProperty) provides the same escaping guarantee without that extra dependency and is the more common choice for new services - see Framework-Specific Guidance below.

XPath: Bind a Variable (Primary Defense)

// SECURE - the value is bound to $username, not concatenated into the expression
import javax.xml.xpath.*;
import javax.xml.namespace.QName;
import java.util.Map;

public NodeList findUserByName(Document doc, String username) throws XPathExpressionException {
    XPath xpath = XPathFactory.newInstance().newXPath();
    Map<QName, Object> vars = Map.of(new QName("username"), username);
    xpath.setXPathVariableResolver(vars::get);

    XPathExpression expression = xpath.compile("//user[name=$username]");
    return (NodeList) expression.evaluate(doc, XPathConstants.NODESET);
}

Why this works: javax.xml.xpath does have parameterization - setXPathVariableResolver is the XPath equivalent of a PreparedStatement placeholder. The expression string compiled by compile() is fixed and contains no attacker-controlled characters; $username is resolved to a single string value at evaluation time, so quotes, or and function calls inside it are compared as text rather than parsed as syntax. Measured on JDK 26 against a document holding alice, bob and O'Brien: the bound query returns 1 node for alice and 0 for ' or '1'='1', where the concatenated version returns all three. It also accepts values the concatenated form cannot express at all, such as O'Brien - XPath 1.0 string literals have no escape sequence for their own delimiter.

One ordering trap: set the resolver before calling compile(). The compiled expression captures the resolver in place at compile time, so installing one afterwards leaves evaluation throwing XPathExpressionException: ... a variable resolver is not set (JDK 26). A resolver that returns null for an unknown name is the correct behaviour and fails loudly - a $typo in the expression raises resolveVariable for variable typo returning null rather than silently matching nothing, which is what Map::get gives you for free.

XPath: Iterate and Compare

// SECURE - no user input in the XPath expression itself
XPathExpression expression = xpath.compile("//user");
NodeList users = (NodeList) expression.evaluate(doc, XPathConstants.NODESET);
for (int i = 0; i < users.getLength(); i++) {
    Node user = users.item(i);
    NodeList children = user.getChildNodes();
    for (int j = 0; j < children.getLength(); j++) {
        Node child = children.item(j);
        if ("name".equals(child.getNodeName()) && username.equals(child.getTextContent())) {
            return user;
        }
    }
}
return null;

Why this works: The XPath expression ("//user") is static and contains no user input, eliminating the injection surface entirely; filtering happens afterward with an exact string comparison (equals()) in Java. Prefer variable binding above where the lookup is expressible as a predicate: both scan the document, but the bound version does the filtering inside the evaluator instead of materializing every <user> node and its children into a NodeList you then walk. Reach for iterate-and-compare when the match is not a simple predicate, or when the comparison itself has to happen in Java (locale-aware collation, normalization, a canonicalized form).

Framework-Specific Guidance

Spring Boot with Jackson XML

@GetMapping(value = "/user", produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<?> getUser(@RequestParam String username) {
    if (!USERNAME_PATTERN.matcher(username).matches()) {
        return ResponseEntity.badRequest().body(new ErrorResponse("Invalid username"));
    }
    return ResponseEntity.ok(new UserResponse(username));
}

@JacksonXmlRootElement(localName = "response")
static class UserResponse {
    @JacksonXmlProperty private final String name;
    UserResponse(String name) { this.name = name; }
}

Why this works: Jackson XML escapes property values during marshalling the same way JAX-B does, and produces = MediaType.APPLICATION_XML_VALUE sets the Content-Type header explicitly so clients don't misinterpret the response. Returning a generic error object (not the raw validation exception) avoids leaking why a request was rejected.

Testing

  • Submit </username><admin>true</admin> to a field with no format constraint (a bio, a comment, a description). Assert on the serialized output, not the HTTP status: the payload must come back as &lt;/username&gt;... text and re-parsing the document must yield one element, not two. A field guarded by a regex is rejected before the escaping is exercised, so testing only that field proves nothing about the escaping.
  • Round-trip a value containing all five metacharacters (<script>&"') through build, serialize and re-parse, and assert the text comes back identical. This is the single assertion that catches both an escaper that misses a character and a "fix" that double-escapes.
  • Submit ' or '1'='1 to the XPath lookup and assert the result set has the same size as for a nonexistent user (zero), not the size of the whole document. Note the payload only bypasses a single-clause predicate: against //user[name=$n and password=$p] it has to appear in both fields, because and binds tighter than or.
  • Test normal inputs to confirm the secure pattern doesn't reject legitimate data, and include O'Brien - a bound query matches it where a concatenated one cannot express it at all.
  • Test boundary cases: empty strings, maximum-length values, Unicode characters.
  • Confirm attribute values and element names are both covered, not just element text content.
  • Re-scan with the security scanner that produced the original finding to confirm it no longer triggers.

Common Pitfalls

  • Passing untrusted input into writer.writeStartElement(userInput) (an element name) instead of only writer.writeCharacters(userInput) (element content) - StAX auto-escapes character data, not element or attribute names, so building a name from input is a different, unaudited code path even in an otherwise-safe streaming writer.
  • Using StringEscapeUtils.escapeHtml4() instead of escapeXml11() because both are already imported from Commons Text - HTML escaping targets a broader named-entity set than XML defines, so output can include entities a strict XML parser without a DOCTYPE won't recognize, producing inconsistent parsing rather than the safe output the developer expected.

Dependencies and Installation

  • org.w3c.dom, javax.xml.stream (StAX), and javax.xml.xpath ship with the JDK - no dependency needed.
  • org.apache.commons:commons-text (shown above) for StringEscapeUtils.
  • com.fasterxml.jackson.dataformat:jackson-dataformat-xml for Jackson XML (typically already present via spring-boot-starter-web's Jackson dependency management).
  • jakarta.xml.bind:jakarta.xml.bind-api plus a runtime such as org.glassfish.jaxb:jaxb-runtime for JAX-B on Java 11+ (JAX-B was removed from the JDK in Java SE 11).

Additional Resources