Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions libraries/MTConnect.NET-XML/XsdPreprocessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ public static class XsdPreprocessor
{
private const string XsdNamespace = "http://www.w3.org/2001/XMLSchema";

/// <summary>
/// Upper bound on the size of an XSD source the preprocessor accepts, in UTF-16 characters
/// (roughly bytes for ASCII XSDs). MTConnect XSDs ship well under 500 KB; the 10 MB gate
/// keeps a bounded ceiling on parser memory and short-circuits XML entity-expansion attacks
/// before the underlying reader allocates. Exposed as a constant so the pinning tests can
/// straddle the boundary without a magic number.
/// </summary>
public const int MaxSourceCharacters = 10_000_000;

/// <summary>
/// Returns a copy of <paramref name="xsdSourceXml"/> with every XSD
/// 1.1-only construct removed. Idempotent: re-running on the
Expand All @@ -51,23 +60,55 @@ public static class XsdPreprocessor
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="xsdSourceXml"/> is null.
/// </exception>
/// <exception cref="System.Xml.XmlException">
/// Thrown when <paramref name="xsdSourceXml"/> exceeds
/// <see cref="MaxSourceCharacters"/> — the guard rail against XML entity-expansion attacks.
/// </exception>
public static string StripXsd11Constructs(string xsdSourceXml)
{
if (xsdSourceXml == null) throw new ArgumentNullException(nameof(xsdSourceXml));
if (xsdSourceXml.Length == 0) return xsdSourceXml;

// Fail loud before parsing: a caller who lobbed a > 10 MB payload at the preprocessor
// has almost certainly hit an entity-expansion attack (billion-laughs / quadratic-
// blowup) or a runaway build-side generator. Silently returning the raw source would
// hand the payload down to XmlSchema.Read, which lacks a matching size cap.
if (xsdSourceXml.Length > MaxSourceCharacters)
{
throw new System.Xml.XmlException(
$"XSD source exceeds the {MaxSourceCharacters:N0}-character preprocessor limit; " +
"refusing to parse to avoid unbounded resource consumption.");
}

XDocument doc;
try
{
using (var reader = new StringReader(xsdSourceXml))
// XDocument.Load(TextReader, LoadOptions) uses default XmlReaderSettings, which
// in .NET 4.0+ disables DTD processing by default but leaves DocumentDoS-shape
// limits at their permissive defaults (MaxCharactersInDocument = 0 → unbounded,
// MaxCharactersFromEntities = 0 → unbounded). Route through an explicitly-tightened
// XmlReader so the loader rejects DTDs, refuses to resolve external entities, and
// caps the document at 10 MB / entity expansion at 0 characters to shut down the
// classic billion-laughs and quadratic-blowup XML entity attacks. The XSD source
// is untrusted — an operator can point the preprocessor at any XSD URL — so the
// hardening runs at every entry.
var settings = new System.Xml.XmlReaderSettings
{
DtdProcessing = System.Xml.DtdProcessing.Prohibit,
XmlResolver = null,
MaxCharactersInDocument = 10_000_000,
MaxCharactersFromEntities = 0,
};
using (var stringReader = new StringReader(xsdSourceXml))
using (var xmlReader = System.Xml.XmlReader.Create(stringReader, settings))
{
doc = XDocument.Load(reader, LoadOptions.PreserveWhitespace);
doc = XDocument.Load(xmlReader, LoadOptions.PreserveWhitespace);
}
}
catch (System.Xml.XmlException)
{
// Not well-formed XML — let the downstream BCL reader emit
// the parse error so the caller's error path stays
// Not well-formed XML, or exceeds the DTD/size guard rails above — let the
// downstream BCL reader emit the parse error so the caller's error path stays
// consistent.
return xsdSourceXml;
}
Expand Down
99 changes: 99 additions & 0 deletions tests/MTConnect.NET-XML-Tests/Xml/XsdPreprocessorSecurityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
// TrakHound Inc. licenses this file to you under the MIT license.

using System.Text;
using System.Xml;
using NUnit.Framework;

namespace MTConnect.Xml.Tests
{
/// <summary>
/// Pins the DoS-hardening guard rails on <see cref="XsdPreprocessor.StripXsd11Constructs(string)"/>.
/// The preprocessor loads untrusted XSD text; without the hardening a hostile source can trigger
/// the classic XML entity-expansion attacks (billion-laughs, quadratic blowup) or exhaust memory
/// through a runaway payload.
/// </summary>
[TestFixture]
[Category("XsdPreprocessorSecurity")]
public class XsdPreprocessorSecurityTests
{
/// <summary>Pins that a source exceeding the character limit raises <see cref="XmlException"/> instead of allocating an unbounded XmlReader.</summary>
[Test]
public void StripXsd11Constructs_OversizedInput_RaisesXmlException()
{
// Cheap oversized payload: a single well-formed root element with padding pushing the
// total length just past the guard. The padding lives inside the element so the XML is
// still parseable — the size gate must fire before the parser sees it.
var padding = new string('a', XsdPreprocessor.MaxSourceCharacters);
var oversized =
"<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">"
+ $"<xs:annotation><xs:documentation>{padding}</xs:documentation></xs:annotation>"
+ "</xs:schema>";

Assert.That(oversized.Length, Is.GreaterThan(XsdPreprocessor.MaxSourceCharacters),
"the payload must straddle the size gate for the test to be meaningful");
Assert.Throws<XmlException>(() => XsdPreprocessor.StripXsd11Constructs(oversized));
}

/// <summary>Pins that a source referencing an external DTD is rejected — the hardened reader has DtdProcessing set to Prohibit.</summary>
[Test]
public void StripXsd11Constructs_ExternalDtdReference_ReturnsSource_Unprocessed()
{
// A well-formed XML with a DOCTYPE declaration. The hardened reader must refuse to
// process the DTD; the preprocessor catches the resulting XmlException and returns the
// raw source (existing not-well-formed contract).
const string withDtd =
"<?xml version=\"1.0\"?>"
+ "<!DOCTYPE xs:schema SYSTEM \"http://example.invalid/malicious.dtd\">"
+ "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"/>";

var result = XsdPreprocessor.StripXsd11Constructs(withDtd);

// Not-well-formed / DTD-prohibited inputs land in the catch and return the raw source
// for the downstream schema reader to error out consistently.
Assert.That(result, Is.EqualTo(withDtd));
}

/// <summary>Pins that a source with an internal DTD entity expansion is rejected — the hardened reader has DtdProcessing set to Prohibit, so the entity is never expanded.</summary>
[Test]
public void StripXsd11Constructs_InternalDtdEntityExpansion_ReturnsSource_Unprocessed()
{
// Simplified billion-laughs shape: three levels of entity expansion. Any bytes-in-entities
// pass would blow up the parser; the DTD prohibition means the parser never even sees
// the definitions.
var sb = new StringBuilder();
sb.Append("<?xml version=\"1.0\"?>");
sb.Append("<!DOCTYPE xs:schema [");
sb.Append("<!ENTITY a \"aaaaaaaaaaaaaaaaaaaaaaaa\">");
sb.Append("<!ENTITY b \"&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;\">");
sb.Append("<!ENTITY c \"&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;\">");
sb.Append("]>");
sb.Append("<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"><xs:element name=\"x\">&c;</xs:element></xs:schema>");
var laugh = sb.ToString();

var result = XsdPreprocessor.StripXsd11Constructs(laugh);

Assert.That(result, Is.EqualTo(laugh),
"the DTD-prohibited reader must refuse the entity-expansion payload and the " +
"preprocessor must return the raw source unprocessed");
}

/// <summary>Pins that a well-formed, small XSD still round-trips through the hardened loader.</summary>
[Test]
public void StripXsd11Constructs_SmallWellFormedXsd_RoundTripsThroughHardenedLoader()
{
// Regression guard: the hardening MUST NOT break the happy path for the shipped
// MTConnect XSDs, which are well under the size cap and carry no DTD.
const string xsd =
"<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" targetNamespace=\"urn:test\">"
+ "<xs:element name=\"Root\" type=\"xs:string\"/>"
+ "</xs:schema>";

var result = XsdPreprocessor.StripXsd11Constructs(xsd);

Assert.That(result, Is.Not.Null.And.Not.Empty);
Assert.That(result, Does.Contain("<xs:element"),
"the round-trip must preserve the schema's structural elements");
}
}
}