Skip to content
Merged
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ Parser files:
- `Parser/XmlParser.cs`: parser facade and document creation.
- `Parser/XmlDomBuilder.cs`: tree construction, DOCTYPE handling, DTD validation, and entity expansion.
- `Parser/XmlTokenizer.cs`: XML lexical tokenizer and character/entity reference handling.
- `Parser/XmlParserOptions.cs`: suppress-errors, source-reference, and element-created callback options.
- `Parser/XmlParserOptions.cs`: suppress-errors, source-reference, element-created callback, entity expansion limit, and nesting depth limit options.
- `Parser/XmlParseError*.cs`: parse error definitions and exception mapping.
- `Parser/Tokens/`: token models for declarations, DOCTYPE, tags, text, CDATA, comments, processing instructions, and EOF.

Expand Down Expand Up @@ -245,6 +245,7 @@ The parser also supports streams, fragments, and asynchronous overloads. `XmlPar
- `IsKeepingSourceReferences`: keeps source token references on created elements for diagnostics/tooling.
- `OnCreated`: callback receiving each created element and its `TextPosition`.
- `MaxCharactersFromEntities`: cap on the characters produced by expanding DTD-declared entities (default `DefaultMaxCharactersFromEntities`, 10,000,000; 0 = no limit), counted in the tokenizer and in `XmlDomBuilder.ExpandInternalEntities` through `XmlTokenizer.ClaimEntityCharacters`. Any new entity expansion path must claim its characters there too.
- `MaximumDepth`: the deepest level the parser nests elements at (root = level 1; default `DefaultMaximumDepth`, 512, as core's `HtmlParserOptions.MaximumDepth`; zero or negative restores the default, `Int32.MaxValue` lifts it). Beyond it the parser throws `MaximumDepthExceeded`; with `IsSuppressingErrors` the element is attached at the limit (`XmlDomBuilder.GetInsertionParent`). Tests and benchmarks that parse deeper documents must lift it, as `XmlDeepNesting` does.

### Validity

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Released on Sunday, November 1 2026.
- Fixed unbounded expansion of nested entities ("billion laughs"), which let a small document exhaust memory; expansion now stops at `XmlParserOptions.MaxCharactersFromEntities`
- Added trimming and Native AOT compatibility (`IsAotCompatible`) for the .NET 8 and .NET 10 targets
- Added `XmlParserOptions.MaxCharactersFromEntities` (default 10,000,000, 0 for no limit), like `System.Xml`'s setting of the same name
- Added `XmlParserOptions.MaximumDepth` (default 512), like `HtmlParserOptions.MaximumDepth`: deeper elements throw, or are attached at the limit when errors are suppressed

# 1.2.0

Expand Down
13 changes: 13 additions & 0 deletions docs/tutorials/01-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ var parser = new XmlParser(new XmlParserOptions
});
```

### MaximumDepth

The deepest level at which the parser nests elements, counting the root element as level 1, like `HtmlParserOptions.MaximumDepth` in AngleSharp. It defaults to 512 (`XmlParserOptions.DefaultMaximumDepth`); zero or a negative value restores the default, and `Int32.MaxValue` lifts the limit. XML puts no limit on nesting, but code walking a tree that is thousands of levels deep recursively can overflow the call stack, which ends the process.

An element beyond the limit throws an `XmlParseException`. With `IsSuppressingErrors`, it is attached at the limit instead, as a sibling of the deepest element, as the HTML parser does; text that follows a closed sibling then stays in its own element.

```cs
var parser = new XmlParser(new XmlParserOptions
{
MaximumDepth = 2000,
});
```

### OnCreated

Callback invoked when an element has been created, including source position.
Expand Down
14 changes: 13 additions & 1 deletion src/AngleSharp.Xml.Benchmarks/ParseValidationBenchmark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ namespace AngleSharp.Xml.Benchmarks
[MemoryDiagnoser]
public class ParseValidationBenchmark
{
private readonly XmlParser parser = new XmlParser();
private readonly XmlParser parser = CreateParser();
private String source;
private String externalSubsetPath;

Expand Down Expand Up @@ -85,5 +85,17 @@ public void GlobalCleanup()

[Benchmark]
public Boolean Parse() => parser.ParseDocument(source).IsValid;

/// <summary>
/// A parser without a depth limit, so the deep shapes measure their full depth. Builds
/// with XmlParserOptions.MaximumDepth stop at 512 levels by default; older builds have
/// no limit and lack the option, so it is set when present.
/// </summary>
private static XmlParser CreateParser()
{
Object options = new XmlParserOptions();
typeof(XmlParserOptions).GetProperty("MaximumDepth")?.SetValue(options, Int32.MaxValue);
return new XmlParser((XmlParserOptions)options);
}
}
}
25 changes: 17 additions & 8 deletions src/AngleSharp.Xml.Tests/Parser/XmlDeepNesting.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
namespace AngleSharp.Xml.Tests.Parser
{
using AngleSharp.Xml.Dom;
using AngleSharp.Xml.Parser;
using NUnit.Framework;
using System;
using System.IO;
Expand All @@ -13,13 +15,14 @@ public class XmlDeepNesting
{
// Recursive tree walks overflowed from about 5000 levels on a 1 MB stack; building the tree
// itself is quadratic in depth (AngleSharp's AppendChild walks the ancestors), so going much
// deeper only makes the tests slow
// deeper only makes the tests slow. The parser stops at XmlParserOptions.MaximumDepth (512 by
// default), so these documents lift it to reach the walks.
private const Int32 Depth = 20000;

[Test]
public void DeeplyNestedDocumentWithoutDoctypeParses()
{
var document = Nest(String.Empty, "x").ToXmlDocument();
var document = ParseWithoutDepthLimit(Nest(String.Empty, "x"));

Assert.IsNotNull(document.DocumentElement);
Assert.IsTrue(document.IsValid);
Expand All @@ -28,15 +31,15 @@ public void DeeplyNestedDocumentWithoutDoctypeParses()
[Test]
public void DeeplyNestedDocumentIsValidAgainstInternalSubset()
{
var document = Nest("<!DOCTYPE a [<!ELEMENT a ANY>]>", "x").ToXmlDocument();
var document = ParseWithoutDepthLimit(Nest("<!DOCTYPE a [<!ELEMENT a ANY>]>", "x"));

Assert.IsTrue(document.IsValid);
}

[Test]
public void UndeclaredElementAtDeepestLevelIsInvalidAgainstInternalSubset()
{
var document = Nest("<!DOCTYPE a [<!ELEMENT a ANY>]>", "<b/>").ToXmlDocument();
var document = ParseWithoutDepthLimit(Nest("<!DOCTYPE a [<!ELEMENT a ANY>]>", "<b/>"));

Assert.IsFalse(document.IsValid);
}
Expand All @@ -45,7 +48,7 @@ public void UndeclaredElementAtDeepestLevelIsInvalidAgainstInternalSubset()
public void DuplicateIdAtDeepestLevelIsInvalid()
{
var doctype = "<!DOCTYPE a [<!ELEMENT a ANY><!ELEMENT b EMPTY><!ATTLIST b id ID #IMPLIED>]>";
var document = Nest(doctype, "<b id=\"one\"/><b id=\"one\"/>").ToXmlDocument();
var document = ParseWithoutDepthLimit(Nest(doctype, "<b id=\"one\"/><b id=\"one\"/>"));

Assert.IsFalse(document.IsValid);
}
Expand All @@ -60,8 +63,8 @@ public void DeeplyNestedDocumentIsValidAgainstExternalSubset()
var dtdPath = Path.Combine(directory, "deep.dtd");
File.WriteAllText(dtdPath, "<!ELEMENT a ANY>");

var valid = Nest($@"<!DOCTYPE a SYSTEM ""{dtdPath}"">", "x").ToXmlDocument();
var invalid = Nest($@"<!DOCTYPE a SYSTEM ""{dtdPath}"">", "<b/>").ToXmlDocument();
var valid = ParseWithoutDepthLimit(Nest($@"<!DOCTYPE a SYSTEM ""{dtdPath}"">", "x"));
var invalid = ParseWithoutDepthLimit(Nest($@"<!DOCTYPE a SYSTEM ""{dtdPath}"">", "<b/>"));

Assert.IsTrue(valid.IsValid);
Assert.IsFalse(invalid.IsValid);
Expand All @@ -75,7 +78,7 @@ public void DeeplyNestedDocumentIsValidAgainstExternalSubset()
[Test]
public void TextContentOfDeeplyNestedElementIncludesTextAndCData()
{
var document = Nest(String.Empty, "x<![CDATA[y]]>").ToXmlDocument();
var document = ParseWithoutDepthLimit(Nest(String.Empty, "x<![CDATA[y]]>"));

Assert.AreEqual("xy", document.DocumentElement.TextContent);
}
Expand Down Expand Up @@ -108,6 +111,12 @@ private static String Nest(String prolog, String innermost)
return source.ToString();
}

private static IXmlDocument ParseWithoutDepthLimit(String source)
{
var context = BrowsingContext.New(Configuration.Default.WithXml());
return new XmlParser(new XmlParserOptions { MaximumDepth = Int32.MaxValue }, context).ParseDocument(source);
}

private static String CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "anglesharp-xml-tests-" + Guid.NewGuid().ToString("N"));
Expand Down
154 changes: 154 additions & 0 deletions src/AngleSharp.Xml.Tests/Parser/XmlMaximumDepth.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
namespace AngleSharp.Xml.Tests.Parser
{
using AngleSharp.Dom;
using AngleSharp.Xml.Parser;
using NUnit.Framework;
using System;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

/// <summary>
/// The parser nests elements up to <see cref="XmlParserOptions.MaximumDepth"/>, counting the
/// root element as level 1, as the HTML parser does with its option of the same name.
/// </summary>
[TestFixture]
public class XmlMaximumDepth
{
[Test]
public void DefaultIs512()
{
Assert.AreEqual(512, XmlParserOptions.DefaultMaximumDepth);
Assert.AreEqual(512, new XmlParserOptions().MaximumDepth);
Assert.AreEqual(512, default(XmlParserOptions).MaximumDepth);
}

[TestCase(0)]
[TestCase(-1)]
public void ZeroOrNegativeRestoresTheDefault(Int32 value)
{
var options = new XmlParserOptions { MaximumDepth = value };

Assert.AreEqual(XmlParserOptions.DefaultMaximumDepth, options.MaximumDepth);
}

[Test]
public void DocumentAtTheDefaultDepthParses()
{
var document = new XmlParser().ParseDocument(Nest(512));

Assert.AreEqual(512, Depth(document.DocumentElement));
}

[Test]
public void DocumentBeyondTheDefaultDepthThrows()
{
var exception = Assert.Throws<XmlParseException>(() => new XmlParser().ParseDocument(Nest(513)));

Assert.AreEqual((Int32)XmlParseError.MaximumDepthExceeded, exception.Code);
}

[Test]
public void DocumentBeyondTheDefaultDepthThrowsWhenParsedAsynchronously()
{
var exception = Assert.ThrowsAsync<XmlParseException>(async () => await new XmlParser().ParseDocumentAsync(Nest(513)));

Assert.AreEqual((Int32)XmlParseError.MaximumDepthExceeded, exception.Code);
}

[TestCase(3, 3, false)]
[TestCase(3, 4, true)]
[TestCase(1, 1, false)]
[TestCase(1, 2, true)]
public void CustomLimitIsEnforced(Int32 maximumDepth, Int32 depth, Boolean throws)
{
var parser = new XmlParser(new XmlParserOptions { MaximumDepth = maximumDepth });

if (throws)
{
Assert.Throws<XmlParseException>(() => parser.ParseDocument(Nest(depth)));
}
else
{
Assert.AreEqual(depth, Depth(parser.ParseDocument(Nest(depth)).DocumentElement));
}
}

[Test]
public void MaxValueLiftsTheLimit()
{
var parser = new XmlParser(new XmlParserOptions { MaximumDepth = Int32.MaxValue });

Assert.AreEqual(2000, Depth(parser.ParseDocument(Nest(2000)).DocumentElement));
}

[Test]
public void SelfClosingElementBeyondTheLimitThrows()
{
var parser = new XmlParser(new XmlParserOptions { MaximumDepth = 2 });

Assert.Throws<XmlParseException>(() => parser.ParseDocument("<a><b><c/></b></a>"));
}

[Test]
public async Task ElementsBeyondTheLimitAreAttachedAtTheLimitWhenErrorsAreSuppressed()
{
var parser = new XmlParser(new XmlParserOptions { MaximumDepth = 3, IsSuppressingErrors = true });
var document = await parser.ParseDocumentAsync("<a><b><c><d>x<e/></d>y</c></b></a>");
var b = document.DocumentElement.FirstElementChild;

// d and e would be levels 4 and 5, so they become siblings of c; the text after the
// closed d stays in c, as in the HTML parser
Assert.AreEqual(3, Depth(document.DocumentElement));
Assert.AreEqual("c,d,e", String.Join(",", b.Children.Select(m => m.LocalName)));
Assert.AreEqual("y", b.Children[0].TextContent);
Assert.AreEqual("x", b.Children[1].TextContent);
}

[Test]
public void RootStaysTheParentAtTheLeastWhenErrorsAreSuppressed()
{
var parser = new XmlParser(new XmlParserOptions { MaximumDepth = 1, IsSuppressingErrors = true });
var document = parser.ParseDocument("<a><b><c/></b></a>");

Assert.AreEqual("b,c", String.Join(",", document.DocumentElement.Children.Select(m => m.LocalName)));
}

private static String Nest(Int32 depth)
{
var source = new StringBuilder(depth * 7);

for (var i = 0; i < depth; i++)
{
source.Append("<a>");
}

for (var i = 0; i < depth; i++)
{
source.Append("</a>");
}

return source.ToString();
}

private static Int32 Depth(IElement root)
{
var depth = 0;
var pending = new System.Collections.Generic.Stack<(IElement Element, Int32 Level)>();
pending.Push((root, 1));

while (pending.Count > 0)
{
var (element, level) = pending.Pop();
depth = Math.Max(depth, level);

foreach (var child in element.Children)
{
pending.Push((child, level + 1));
}
}

return depth;
}
}
}
28 changes: 27 additions & 1 deletion src/AngleSharp.Xml/Parser/XmlDomBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,32 @@ private void InMisc(XmlToken token)
}
}

/// <summary>
/// Gets the node a new element is attached to, keeping it within
/// <see cref="XmlParserOptions.MaximumDepth"/>: an element beyond it throws, or with errors
/// suppressed goes to the open element one level above the limit, so it becomes a sibling
/// of the deepest element, as in the HTML parser. Each open element is then within the
/// limit, so its text and other content can go to it directly.
/// </summary>
/// <param name="position">The position to report the error at.</param>
private Node GetInsertionParent(TextPosition position)
{
var maximumDepth = _options.MaximumDepth;

if (_openElements.Count < maximumDepth)
{
return CurrentNode;
}

if (!_options.IsSuppressingErrors)
{
throw XmlParseError.MaximumDepthExceeded.At(position);
}

// The root element stays the parent at the least, as the HTML parser keeps it
return _openElements[Math.Max(maximumDepth - 1, 1) - 1];
}

/// <summary>
/// In the body state - no doctypes and declarations allowed.
/// </summary>
Expand All @@ -327,7 +353,7 @@ private void InBody(XmlToken token)
{
var tagToken = (XmlTagToken)token;
var element = CreateElement(tagToken.Name, tagToken.IsSelfClosing);
CurrentNode.AppendChild(element);
GetInsertionParent(tagToken.Position).AppendChild(element);

var namespaceDeclarations = tagToken.Attributes
.Where(attr => attr.Key.StartsWith("xmlns"))
Expand Down
4 changes: 4 additions & 0 deletions src/AngleSharp.Xml/Parser/XmlParseError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,5 +153,9 @@ enum XmlParseError : ushort
/// (0x313) Expanding the declared entities exceeds the allowed number of characters.
/// </summary>
EntityExpansionLimitExceeded = 0x313,
/// <summary>
/// (0x314) Elements are nested deeper than the allowed depth.
/// </summary>
MaximumDepthExceeded = 0x314,
}
}
26 changes: 26 additions & 0 deletions src/AngleSharp.Xml/Parser/XmlParserOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,33 @@ public struct XmlParserOptions
/// </summary>
public const Int64 DefaultMaxCharactersFromEntities = 10_000_000;

/// <summary>
/// The element nesting depth used when <see cref="MaximumDepth"/> is not set, as
/// AngleSharp.Html.Parser.HtmlParserOptions.DefaultMaximumDepth.
/// </summary>
public static readonly Int32 DefaultMaximumDepth = 512;

private Int64? _maxCharactersFromEntities;
private Int32 _maximumDepth;

/// <summary>
/// Gets or sets the deepest level at which the parser nests elements, counting the root
/// element as level 1, as AngleSharp.Html.Parser.HtmlParserOptions.MaximumDepth does.
/// Defaults to <see cref="DefaultMaximumDepth"/>; zero or a negative value restores the
/// default and <see cref="Int32.MaxValue"/> lifts the limit.
/// </summary>
/// <remarks>
/// XML puts no limit on nesting, but code walking a tree that is thousands of levels deep
/// recursively can overflow the call stack, which ends the process. An element beyond the
/// limit throws an <see cref="XmlParseException"/>, since nesting it elsewhere would change
/// the document; when errors are suppressed, it is attached at the limit instead, as a
/// sibling of the deepest element, as the HTML parser does.
/// </remarks>
public Int32 MaximumDepth
{
readonly get => _maximumDepth > 0 ? _maximumDepth : DefaultMaximumDepth;
set => _maximumDepth = value;
}

/// <summary>
/// Gets or sets the maximum number of characters that the expansion of entities declared
Expand Down
Loading