VNX-643: XPath injection: query built from a dynamic string
Reports an XPath query that is built by concatenation, formatting or an f-string and then compiled or evaluated (Java javax.xml.xpath and dom4j; Python lxml, elementpath and ElementTree; a Python expression assembled in an `io.StringIO` with `write()` counts as built). Parameterised queries, constant expressions and operands the enclosing method proves constant are not reported.
| Field | Value |
|---|---|
| Rule ID | VNX-643 |
| Severity | High |
| SARIF level | error |
| Kind | sast |
| Languages | Java, Python |
| CWE | CWE-643 |
| CAPEC | CAPEC-66 |
| 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 injection cwe-643 |
| Source | internal/sast/rules/vnx-643.rego |
Overview
VNX-643 maps to CWE-643: Improper Neutralization of Data within XPath Expressions (‘XPath Injection’). It reports an XPath expression that splices a variable into the query text, either inline in the call or in a variable assigned up to 20 lines above, when that expression reaches an XPath compile or evaluate call.
Why This Matters
XPath has no prepared statements by default. A value such as ' or '1'='1 closes the quoted literal and turns a lookup for one user into a match on every node, which is how XML-backed logins and directories are bypassed and dumped.
What Gets Flagged
xpath.evaluate("//stock[@supplier='" + supplier.getCode() + "']", doc, XPathConstants.NODESET);
String path = "//bin[@label='" + bin + "']";
XPathExpression expr = xpath.compile(path);
doc.selectSingleNode(String.format("//product[sku='%s']", sku));
A Java expression traced to request data (request.getParameter, a header or cookie, a @RequestParam or other annotated handler parameter, or a String method parameter spliced into a quoted value as in "//user[@name='" + name + "']") is reported by VNX-JAVA-013, the Java taint rule for the same injection, so the line appears once. This rule keeps the Java expressions built from any other non-constant value, and every Python one.
tree.xpath(f"//user[@name='{user}' and @password='{pw}']")
query = f"/Employees/Employee[@emplid='{param}']"
run_query = lxml.etree.XPath(query)
root.findall(".//book[@author='" + author + "']")
Not flagged: xpath.evaluate("//user[name/text()=$user]", doc) with an XPathVariableResolver, tree.xpath("//user[@name=$name]", name=user), constant expressions such as ROOT + "/server/host", and other evaluate() calls (ScriptEngine, browser document.evaluate).
Reading The Method First
Before reporting, the rule reads the enclosing Java method or Python function (at most 120 lines up) the way a reviewer would, and does not report an operand that provably holds a constant where the query is built:
- a literal it was last assigned on every path that reaches the query;
- the branch an
if/else(Pythonif/elif/else) or a conditional expression actually takes, when the condition folds to a constant: integer literals and integer locals assigned once (if (4 * 10) + n > 30withn = 5), or in Python a literal tested within/not inagainst a string local; - the case a
switch(Java) ormatch(Python) on a constant selects; - the element a list index, or a map, dict or
ConfigParserkey, actually holds after the adds, removes, puts and pops above it, when that element is a literal; - the result of a method or function in the same file (a nested class included) whose every return is constant.
sku = request.args.get("sku", "")
batch = 8
if batch * 5 - 10 > 20: # always true
pick = "SKU-100"
else:
pick = sku # never runs
tree.xpath(f"//item[@sku='{pick}']") # not flagged
In Python, a value placed inside an apostrophe-quoted XPath literal is also not reported when its apostrophes are replaced ({name.replace("'", "'")}) or when an early return has proven it has none (if "'" in name: return ... above the query, in a block that encloses it). A value between double quotes is still reported, because the apostrophe check does not protect that delimiter.
Anything the analysis cannot resolve stays reported: a condition on run-time data, a helper in another file, a collection that is passed to a call or aliased, a value assigned inside try, a loop, or rebound by a tuple, for, with or walrus target.
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*,input,getenv,get_*of a parameter, header, cookie or value,query,execute,popen); - every argument is a constant: a literal, or a name the method fixes;
- its receiver is a class, an imported module, or a local built from such a call or from a constructor with constant arguments, and that local is only ever called with constant arguments.
# NOT REPORTED: the helper is given literals only
book = DeptBook.standard()
code = book.code_of("staff")
TREE.xpath("//user[@dept='" + code + "']")
# REPORTED: a run-time argument
code = book.code_of(dept)
# REPORTED: the receiver was built from, or fed, a run-time value
book = DeptBook(request.args.get("scope", ""))
code = book.code_of("staff")
# REPORTED: a source-like method name
code = helpers.read_line("dept")
Anything that fails one of the tests stays reported, so a helper that hands back its argument is never mistaken for a constant.
Remediation
- Bind values as XPath variables:
XPath.setXPathVariableResolverin Java, keyword arguments with$namein lxml. - If a variable cannot be bound, validate the value against a strict allow-list (for example digits only for an id).
- Never build the expression text from request data.
References
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-643
// nosec vnx-643
Or record an ignore rule: vulnetix ignore add --rule vnx-643 --reason "...". See Suppressing findings.