Description
XPath injection occurs when externally controlled text becomes part of the expression syntax rather than being passed as data. lxml supports full XPath 1.0, so injected quotes, operators, or predicates can alter node selection or defeat the intended condition.
Python's standard xml.etree.ElementTree supports a limited ElementPath syntax rather than full XPath. Although fewer operations are available, attacker control of the match argument to find(), findall(), findtext(), or iterfind() can still select unintended nodes.
Potential impact
- Data exposure: Expanding the returned node selection may expose another user's or tenant's information.
- Authentication or authorization bypass: If a lookup determines login or access rights, a manipulated condition may bypass that decision.
- Unintended changes: The wrong nodes may be changed only when the application subsequently modifies or deletes selected nodes.
- Resource exhaustion: Allowing arbitrary complex XPath against sufficiently large documents can consume excessive CPU. Dynamic ElementPath does not necessarily cause denial of service.
Remediation
- Keep XPath or ElementPath expressions constant. Do not build their syntax with concatenation, f-strings,
%formatting, orstr.format(). - With
lxml, bind untrusted values as XPath variables, for exampletree.xpath("//user[@id=$uid]", uid=value). Variables replace values, not element names, operators, or expression structure. - If users must select a field, path, or operation, map input to complete constant expressions owned by the server. Normalize any directly used token first and validate the entire string against an allow-list.
- ElementTree has no XPath variable-binding API. Keep
matchconstant and compare values in Python, or choose a constant expression from a server-owned mapping. - Convert numeric positions or values to built-in Python numeric types and check application-specific ranges.
- Do not assume a function name or general-purpose escaping makes an expression safe. XPath string escaping depends on context, and replacing quotes alone is easily incomplete. Prefer variable binding or constant mappings.
- If user-authored arbitrary XPath is an actual requirement, isolate it from ordinary lookups and apply separate authorization, input-size limits, time limits, and resource limits.
Examples
lxml
Before
from flask import Flask, request
from lxml import etree
app = Flask(__name__)
XML_DATA = b"""
<accounts>
<account tenant="blue" id="1001"><email>blue@example.com</email></account>
<account tenant="red" id="2001"><email>red@example.com</email></account>
</accounts>
"""
@app.get("/account")
def account():
account_id = request.args.get("id", "")
root = etree.fromstring(XML_DATA)
# Unsafe: account_id is inserted directly into XPath syntax.
expression = f"//account[@tenant='blue' and @id='{account_id}']"
matches = root.xpath(expression)
if not matches:
return ("not found", 404)
return etree.tostring(matches[0], encoding="unicode")
After
from flask import Flask, request
from lxml import etree
app = Flask(__name__)
XML_DATA = b"""
<accounts>
<account tenant="blue" id="1001"><email>blue@example.com</email></account>
<account tenant="red" id="2001"><email>red@example.com</email></account>
</accounts>
"""
@app.get("/account")
def account():
account_id = request.args.get("id", "")
root = etree.fromstring(XML_DATA)
# The expression is constant; untrusted values are passed as variables.
expression = "//account[@tenant=$tenant and @id=$account_id]"
matches = root.xpath(
expression,
tenant="blue",
account_id=account_id,
)
if not matches:
return ("not found", 404)
return etree.tostring(matches[0], encoding="unicode")
Before the change, account_id is inserted inside quotes and can change the predicate structure. Passing the same value as an XPath variable prevents quotes or operators in the input from becoming expression syntax. Format and length validation may enforce separate business constraints but does not replace variable binding.
ElementTree
Map the complete expression when users need to select a lookup mode:
import xml.etree.ElementTree as ET
from flask import request
def accounts_by_view():
account_paths = {
"active": ".//account[@active='true']",
"disabled": ".//account[@active='false']",
}
selected_path = account_paths.get(request.args.get("view"))
if selected_path is None:
return ("invalid view", 400)
root = ET.fromstring(XML_DATA)
return root.findall(selected_path)
Implementation considerations
The server must own the allow-list and expression mapping. Review external-entity settings and input-size limits separately from protecting XPath expressions.