Parameterize the query: compile the XPath with $username and $password variables and supply them through a custom XsltContext (ResolveVariable returning an IXsltContextVariable), attached with XPathExpression.SetContext. The engine resolves the variables, so ' or '1'='1 is matched as a literal and never becomes query logic. Parameterize every field, not just one.
- List every XPath built from input: login, directory search, filters, counts. Note where each value comes from.
- Write the expression with named variables: /users/user[username=$username and password=$password]. Do not concatenate values into the string.
- Implement an XsltContext whose ResolveVariable returns an IXsltContextVariable for each name (VariableType XPathResultType.String, Evaluate returning the bound value), and attach it with XPathExpression.SetContext before XPathNavigator.Select.
- Bind every field this way, including the directory search term; parameterizing one field and concatenating another leaves the injection in place.
- Decide authentication in code: select the user by a bound username, then compare the password against a stored verifier in constant time, rather than trusting a matching node.
- Add regression tests that send ' or '1'='1 in the username, in the password and in the search term, and assert login is refused and the search returns only true matches.
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
public string? Login(string username, string password)
{
XPathNodeIterator users = _nav.Select(
"/users/user[username='" + username + "' and password='" + password + "']");
return users.MoveNext() ? users.Current!.SelectSingleNode("role")?.Value : null;
}
public IReadOnlyList<string> Search(string name)
{
XPathNodeIterator it = _nav.Select("/users/user[username='" + name + "']/username");
var names = new List<string>();
while (it.MoveNext()) names.Add(it.Current!.Value);
return names;
}With IMPL=vulnerable, 3 of 6 tests fail (3 exploit tests).
The fix
public string? Login(string username, string password)
{
XPathExpression expr = _nav.Compile("/users/user[username=$username and password=$password]");
expr.SetContext(new XPathVariableContext().Set("username", username).Set("password", password));
XPathNodeIterator users = _nav.Select(expr);
return users.MoveNext() ? users.Current!.SelectSingleNode("role")?.Value : null;
}
public IReadOnlyList<string> Search(string name)
{
XPathExpression expr = _nav.Compile("/users/user[username=$name]/username");
expr.SetContext(new XPathVariableContext().Set("name", name));
return Usernames(_nav.Select(expr));
}With IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
public string? Login(string username, string password)
{
// Username parameterized; password still concatenated.
XPathExpression expr = _nav.Compile(
"/users/user[username=$username and password='" + password + "']");
expr.SetContext(new XPathVariableContext().Set("username", username));
XPathNodeIterator users = _nav.Select(expr);
return users.MoveNext() ? users.Current!.SelectSingleNode("role")?.Value : null;
}
public IReadOnlyList<string> Search(string name)
{
// The search was parameterized in the same review.
XPathExpression expr = _nav.Compile("/users/user[username=$name]/username");
expr.SetContext(new XPathVariableContext().Set("name", name));
XPathNodeIterator it = _nav.Select(expr);
var names = new List<string>();
while (it.MoveNext()) names.Add(it.Current!.Value);
return names;
}With IMPL=wrong, 1 of 6 tests fail (1 exploit test).
Route: login and search against the XML directory
// /login authenticates against the XML directory; /directory searches it. The directory decides
// matches with XPath, so the username, password and search term must be bound as variables, never
// concatenated into the query.
app.MapPost("/login", (LoginRequest body, IUserDirectory dir) =>
dir.Login(body.Username ?? "", body.Password ?? "") is { } role
? Results.Ok(new { role })
: Results.Json(new { error = "invalid credentials" }, statusCode: 401));
app.MapGet("/directory", (string name, IUserDirectory dir) => Results.Ok(new { users = dir.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.
public sealed class ExploitTests(DirectoryApiFactory factory) : DirectoryApiTests(factory)
{
private const string Tautology = "' or '1'='1";
// In the username field the trailing "and password=..." clause must be neutralized too, so the
// tautology is repeated to the right of the and-bound clause.
private const string UsernameTautology = "x' or '1'='1' or 'x'='x";
[Fact]
public async Task InjectionInTheUsernameDoesNotBypassLogin()
{
var (status, body) = await Login(UsernameTautology, "anything");
Assert.True(status == 401, $"username injection logged in: {body}");
}
[Fact]
public async Task InjectionInThePasswordDoesNotBypassLogin()
{
var (status, body) = await Login("alice", Tautology);
Assert.True(status == 401, $"password injection logged in: {body}");
}
[Fact]
public async Task DirectoryInjectionReturnsOnlyTrueMatches()
{
var users = JsonDocument.Parse(await Directory(Tautology)).RootElement.GetProperty("users");
Assert.True(users.GetArrayLength() == 0, $"directory injection returned {users.GetArrayLength()} users");
}
}- Parameterizing the username but still concatenating the password (or a second search field). The tautology just moves to the concatenated field. That is this card's wrong implementation.
- Trying to escape quotes: XPath 1.0 has no escape sequence inside a string literal, so doubling or backslashing a quote does not neutralize it. Use variables.
- Treating a matching node as a correct password. An injected tautology makes the login query match; verify the credential in code after selecting by a bound key.
- Building the expression once as a string and only then calling SetContext: the variables must appear as $name in the compiled expression, not be substituted into it.
- Where the data is relational, XML and XPath may not be needed at all; a database with parameterized queries removes this class.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs xpath-injection/csharp-aspnetcore does this for you).
- Against your own endpoints, send ' or '1'='1 (and a username variant that neutralizes the trailing clause) in each field, and confirm login is refused and the directory returns only exact matches.
- Semgrep's C# XPath rule flags the concatenated queries and leaves the variable-bound one alone; CodeQL's cs/xml/xpath-injection models the same sink. The wrong implementation (username parameterized, password concatenated) still needs the regression test, which the password injection drives.
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 | csharp.dotnet.security.audit.xpath-injection.xpath-injection | 2 findings: the concatenated XPath in VulnerableUserDirectory and in WrongUserDirectory (the still-concatenated password). 0 findings on FixedUserDirectory, where the values are bound as XPath variables through an XsltContext.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (src only, 0 parse errors) |
| CodeQL | cs/xml/xpath-injection | Models request input reaching XPathNavigator.Select/Evaluate/Compile as an XPath-injection sink, so it is expected to flag the vulnerable and wrong directories. The fixed directory passes values through XPath variables, which the query does not treat as tainting the expression.Checked: github/codeql main; not executed |
Semgrep's C# XPath rule flags the concatenated queries in the vulnerable and wrong directories and leaves the variable-bound fixed one alone; it is the same rule that would miss the wrong directory's parameterized username, so the password concatenation is what it reports there. The regression tests remain the control for the one-field-fixed wrong case.
| Component | Version |
|---|---|
| Language | C# |
| Runtime | ASP.NET Core 10 minimal API on .NET 10.0.12 |
| Framework | ASP.NET Core 10 (minimal API); System.Xml.XPath |
| System.Xml.XPath, System.Xml.Xsl (built in) | .NET 10.0.12 |
| Microsoft.AspNetCore.Mvc.Testing (test only) | 10.0.12 |
| xunit.v3 (test only) | 4.0.2 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xpath-injection/csharp-aspnetcorefolder.
dotnet restore tests/Directory.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Directory.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Directory.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Directory.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs xpath-injection/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- dotnetSdk 10.0.112
- aspNetCoreRuntime 10.0.12
- xunitV3 4.0.2
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 3 of 6 tests fail (3 exploit tests); wrong: 1 of 6 tests fail (1 exploit test)
- Test results
- IMPL=fixed 6/6 pass; IMPL=vulnerable 3/6 fail (all exploit: username injection, password injection and the directory tautology); IMPL=wrong 1/6 fail (1 exploit: the password field is still concatenated, so ' or '1'='1 in the password bypasses login; the parameterized username and search hold).
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.