Software Securityhome
Fix a findingSearch
Fix card · A02:2025 Security Misconfiguration

Fix XML external entity processing 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

On .NET, parse untrusted XML with XmlReaderSettings.DtdProcessing = Prohibit and XmlResolver = null (both the modern defaults), so any DOCTYPE throws before an external entity is read (CWE-611) or entities expand (CWE-776). Legacy code that opted back in with DtdProcessing.Parse and an XmlUrlResolver is the bug. Do not blocklist '<!DOCTYPE' bytes; a UTF-16 copy evades it.

On this page

Steps

  1. Find every XmlReader, XmlReaderSettings, XmlDocument, XmlTextReader, XPathDocument, XmlSerializer, DataSet and LINQ-to-XML load that reads request data.
  2. Build an XmlReaderSettings with DtdProcessing = DtdProcessing.Prohibit and XmlResolver = null, and pass it to XmlReader.Create; load XmlDocument/XDocument from that reader, never from the raw bytes.
  3. On modern .NET these are already the defaults; set them explicitly anyway so intent is clear and a later edit cannot re-enable DTDs. Delete any DtdProcessing.Parse or new XmlUrlResolver() left over from a .NET Framework migration.
  4. If a DTD is genuinely needed, keep DtdProcessing.Ignore or a bounded MaxCharactersFromEntities/MaxCharactersInDocument and still use a null resolver; never attach a live XmlResolver to untrusted input.
  5. Catch XmlException and return a short 400; do not echo the parser message, and keep the developer exception page off in production.
  6. Add regression tests with this card's payloads (an external file entity and a small bounded expansion, each as UTF-8 and UTF-16) and cap the request body size.

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

Vulnerablesrc/Import/impl/VulnerableContactParser.csC#
// Opt back in to the unsafe behaviour (as legacy code migrated from
// older frameworks often does): parse DTDs and hand over a URL resolver.
var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Parse,
    XmlResolver = new XmlUrlResolver(),
};

With IMPL=vulnerable, 4 of 9 tests fail (4 exploit tests).

The fix: refuse the DTD

Fixedsrc/Import/impl/FixedContactParser.csC#
var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,   // any DOCTYPE is an error
    XmlResolver = null,                        // nothing to fetch with
    MaxCharactersFromEntities = 1024,          // bound expansion as well
};

With IMPL=fixed, 9 of 9 tests pass.

The fix: parse untrusted bytes

Fixedsrc/Import/impl/FixedContactParser.csC#
try
{
    using var stream = new MemoryStream(xml);
    using var reader = XmlReader.Create(stream, settings);
    var doc = new XmlDocument();
    doc.Load(reader);
    return Dom.Extract(doc);
}
catch (Exception e) when (e is XmlException or IOException)
{
    // A DOCTYPE lands here ("DTD is prohibited"); do not echo the text.
    throw new BadXmlException("XML document type definitions are not allowed", e);
}

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fixsrc/Import/impl/WrongContactParser.csC#
private static bool LooksLikeDtd(byte[] xml)
{
    // Latin1 keeps every byte as a character, so this is a byte-level search;
    // OrdinalIgnoreCase only makes it feel thorough. UTF-16 bytes decode to
    // interleaved NULs here, so the ASCII markers below never match.
    var text = Encoding.Latin1.GetString(xml);
    return text.Contains("<!DOCTYPE", StringComparison.OrdinalIgnoreCase)
        || text.Contains("<!ENTITY", StringComparison.OrdinalIgnoreCase);
}

With IMPL=wrong, 2 of 9 tests fail (2 exploit tests).

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/Import.Tests/ExploitTests.csC#
/// <summary>Exploit: each payload writes a marker file in the test's own temp
/// directory and points an external entity at it, or expands a small bounded
/// entity. The attack must leave no trace in the response and be refused.</summary>
public sealed class ExploitTests : ImportApiTests
{
    public ExploitTests(ImportApiFactory factory) : base(factory) { }

    private async Task AssertRefused(byte[] payload, string needle)
    {
        using var response = await PostImport(payload);
        var body = await ReadBody(response);
        Assert.False(body.Contains(needle, StringComparison.Ordinal),
            "XXE leak: the attack output appeared in the response");
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }

    private static async Task WithMarkerFile(Func<string, string, Task> body)
    {
        var path = Path.Combine(Path.GetTempPath(), $"xxe-marker-{Guid.NewGuid():N}.txt");
        var text = "XXE-FILEMARKER-" + Guid.NewGuid().ToString("N");
        await File.WriteAllTextAsync(path, text, TestContext.Current.CancellationToken);
        try
        {
            await body(new Uri(path).AbsoluteUri, text);
        }
        finally
        {
            File.Delete(path);
        }
    }

    [Fact]
    public Task ExternalEntityFileUtf8() =>
        WithMarkerFile((uri, text) => AssertRefused(Utf8(ExtFile(uri)), text));

    [Fact]
    public Task ExternalEntityFileUtf16() =>
        WithMarkerFile((uri, text) => AssertRefused(Utf16(ExtFile(uri)), text));

    [Fact]
    public Task EntityExpansionUtf8() => AssertRefused(Utf8(Expansion), ExpansionMarker);

    [Fact]
    public Task EntityExpansionUtf16() => AssertRefused(Utf16(Expansion), ExpansionMarker);
}

Pitfalls

  • Modern .NET (4.5.2+ and all of .NET 5-10) is safe by default: XmlReaderSettings.DtdProcessing is Prohibit and XmlResolver is null. The vulnerability is almost always legacy code that opted back in with DtdProcessing.Parse and a resolver.
  • DtdProcessing has three values: Prohibit (throw on DOCTYPE, the safe choice), Ignore (skip it silently), and Parse (process it). Parse plus a non-null XmlResolver is the hole; Ignore is safe-ish but Prohibit is clearer.
  • A byte or string blocklist for '<!DOCTYPE'/'<!ENTITY' is bypassed by sending the same document as UTF-16 (or UTF-7); the ASCII bytes are absent but XmlReader honours the BOM/encoding and reads the DTD. Reject at the reader.
  • Older XmlTextReader and some XmlDocument/DataSet paths default to a live resolver on legacy frameworks; set XmlResolver = null on those objects too, not just on XmlReaderSettings.
  • Entity expansion (CWE-776) is bounded by MaxCharactersFromEntities, but the default is 0 (no limit); Prohibit removes the need, otherwise set a cap.
  • CA3075 and Semgrep CE did not flag this opt-in on .NET 10 (verified), so neither a clean analyzer run nor a clean Semgrep scan proves the parser is safe. Read the DtdProcessing/XmlResolver values and run the tests.
  • XmlSerializer and DataContractSerializer read a DTD through their internal XmlReader; feed them a reader you configured, not a Stream or TextReader.

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 csharp-aspnetcore does this for you).
  2. Each exploit posts a DOCTYPE document (as UTF-8 and UTF-16) whose external entity reads a marker file the test wrote in its own temp dir, or expands a small bounded entity, and asserts the marker/expansion never appears and the request is refused with 400.
  3. Scanners are no help here: Semgrep CE p/csharp and the .NET CA3075 analyzer both reported nothing on the vulnerable code, so rely on reading DtdProcessing/XmlResolver and on the regression suite; CodeQL cs/xml/insecure-dtd-handling models the pattern if you run it.

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 XML external entity processing in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepp/csharp0 findings on all three implementations. Semgrep CE has no C# rule that models XmlReaderSettings.DtdProcessing = Parse with a non-null XmlResolver, so the vulnerable opt-in is invisible to it (as with the deserialization C# card). The regression tests are the control.Checked: Semgrep 1.180.0 (CE); semgrep scan --config p/csharp src/Import (Ran 27 rules on 8 files)
CodeQLcs/xml/insecure-dtd-handlingPath-problem query (@id cs/xml/insecure-dtd-handling, tags CWE-611/827/776). Its model flags untrusted XML read with DtdProcessing.Parse and a non-null/insecure XmlResolver, which is exactly the vulnerable and wrong parsers here, and treats DtdProcessing.Prohibit + null resolver (the fixed parser) as safe. Expected to flag vulnerable and wrong and clear fixed.Checked: github/codeql main; not executed
.NET analyzer (Microsoft.CodeAnalysis.NetAnalyzers)CA3075Insecure DTD processing in XMLCA3075 did NOT fire on the vulnerable parser (DtdProcessing.Parse + new XmlUrlResolver() flowing into XmlReader.Create and XmlDocument.Load), confirmed on an isolated probe too. On .NET 10 the built-in XXE analyzer does not match this XmlReaderSettings opt-in, so a clean build is not proof. The regression tests catch it.Checked: .NET SDK 10.0.107; dotnet build -p:EnableNETAnalyzers=true -p:AnalysisMode=All (24 CA warnings, none CA30xx)

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC# 14
Runtime.NET 10.0.12 (SDK 10.0.112)
FrameworkASP.NET Core 10 minimal API
System.Xml (XmlReader/XmlReaderSettings, in .NET 10)10.0.12
Microsoft.AspNetCore.Mvc.Testing10.0.12
xunit.v34.0.2 (Microsoft Testing Platform v2)

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xxe/csharp-aspnetcorefolder.

Shell
dotnet restore tests/Import.Tests --locked-mode
Shell
# Expected: every test passes
IMPL=fixed dotnet test --project tests/Import.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/Import.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/Import.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 xxe/csharp-aspnetcore runs all three modes and checks the expected results.

Download the project ZIP, 25 KB

Verified

Toolchain
  • dotnetSdk 10.0.112
  • dotnetRuntime 10.0.12
  • aspnetcore 10.0.12 (Mvc.Testing)
  • xunitV3 4.0.2
Matrix
fixed: 9 of 9 tests pass; vulnerable: 4 of 9 tests fail (4 exploit tests); wrong: 2 of 9 tests fail (2 exploit tests)
Test results
IMPL=fixed 9/9 pass; IMPL=vulnerable 4 exploit tests fail (both file-read markers leak via DtdProcessing.Parse + XmlUrlResolver, both expansions return 128 bytes); IMPL=wrong 2 exploit tests fail (the UTF-16 file-read and UTF-16 expansion slip past the Latin1 '<!DOCTYPE' blocklist; the UTF-8 pair is blocked).
Scanners
Semgrep 1.180.0 from a throwaway venv (../.tools/venv); CA3075 from the .NET 10 SDK analyzers.
Notes
Microsoft.AspNetCore.Mvc.Testing 10.0.12 and xunit.v3 4.0.2 are current and >14 days old. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch. Versions and results are true for this date and machine.

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 XML External Entity Prevention Cheat Sheet (.NET section)
  2. .NET API: XmlReaderSettings.DtdProcessing and DtdProcessing enum
  3. .NET API: XmlReaderSettings.XmlResolver
  4. CA3075: Insecure DTD processing in XML
  5. CWE-611: Improper Restriction of XML External Entity Reference
  6. CWE-776: Improper Restriction of Recursive Entity References in DTDs ('XML Entity Expansion')
  7. CAPEC-221: Data Serialization External Entities Blowup

Attack patterns:

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.