VNX-JAVA-013: Java XPath injection
Reports XPath.evaluate/compile, dom4j selectNodes/selectSingleNode and Jaxen XPath constructors whose expression carries HTTP request data, or concatenates a String method parameter into a quoted value, directly or through a local built a few lines above. Variable-resolver expressions, encoded values and constants are not reported.
| Field | Value |
|---|---|
| Rule ID | VNX-JAVA-013 |
| Severity | High |
| SARIF level | error |
| Kind | sast |
| Languages | Java |
| CWE | CWE-643 |
| CAPEC | CAPEC-83 |
| ATT&CK | T1190 |
| CWSS vector | CWSS:1.0/TI:H/AP:A/AL:A/IC:N/FC:LT/RP:N/RL:A/AV:I/AS:N/IN:A/SC:A/BI:H/DI:H/EX:H/EC:N/P:H |
| Tags | xpath-injection java injection |
| Source | internal/sast/rules/vnx-java-013.rego |
Overview
This rule flags Java code where xpath.evaluate() or xpath.compile() receives user-controlled input from HTTP request parameters, or where XPath expressions are assembled using string concatenation (+). An attacker can manipulate the injected expression to bypass authentication checks, extract all data from the underlying XML document, or enumerate its structure. This is CWE-643: Improper Neutralization of Data within XPath Expressions.
Why This Matters
XPath expressions describe paths through an XML document tree. When user input is interpolated directly into the expression string, an attacker can inject XPath operators to alter query logic. Unlike SQL databases, XML documents have no per-element access control: a single injected expression can traverse the entire document tree and return arbitrary nodes.
Authentication bypass is a common consequence. A filter such as //user[name='INPUT' and password='INPUT'] can be bypassed by supplying ' or '1'='1 as the username, turning the expression into //user[name='' or '1'='1' and password='...'], which selects all user nodes. From this foothold, more targeted payloads can exfiltrate every value in the document, configuration data, secrets stored as XML, or PII.
XPath injection is not as widely known as SQL injection, which means it is more likely to appear in code review blind spots and static analysis exclusion lists. Because Java’s javax.xml.xpath API lacks native parameterization, the fix requires a deliberate design choice rather than a simple API swap.
What Gets Flagged
The rule reports the call whose expression (first) argument carries request data, a servlet request value, an annotated handler parameter or a local built from one, or concatenates a String parameter of the enclosing method between quotes ('" + name + "'). Expressions using $variables with an XPathVariableResolver, values encoded with encodeForXPath or parsed as numbers, and request data used as the document being queried are not reported.
// FLAGGED: user input passed directly to evaluate()
String username = request.getParameter("user");
String result = xpath.evaluate(
"//users/user[name='" + username + "']",
doc);
// FLAGGED: compile() with concatenation
String filter = request.getParameter("filter");
XPathExpression expr = xpath.compile("/catalog/item[" + filter + "]");
NodeList items = (NodeList) expr.evaluate(doc, XPathConstants.NODESET);
// FLAGGED: evaluate with request parameter reference on same line
xpath.evaluate("//accounts/account[id='" + request.getParameter("id") + "']",
doc, XPathConstants.STRING);
Following the value through the method
The look-back stops at the enclosing method’s declaration (at most 120 lines up), and the method is read before the call is reported:
- a definition on a branch that never runs is dropped: an
if/elseor ternary whose condition folds to a constant (integer literals, andintlocals assigned once from a literal), or acasethat aswitchon a constant does not select; - a local that provably holds a constant at the call stops carrying the request value: a literal on every path, the element a list index or map key actually holds, or the result of a method in the same file (including a nested class) whose every
returnis a constant; - a request value stored in a list, map or builder and read back is followed, so
list.add(request.getParameter("x"))followed bylist.get(0)is request data.
// NOT FLAGGED: 100 / 4 > 20, so channel is always "news"
int limit = 100;
if (limit / 4 > 20) channel = "news";
else channel = request.getParameter("channel");
xp.evaluate("/rss/channel[@id='" + channel + "']/title", feed);
// FLAGGED: index 0 of the list holds the request value
tags.add(request.getParameter("tag"));
tags.add("featured");
xp.evaluate("//item[category='" + tags.get(0) + "']/title", feed);
Anything the analysis cannot resolve stays reported: a condition on request data, a call into another class that is handed a run-time value (a call with constant arguments is read as inert, below), a collection filled in a loop or handed to another method, a helper that returns (a transform of) its parameter, which stays as tainted as the argument it is handed (see Helpers in other files).
Helpers in other files
A call into another class of the project is read from that class’s own source instead of being guessed at. For obj.m(args), Cls.m(args) and new Cls(args).m(args) the class is found by the declared type of obj (a local, a parameter, or the new Cls(...) it was assigned from) or by the static class name, and the method (at most 60 lines, overloads told apart by arity) is summarised as one of:
- constant: every return is a literal, an
UPPER_CASEname, a field initialised to a literal and never assigned again, or the result of a constant method of the same class. The call is as constant as that literal, whatever it is handed. - a function of its arguments: every return is constant once the parameters, and the fields the class’s one constructor stores from its own parameters, are taken as constants. The call is only as risky as the arguments it depends on: a getter of a value stored by
new Cls(arg)is as tainted asarg, and a method that ignores one of its parameters is not tainted by it. - opaque: anything else, such as a method that reads a request value or a header, a field a setter can change, or a call the summary cannot see through.
A value of an interface or base class type runs the method of every project class that extends or implements it, so one implementation that returns its parameter keeps the call as tainted as that parameter. What is still reported: opaque calls, a simple class name that two files declare (ambiguous), a class outside the project, a type with an anonymous subclass, and a callee that hands the value to a third class (a callee is read one level deep). A method of the same file called without a receiver is summarised the same way.
// NOT FLAGGED: Sanitizer.clean ignores its argument and returns a literal
String value = sanitizer.clean(request.getParameter("value"));
xpath.evaluate("//user[@id='" + value + "']", doc);
// NOT FLAGGED: the constructor argument is a literal, so the getter is too
Holder holder = new Holder("report");
xpath.evaluate("//user[@id='" + holder.get() + "']", doc);
// FLAGGED: Echo.same returns its argument
String value = echo.same(request.getParameter("value"));
xpath.evaluate("//user[@id='" + value + "']", doc);
// FLAGGED: the getter returns what the constructor was given
Holder holder = new Holder(request.getParameter("value"));
xpath.evaluate("//user[@id='" + holder.get() + "']", doc);
Calls that cannot bring data in
A call into code the scan cannot see (another class or module) is treated as inert, and its value as a constant, when all of these hold:
- its name is not that of a source (
read*,next*,getParameter*,getHeader*,getCookie*,getenv,getProperty,query,execute); - every argument is a constant: a literal, or a name the method fixes;
- its receiver is a type name, a
newexpression with constant arguments, or a local built from such a call, and that local is only ever called with constant arguments.
// NOT REPORTED: the helper is given literals only
Tags tags = TagsFactory.create();
String tag = tags.nameOf("stocked");
catalog.selectNodes("//item[@tag='" + tag + "']");
// REPORTED: a run-time argument
String tag = tags.nameOf(param);
// REPORTED: the receiver was built from, or fed, a run-time value
Tags tags = new Tags(req.getParameter("scope"));
String tag = tags.nameOf("stocked");
// REPORTED: a source-like method name
String tag = Prompts.readLine("tag");
Anything that fails one of the tests stays reported, so a helper that hands back its argument is never mistaken for a constant.
Remediation
Java’s javax.xml.xpath API does not natively support parameterized queries in the same way JDBC’s PreparedStatement does, but the XPathVariableResolver interface provides an equivalent mechanism: user-supplied values are bound as typed variables, never interpolated as expression syntax.
-
Use
XPathVariableResolverfor parameterized XPath queries:// SAFE: user value is bound as a typed variable, not expression syntax import javax.xml.namespace.QName; import javax.xml.xpath.*; String username = request.getParameter("user"); XPathFactory factory = XPathFactory.newInstance(); XPath xpath = factory.newXPath(); // Bind user input as a variable, it cannot alter expression structure xpath.setXPathVariableResolver(variableName -> { if ("username".equals(variableName.getLocalPart())) { return username; } return null; }); // Expression uses $username; structure is fixed at compile time XPathExpression expr = xpath.compile("//users/user[name=$username]"); NodeList nodes = (NodeList) expr.evaluate(doc, XPathConstants.NODESET);The variable resolver passes the user value as a data binding. The XPath engine never interprets it as part of the expression syntax, so injection metacharacters such as
',",[,],or, andandare treated as literal string data. -
Validate input against a strict allowlist before any XPath use. Even with variable resolvers, apply upfront validation to reject values that do not conform to the expected format. For numeric identifiers, parse as an integer. For names, enforce an alphanumeric pattern:
// Supplementary guard: allowlist validation before XPath String username = request.getParameter("user"); if (username == null || !username.matches("[a-zA-Z0-9._@-]{1,64}")) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid username"); return; } // Proceed with variable resolver pattern above -
Pre-compile expressions outside of request handlers. Compile XPath expressions once at startup (e.g. in a
@PostConstructmethod or Spring bean initializer) and cache the resultingXPathExpression. This eliminates any possibility of per-request expression construction and improves performance:@Component public class UserXPathQueries { private final XPathExpression findByName; @PostConstruct public void init() throws XPathExpressionException { XPath xpath = XPathFactory.newInstance().newXPath(); // Expression is fixed, user values will be bound via variable resolver this.findByName = xpath.compile("//users/user[name=$username]"); } public NodeList findUser(Document doc, String username) throws XPathExpressionException { XPath xpath = XPathFactory.newInstance().newXPath(); xpath.setXPathVariableResolver(name -> "username".equals(name.getLocalPart()) ? username : null); return (NodeList) findByName.evaluate(doc, XPathConstants.NODESET); } } -
Consider replacing XML with a database or JSON store. If XPath queries are used to implement business logic that grows complex over time, the design may benefit from migrating to a relational or document database that provides native parameterized query APIs with access control semantics.
-
Apply least-privilege document access. If the XML document contains mixed-sensitivity data, consider splitting it into separately loaded documents so that a given handler only receives the subset of the tree relevant to its function. An injected query can only reach data present in the
Documentobject passed toevaluate().
References
- CWE-643: Improper Neutralization of Data within XPath Expressions
- CAPEC-83: XPath Injection
- MITRE ATT&CK T1190 – Exploit Public-Facing Application
- SEI CERT IDS53-J: Prevent XPath Injection
- OWASP XPath Injection
- OWASP Injection Prevention in Java Cheat Sheet
- OWASP ASVS V5.2 – Sanitization and Sandboxing
- Java javax.xml.xpath API documentation
Suppressing this rule
Put a nosec comment on the line, or on line 1 to cover the whole file. Use the comment marker of the file’s language (#, //, -- or ;):
# nosec vnx-java-013
// nosec vnx-java-013
Or record an ignore rule: vulnetix ignore add --rule vnx-java-013 --reason "...". See Suppressing findings.