Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix XPath injection in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language C#
  • Runtime ASP.NET Core 10 minimal API on .NET 10.0.12
  • Framework ASP.NET Core 10 (minimal API); System.Xml.XPath
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks2 cards
Download the project ZIP, 25 KB
On this page

Steps

  1. List every XPath built from input: login, directory search, filters, counts. Note where each value comes from.
  2. Write the expression with named variables: /users/user[username=$username and password=$password]. Do not concatenate values into the string.
  3. 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.
  4. Bind every field this way, including the directory search term; parameterizing one field and concatenating another leaves the injection in place.
  5. 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.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerable…/Directory/impl/VulnerableUserDirectory.csC#
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

Fixedsrc/Directory/impl/FixedUserDirectory.csC#
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

Wrong fixsrc/Directory/impl/WrongUserDirectory.csC#
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

src/Directory/Program.csC#
// /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 regression test

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.

Testtests/Directory.Tests/ExploitTests.csC#
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");
    }
}

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix XPath injection in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepcsharp.dotnet.security.audit.xpath-injection.xpath-injection2 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)
CodeQLcs/xml/xpath-injectionModels 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.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC#
RuntimeASP.NET Core 10 minimal API on .NET 10.0.12
FrameworkASP.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

Run the tests

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.

Shell
dotnet restore tests/Directory.Tests --locked-mode
Shell
# 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.xml

Each 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.

Download the project ZIP, 25 KB

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.

References

  1. OWASP Top 10:2025 A05 Injection
  2. CWE-643: Improper Neutralization of Data within XPath Expressions ('XPath Injection')
  3. OWASP XPath Injection
  4. Microsoft Learn: XsltContext Class (ResolveVariable)
  5. Microsoft Learn: IXsltContextVariable Interface
  6. Microsoft Learn: XPathExpression.SetContext

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.