Bind the input as XPath variables, never splice it into the expression. lxml's doc.xpath("//employee[name=$n and role=$r]/title/text()", n=name, r=role) matches the values as data, so quotes and operators in them are literal. Parameterize every field, not just the obvious one, and keep secret nodes out of what the query selects.
- Find every XPath built from input: doc.xpath(), etree.XPath(), ElementTree.find/findall with an f-string or % formatting, and any helper that concatenates a query.
- Rewrite the query with XPath variables ($name, $role) and pass the values as keyword arguments to doc.xpath(); lxml binds them as data.
- Parameterize every field the caller controls, including ones that look fixed (an enum, a dropdown); a 'trusted' concatenated field is the usual bypass.
- Scope the query to the public nodes you mean to return (here, /title/text()), so even a malformed query cannot reach a secret element.
- Return a generic error for a lookup that fails to evaluate; do not echo the query.
- Add regression tests with tautology and union payloads in each field and assert no secret value is returned.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def lookup(doc, name: str, role: str) -> list[str]:
# The values are spliced into the expression, so their quotes and operators are parsed
# as XPath: "' or '1'='1" becomes a tautology, and a "] | //employee/ssn ..." union
# leaks the secret node.
query = "//employee[name='%s' and role='%s']/title/text()" % (name, role)
return [str(value) for value in doc.xpath(query)]With IMPL=vulnerable, 5 of 7 tests fail (4 exploit tests).
The fix: bind the input as XPath variables
def lookup(doc, name: str, role: str) -> list[str]:
# $n and $r are XPath variables: lxml binds the values, so quotes and XPath operators
# in them are treated as literal characters, not syntax.
results = doc.xpath("//employee[name=$n and role=$r]/title/text()", n=name, r=role)
return [str(value) for value in results]With IMPL=fixed, 7 of 7 tests pass.
A fix that looks safe but is not
def lookup(doc, name: str, role: str) -> list[str]:
# Only name is parameterized; role is spliced in, so a crafted role still injects a
# union that leaks the ssn element or a tautology that returns everyone.
query = "//employee[name=$n and role='%s']/title/text()" % role
return [str(value) for value in doc.xpath(query, n=name)]With IMPL=wrong, 2 of 7 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: payloads that try to list everyone or read the secret ssn element.
def _assert_no_ssn(text):
for ssn in SSNS:
assert ssn not in text, "the secret ssn element leaked through the lookup"
def test_exploit_name_tautology_returns_no_extra_rows(client):
# The attacker names no real employee; a tautology must not return anyone's title.
_, titles, text = lookup(client, "' or '1'='1", "engineer")
_assert_no_ssn(text)
assert "Staff Engineer" not in titles and "Director" not in titles
def test_exploit_role_union_leaks_ssn(client):
_, titles, text = lookup(client, "Alice", "engineer'] | //employee/ssn/text() | //employee[role='engineer")
_assert_no_ssn(text)
def test_exploit_role_tautology_returns_no_extra_rows(client):
_, titles, text = lookup(client, "Alice", "x' or '1'='1")
_assert_no_ssn(text)
assert "Director" not in titles
def test_exploit_name_union_leaks_ssn(client):
_, titles, text = lookup(client, "x'] | //employee/ssn/text() | //employee[name='x", "engineer")
_assert_no_ssn(text)- Escaping quotes does not work in XPath 1.0: there is no backslash escape, and doubling a quote ('') is a syntax error inside a literal, so a 'sanitizer' either breaks valid input or is bypassed; bind variables instead.
- Parameterising only the obvious field leaves the others injectable: here name is bound but role is concatenated, and a crafted role still injects a union that leaks the ssn element.
- A union payload (...] | //employee/ssn/text() | ...) adds nodes the query never meant to select, so limiting the final step to /title is not enough on its own; the variables are what stop it.
- etree.XPath(expr) compiled from a formatted string is just as injectable as doc.xpath(formatted); the fix is to put the input in the variables you pass when you call it, not in expr.
- lxml evaluates the expression; some other XPath libraries differ in version and feature support, so check that yours supports variable binding (ElementTree's limited XPath does not, so build the query from fixed parts and a vetted allowlist there).
- Returning element nodes rather than text can serialize more than you expect; select the specific text you mean to expose.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs xpath-injection/python-flask does this for you).
- Against your own endpoint, send a tautology (' or '1'='1) and a union payload (...] | //employee/ssn/text() | ...) in each field and confirm no secret value is returned and no extra rows appear; confirm a name with an apostrophe (O'Brien) still works.
- Semgrep's registry has no Python XPath-injection rule, and Bandit has none; CodeQL's py/xpath-injection models lxml's .xpath() expression argument as a sink, so it should flag the %-formatted queries in the vulnerable and wrong code (read, not executed). The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/python, p/owasp-top-ten, p/security-audit, p/flask | No findings on any implementation. semgrep-rules develop at 9bbf021 has no Python XPath-injection rule (no rule file mentions xpath), so the %-formatted queries in the vulnerable and wrong code are not flagged.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3; semgrep scan --metrics=off --config p/python --config p/owasp-top-ten --config p/security-audit --config p/flask --exclude tests . |
| CodeQL | py/xpath-injection | Lxml.qll models the first argument of an lxml .xpath() call (and etree.XPath/ETXPath) as an XPath sink, and Flask.qll makes request.args a remote source, so the %-formatted queries in the vulnerable and wrong code should be reported; in the fixed code the input reaches only the keyword variables, which are not a sink.Checked: github/codeql main at b5b8165; not executed |
| Bandit | all plugins (default profile)Bandit 1.9.4 | No findings in any implementation. Bandit has no plugin for XPath injection, so its silence says nothing about this weakness.Checked: Bandit 1.9.4 (pip install bandit==1.9.4 in a scratch venv, Python 3.14.8); bandit -r . -x ./.venv,./tests |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| lxml (etree, XPath variables) | 6.1.3 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xpath-injection/python-flaskfolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs xpath-injection/python-flask runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- pip 26.2.1
- flask 3.1.3
- lxml 6.1.3
- pytest 9.1.1
- Matrix
- fixed: 7 of 7 tests pass; vulnerable: 5 of 7 tests fail (4 exploit tests); wrong: 2 of 7 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 7/7 pass; IMPL=vulnerable 5/7 fail (4 exploit: both tautologies and both ssn-union payloads; plus the apostrophe functional test, which an unbalanced quote breaks); IMPL=wrong 2/7 fail (2 exploit: the role-union ssn leak and the role tautology, because role is still concatenated).
- Dependency freshness
- flask 3.1.3 (2026-02-19), lxml 6.1.3 (2026-09-02) and pytest 9.1.1 (2026-06-19) are the latest stable releases (lxml 7.0.0 is still in alpha/beta). lxml variable binding (doc.xpath(expr, n=...)) was verified against lxml 6.1.3. Python 3.14.8 is the 30 September 2026 security release.
- Scanners
- Semgrep 1.180.0 in the pinned semgrep/semgrep image and Bandit 1.9.4 from a scratch venv, run (see scannerRules); CodeQL read from github/codeql main at b5b8165, not executed.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- CWE-643: Improper Neutralization of Data within XPath Expressions ('XPath Injection')
- OWASP XPath Injection
- lxml: XPath and XSLT (variables)
- CAPEC-83: XPath Injection
- OWASP ASVS 5.0.0 V1 Encoding and Sanitization (1.2.7 XPath injection)
Attack patterns: