
XPath injection is user input concatenated into a query against XML.
A login or a search that builds an XPath string will accept a quote that changes the query. Parameterized XPath APIs exist in some stacks. In others you allow-list and escape for that grammar, not for HTML.
The usual mistake is validating that the field ‘looks like an email’ and then interpolating it into the XPath anyway.
This page is the sink, a documented CVE in that shape, and the test that a quote in the field does not change the result set.
CVE-2019-0370 is CVSS 6.5 on NVD. SAP Financial Consolidation, on the two versions NVD lists as 10.0 plus 10.1, took user text and let it change the XPath around it. As of 11 December 2025, NVD page. The last modification I saw is 16 June 2026. CWE-643 last moved in CWE 4.19. The page still says CWE 4.20.
This page is the boundary: a typed value, a compiled template, an allowlist where a bind cannot go. The login field still needs an allowlist on the way in. That is input validation. The interpreter family is injection. The directory cousin is LDAP.
Compile the path once. Bind $login as a value. The string from the form never becomes an axis or a second step.
SecureCoding
CWE-643 is grammar, not a quote filter
CWE-643 is “Improper Neutralization of Data within XPath Expressions.” MITRE entry. The product builds all or part of an expression from upstream input and does not keep that input in the data plane. CWE lists it as a child of CWE-91 and of CWE-943. It sits in OWASP Top Ten 2025 Category A05, Injection. The mitigation on that page is parameterized XPath, for example via XQuery, so the value cannot become an operator, a predicate, or a second path.
An XML document is an interpreter. The language has predicates, unions, wildcards, and axes. If a login field can change any of that grammar, the engine answers a different question than the one you wrote. Stripping a single quote is not parameterization. A later character, a different quote style, or a predicate that never needed a quote will walk past the filter.
Grep the interpolations. The CWE example is still a concatenated login. That is the control to remove.
Bind $login. Do not concatenate it
Java ships XPathVariableResolver with the platform javax.xml.xpath API. Compile a template that names $login. Resolve that name to the string the user typed. The engine treats it as a value. It does not parse it as a step.
import java.util.regex.Pattern;
import javax.xml.xpath.*;
import org.w3c.dom.Document;
final Pattern LOGIN_OK = Pattern.compile("^[A-Za-z0-9._-]{1,64}$");
static String lookupUser(Document doc, String login) throws XPathExpressionException {
if (!LOGIN_OK.matcher(login).matches()) return null;
XPath xpath = XPathFactory.newInstance().newXPath();
xpath.setXPathVariableResolver(name -> {
if ("login".equals(name.getLocalPart())) return login;
return "";
});
XPathExpression expr = xpath.compile(
"/users/user[login/text()=$login]/home_dir/text()"
);
String home = expr.evaluate(doc);
return home.isEmpty() ? null : home;
}
lxml does the same job with a keyword argument. The template is a literal. login= is the bind.
import re
from lxml import etree
LOGIN_OK = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
def lookupUser(tree, login: str):
if not LOGIN_OK.match(login):
return None
found = tree.xpath("/users/user[login/text()=$login]", login=login)
if len(found) != 1:
return None
return found[0]
rejectIfNoBind is the named fallback when the library has no variable API. Fail closed. Do not switch to string format.
function rejectIfNoBind() {
throw new Error("xpath bind API missing; refusing to interpolate");
}
Compile once at startup when the path is fixed. Reusing XPathExpression avoids a per-request compile and makes it harder for a later author to splice a string into the template. If you must choose a field at runtime, that is the next section. It is not an excuse to concatenate login.
Allowlist axes and node names
A bind covers a value. It does not cover a step. child::, descendant-or-self::, parent::, |, and a raw // are grammar. If a query parameter is allowed to pick “search by email or uid,” that parameter is a token, not a path fragment.
const AXIS_OK = new Map([
["email", "/users/user[email/text()=$q]"],
["uid", "/users/user[uid/text()=$q]"],
]);
function lookupUserByToken(doc, token, q, bindXPath) {
const template = AXIS_OK.get(token);
if (!template) return null;
if (!LOGIN_OK.test(q)) return null;
return bindXPath(doc, template, { q });
}
AXIS_OK is the whole surface. A token that is not a key returns null. Do not take req.query.path and prepend it to a document. Do not take a “custom XPath” field from an admin UI and pass it to compile. An admin UI that lets people type path grammar is an XML console. Put that on a jump host, not in the app.
kjhughes said the same thing on Stack Overflow 30363567, 21 May 2015: do not take in a full expression from an unsecured source. Isolate the user-based parts to string-only parameters, then use the library parameterization mechanism. The Jaxen name he gives for Axiom is SimpleVariableContext. Use that if you are on Jaxen. Use XPathVariableResolver if you are on the JDK. The rule is the same.
CVE-2019-0370, and what NVD still says
Those are different interpreters. This page keeps the XPath one.
NVD’s English is short: due to missing input validation, the product enabled crafted input to interfere with the structure of the surrounding query. NIST mapped it to CWE-91, the parent. CVSS 3.1 is 6.5. Published 8 October 2019. The affected lines are the same pair NVD names in the opener. SAP note 2806403 is behind a login. This section does not include payloads.
The useful half of that CVE is the shape. The product let a field change the query structure. A patch on 10.0 and 10.1 closed that instance. Your handler is not patched because SAP shipped. Your handler is patched when lookupUser binds $login and AXIS_OK owns every path.
Same family as SQL and LDAP
CWE-643’s own relationship note says the class is like SQL injection, command injection, and LDAP injection. The target here is the XML document. The control is the same split: you write the grammar, the user supplies a value.
SQL has a wire protocol that can carry the statement and the parameters apart. XPath 1.0 in the JDK is a compile-plus-resolver. LDAP is a hard-coded filter template plus an escaped assertion. None of those three is “strip the quote and hope.” Injection is the hub. LDAP is the directory writeup. This page is the XML one.
Auth on an XML user file is the case CWE still uses as its example. Require exactly one match. If lookupUser returns zero or more than one, fail closed. Do not treat “any node came back” as success. A wide predicate that still binds $login can return a set. The count check is the cheap second control, the same way a size limit is the cheap second control on a directory search.
Error pages that reprint the compiled expression leak the template and the document shape. Log a template id, not the rendered string. Return a generic 400. That is not “security by hiding.” It is so a support dump does not become a map of /users/user.
Grep the interpolations
You are not probing a document you do not own. You are proving your helper bound a value and that login code has no string-plus path. Point the tests at a fixture XML file in the repo.
rg -n "xpath.*(\\+|`|\\$\\{|String.format|sprintf)" --glob '!node_modules'
rg -n "XPathVariableResolver|setXPathVariableResolver|SimpleVariableContext|login=login" \
--glob '!node_modules'
rg -n "AXIS_OK|rejectIfNoBind" --glob '!vendor'
A hit that builds an expression with + or a format string is the review. A login file with no resolver and no login= bind is the CWE example. A search that takes req.query.path is the full-expression case kjhughes told people to stop.
from lxml import etree
from app.xpath_auth import lookupUser, LOGIN_OK
def test_bind_keeps_reserved_text_as_a_value():
tree = etree.XML(b"<users><user><login>ada</login></user></users>")
assert lookupUser(tree, "ada") is not None
assert lookupUser(tree, "ada]lee") is None # LOGIN_OK rejects
def test_allowlist_rejects_unknown_token():
assert LOGIN_OK.match("ada.lee")
assert not LOGIN_OK.match("ada lee")
The first test pins the allowlist so a reserved octet never reaches the engine on this path. The second test pins LOGIN_OK to the same pattern the Java helper uses. Keep the identifier LOGIN_OK in both languages. A later author who widens only one side is the next miss.
Questions we keep getting
Can I skip the bind if I already allowlist usernames?
On the login field, an allowlist that excludes quotes, brackets, and spaces is already a pass for that one field. Keep the bind for the next field. Display names, emails, and search strings grow characters the allowlist will not hold. The bind is the default. The allowlist is the extra belt on identifiers you already decided are tokens.
Is escaping quotes enough, the way OWASP still mentions?
OWASP’s XPath page still describes replacing a delimiter as a fallback when no parameterized API exists. Michael Kay’s line is the one this page follows. If your library has variables, use them. If it does not, rejectIfNoBind and pick a library that does. Escaping one quote is how the next delimiter wins.
Does a failed login mean the expression was safe?
No. A failed login tells you no node matched the password check you ran afterwards. It does not tell you the expression selected the node you intended. Check the count, then compare secrets. Log the template id, not the raw path.



