Parameterize the XPath. Compile the query with $username and $password variables and supply their values through an XPathVariableResolver, so a value such as ' or '1'='1 is compared as a literal and can never become query structure. Bind every field, including the directory search term; concatenating even one field leaves the injection in place.
- Find every XPath built from request data: login, directory search, filters, any lookup against an XML document.
- Compile the expression with named variables ($username, $password, $name) and set an XPathVariableResolver on the XPath that returns their values by local name.
- Pass the values only through the resolver; never concatenate a request value into the expression string.
- Do the same for every field and every query; the wrong implementation here binds the username but concatenates the password, and the bypass simply moves there.
- Keep the authentication decision in code: match the user by a bound username, then verify the password against a stored verifier, so a matching-node trick cannot stand in for a correct password.
- Add regression tests that inject ' or '1'='1 (and a variant that neutralizes a trailing clause) into each field, and a wildcard into the search.
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
@Override
public String authenticate(String username, String password) {
try {
XPath xpath = XPathFactory.newInstance().newXPath();
String role = (String) xpath.evaluate(
"string(/users/user[username='" + username + "' and password='" + password + "']/role)",
users, XPathConstants.STRING);
return role == null || role.isEmpty() ? null : role;
} catch (XPathExpressionException e) {
throw new IllegalStateException(e);
}
}
@Override
public List<String> search(String name) {
try {
XPath xpath = XPathFactory.newInstance().newXPath();
NodeList nodes = (NodeList) xpath.evaluate(
"/users/user[contains(username, '" + name + "')]/username", users, XPathConstants.NODESET);
List<String> usernames = new ArrayList<>();
for (int i = 0; i < nodes.getLength(); i++) {
usernames.add(nodes.item(i).getTextContent());
}
return usernames;
} catch (XPathExpressionException e) {
throw new IllegalStateException(e);
}
}With IMPL=vulnerable, 3 of 7 tests fail (3 exploit tests).
The fix
@Override
public String authenticate(String username, String password) {
try {
XPath xpath = xpath(Map.of("username", username, "password", password));
String role = (String) xpath.evaluate(
"string(/users/user[username=$username and password=$password]/role)",
users, XPathConstants.STRING);
return role == null || role.isEmpty() ? null : role;
} catch (XPathExpressionException e) {
throw new IllegalStateException(e);
}
}
@Override
public List<String> search(String name) {
try {
XPath xpath = xpath(Map.of("name", name));
NodeList nodes = (NodeList) xpath.evaluate(
"/users/user[contains(username, $name)]/username", users, XPathConstants.NODESET);
List<String> usernames = new ArrayList<>();
for (int i = 0; i < nodes.getLength(); i++) {
usernames.add(nodes.item(i).getTextContent());
}
return usernames;
} catch (XPathExpressionException e) {
throw new IllegalStateException(e);
}
}With IMPL=fixed, 7 of 7 tests pass.
A fix that looks safe but is not
@Override
public String authenticate(String username, String password) {
try {
XPath xpath = XPathFactory.newInstance().newXPath();
xpath.setXPathVariableResolver(name -> "username".equals(name.getLocalPart()) ? username : null);
// The username is a bound variable, but the password is pasted in: still injectable.
String role = (String) xpath.evaluate(
"string(/users/user[username=$username and password='" + password + "']/role)",
users, XPathConstants.STRING);
return role == null || role.isEmpty() ? null : role;
} catch (XPathExpressionException e) {
throw new IllegalStateException(e);
}
}
@Override
public List<String> search(String name) {
try {
XPath xpath = XPathFactory.newInstance().newXPath();
xpath.setXPathVariableResolver(n -> Map.of("name", name).get(n.getLocalPart()));
NodeList nodes = (NodeList) xpath.evaluate(
"/users/user[contains(username, $name)]/username", users, XPathConstants.NODESET);
List<String> usernames = new ArrayList<>();
for (int i = 0; i < nodes.getLength(); i++) {
usernames.add(nodes.item(i).getTextContent());
}
return usernames;
} catch (XPathExpressionException e) {
throw new IllegalStateException(e);
}
}With IMPL=wrong, 1 of 7 tests fail (1 exploit test).
Routes: both inputs reach XPath
// Login authenticates against the XML store; the directory search lists matching usernames.
// Both inputs reach XPath, so both must be bound as variables, never concatenated.
@PostMapping("/login")
ResponseEntity<?> login(@RequestBody LoginRequest request) {
String role = directory.authenticate(
request.username() == null ? "" : request.username(),
request.password() == null ? "" : request.password());
return role == null
? ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "invalid credentials"))
: ResponseEntity.ok(Map.of("role", role));
}
@GetMapping("/directory")
Map<String, List<String>> directory(@RequestParam(defaultValue = "") String name) {
return Map.of("usernames", directory.search(name));
}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.
@Test
void exploitInjectionInTheUsernameDoesNotBypassLogin() throws Exception {
// Neutralizes the trailing password clause, so username injection alone would bypass.
MvcResult result = login("' or '1'='1' or 'x'='", "anything");
assertThat(result.getResponse().getStatus()).as("body %s", body(result)).isEqualTo(401);
assertThat(body(result)).doesNotContain("member").doesNotContain("staff");
}
@Test
void exploitInjectionInThePasswordDoesNotBypassLogin() throws Exception {
// The classic payload in the last field: pass='' or '1'='1' matches every user.
MvcResult result = login("alice", "' or '1'='1");
assertThat(result.getResponse().getStatus()).as("body %s", body(result)).isEqualTo(401);
}
@Test
void exploitInjectionInTheDirectorySearchDoesNotListEveryone() throws Exception {
String result = search("') or ('1'='1");
// No username contains that literal, so a parameterized search returns nobody.
assertThat(result).doesNotContain("alice").doesNotContain("bob").doesNotContain("sam");
}- XPath 1.0 has no way to escape a quote inside a string literal, so escaping or doubling quotes does not neutralize a concatenated value; variables are the only reliable control.
- Binding one field and concatenating another: the username is parameterized after a review but the password is still built into the string, so ' or '1'='1 in the password bypasses login. That is this card's wrong implementation.
- Injecting into the last field of an and-chain bypasses with a simple ' or '1'='1; injecting into an earlier field needs a payload that neutralizes the trailing clause. Both are blocked by variables.
- A denylist of characters or keywords is bypassed with functions, encodings or structure; parameterize instead.
- If the XML itself is untrusted, disable DTDs and external entities on the parser (this card does) so you are not trading XPath injection for XXE.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs xpath-injection/java-spring does this for you).
- The exploit tests inject into the username, into the password, and into the directory search; each must be refused or return only true matches. The functional tests keep correct credentials and an exact-name search working.
- Against your own endpoint, inject an XPath tautology in every input that reaches a query, including secondary search and filter fields.
- Static analysis helps: CodeQL's java/xml/xpath-injection models the sink. Semgrep CE's file-local taint does not follow the controller into the directory, so it reported nothing here; the regression tests are the control, especially for the wrong implementation where one field is bound and another is not.
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/java | 0 findings. The request parameter reaches the XPath only across the controller -> directory boundary, which Semgrep CE's file-local taint does not follow; the pack has no syntactic XPath-concatenation rule that fires here.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (7 Java files scanned) |
| CodeQL | java/xml/xpath-injection | Models a string built from remote input and passed to XPath.compile/evaluate as an XPath-injection sink, with @RequestBody/@RequestParam as sources, so it is expected to flag the vulnerable concatenation and the wrong implementation's concatenated password, and not the fixed implementation, which passes values through an XPathVariableResolver.Checked: github/codeql main; not executed |
| Find Security Bugs (SpotBugs plugin) | XPATH_INJECTIONPotential XPath Injection | Not run on this card.Checked: Bug pattern listed on the Find Security Bugs site; not executed |
The semgrep-rules registry has no Java XPath-injection rule that fires on the controller-to-directory flow, so the regression tests, which drive injection payloads in each field, are the control.
| Component | Version |
|---|---|
| Language | Java |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (javax.xml.xpath) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| javax.xml.xpath (JDK built in) | OpenJDK 21.0.12.1 |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xpath-injection/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs xpath-injection/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- java OpenJDK 21.0.12.1
- maven 3.9.16
- springBoot 4.1.1
- junitJupiter 6.0.3
- Matrix
- fixed: 7 of 7 tests pass; vulnerable: 3 of 7 tests fail (3 exploit tests); wrong: 1 of 7 tests fail (1 exploit test)
- Test results
- IMPL=fixed 7/7 pass; IMPL=vulnerable 3/7 fail (all three exploit tests: username injection, password injection and a wildcard directory search); IMPL=wrong 1/7 fail (1 exploit: the username is bound as a variable but the password is concatenated, so injection in the password bypasses login).
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.