Skip to content

Commit e562ded

Browse files
Merge pull request #1927 from EvotecIT/fix-unbounded-xml-parsing-in-visio-comments
Harden Visio comments loading to prevent XML DoS
2 parents 8b62732 + 7f4b605 commit e562ded

3 files changed

Lines changed: 230 additions & 3 deletions

File tree

OfficeIMO.Tests/Visio.Comments.cs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.IO;
34
using System.IO.Compression;
45
using System.Linq;
6+
using System.Text;
57
using System.Xml.Linq;
68
using OfficeIMO.Visio;
79
using OfficeIMO.Visio.Fluent;
@@ -170,6 +172,74 @@ public void CommentsCanBeReviewedUpdatedReopenedAndRemovedInLoadedDocuments() {
170172
AssertCommentXml(fluentPath, "Follow-up resolved", done: true, expectedEdited: finalResolvedAt);
171173
}
172174

175+
[Fact]
176+
public void LoadRejectsCommentsPartWithTooMuchXml() {
177+
string filePath = CreateDocumentWithComment();
178+
string oversizedCommentText = new('x', checked((int)VisioDocument.MaxCommentsXmlCharacters));
179+
ReplaceCommentsXml(filePath, CreateCommentsXml(oversizedCommentText));
180+
181+
Assert.ThrowsAny<System.Xml.XmlException>(() => VisioDocument.Load(filePath));
182+
}
183+
184+
[Fact]
185+
public void LoadRejectsTooManyNativeComments() {
186+
string filePath = CreateDocumentWithComment();
187+
ReplaceCommentsXml(filePath, CreateCommentsXml(Enumerable.Range(0, VisioDocument.MaxLoadedComments + 1)
188+
.Select(index => "Comment " + index.ToString())));
189+
190+
InvalidDataException exception = Assert.Throws<InvalidDataException>(() => VisioDocument.Load(filePath));
191+
Assert.Contains(VisioDocument.MaxLoadedComments.ToString(), exception.Message);
192+
}
193+
194+
[Fact]
195+
public void LoadRejectsOversizedNativeCommentText() {
196+
string filePath = CreateDocumentWithComment();
197+
string oversizedCommentText = new('x', VisioDocument.MaxCommentTextCharacters + 1);
198+
ReplaceCommentsXml(filePath, CreateCommentsXml(oversizedCommentText));
199+
200+
InvalidDataException exception = Assert.Throws<InvalidDataException>(() => VisioDocument.Load(filePath));
201+
Assert.Contains(VisioDocument.MaxCommentTextCharacters.ToString(), exception.Message);
202+
}
203+
204+
[Fact]
205+
public void SaveRejectsTooManyNativeComments() {
206+
string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".vsdx");
207+
VisioDocument document = VisioDocument.Create(filePath);
208+
VisioPage page = document.AddPage("Review", 11, 8.5);
209+
for (int index = 0; index <= VisioDocument.MaxLoadedComments; index++) {
210+
page.Comments.Add(new VisioComment("Comment " + index.ToString()) {
211+
AuthorName = "Operations",
212+
AuthorInitials = "OP"
213+
});
214+
}
215+
216+
InvalidDataException exception = Assert.Throws<InvalidDataException>(() => document.Save());
217+
Assert.Contains(VisioDocument.MaxLoadedComments.ToString(), exception.Message);
218+
}
219+
220+
[Fact]
221+
public void SaveRejectsOversizedNativeCommentText() {
222+
string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".vsdx");
223+
VisioDocument document = VisioDocument.Create(filePath);
224+
VisioPage page = document.AddPage("Review", 11, 8.5);
225+
page.AddComment(new string('x', VisioDocument.MaxCommentTextCharacters + 1), "Operations", "OP");
226+
227+
InvalidDataException exception = Assert.Throws<InvalidDataException>(() => document.Save());
228+
Assert.Contains(VisioDocument.MaxCommentTextCharacters.ToString(), exception.Message);
229+
}
230+
231+
[Fact]
232+
public void SaveRejectsCommentsPartThatExceedsUtf8ByteLimit() {
233+
string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".vsdx");
234+
VisioDocument document = VisioDocument.Create(filePath);
235+
VisioPage page = document.AddPage("Review", 11, 8.5);
236+
VisioComment comment = page.AddComment("Byte budget", "Operations", "OP");
237+
comment.AuthorName = new string('\u20ac', checked((int)(VisioDocument.MaxCommentsPartBytes / 3L + 1024L)));
238+
239+
InvalidDataException exception = Assert.Throws<InvalidDataException>(() => document.Save());
240+
Assert.Contains(VisioDocument.MaxCommentsPartBytes.ToString(), exception.Message);
241+
}
242+
173243
private static void AssertNativeCommentPackage(
174244
string filePath,
175245
string expectedText,
@@ -244,6 +314,49 @@ private static void AssertCommentTextAbsent(string filePath, string text) {
244314
Assert.DoesNotContain(comments.Descendants(v + "CommentEntry"), entry => entry.Value == text);
245315
}
246316

317+
private static string CreateDocumentWithComment() {
318+
string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".vsdx");
319+
VisioDocument document = VisioDocument.Create(filePath);
320+
VisioPage page = document.AddPage("Review", 11, 8.5);
321+
page.AddComment("Initial comment", "Operations", "OP");
322+
document.Save();
323+
return filePath;
324+
}
325+
326+
private static void ReplaceCommentsXml(string filePath, string commentsXml) {
327+
using ZipArchive archive = ZipFile.Open(filePath, ZipArchiveMode.Update);
328+
ZipArchiveEntry entry = archive.GetEntry("visio/comments.xml") ?? throw new InvalidOperationException("Missing comments part.");
329+
entry.Delete();
330+
ZipArchiveEntry replacement = archive.CreateEntry("visio/comments.xml", CompressionLevel.Optimal);
331+
using Stream stream = replacement.Open();
332+
using StreamWriter writer = new(stream, new UTF8Encoding(false));
333+
writer.Write(commentsXml);
334+
}
335+
336+
private static string CreateCommentsXml(string commentText) {
337+
return CreateCommentsXml(new[] { commentText });
338+
}
339+
340+
private static string CreateCommentsXml(IEnumerable<string> commentTexts) {
341+
XNamespace v = "http://schemas.microsoft.com/office/visio/2012/main";
342+
int id = 1;
343+
XDocument comments = new(new XElement(v + "Comments",
344+
new XAttribute("xmlns", v.NamespaceName),
345+
new XElement(v + "AuthorList",
346+
new XElement(v + "AuthorEntry",
347+
new XAttribute("ID", "1"),
348+
new XAttribute("Name", "Operations"),
349+
new XAttribute("Initials", "OP"))),
350+
new XElement(v + "CommentList",
351+
commentTexts.Select(text => new XElement(v + "CommentEntry",
352+
new XAttribute("IX", id++),
353+
new XAttribute("AuthorID", "1"),
354+
new XAttribute("PageID", "0"),
355+
text)))));
356+
357+
return comments.ToString(SaveOptions.DisableFormatting);
358+
}
359+
247360
private static string FormatExpectedDate(DateTimeOffset value) {
248361
return System.Xml.XmlConvert.ToString(value.UtcDateTime, System.Xml.XmlDateTimeSerializationMode.Utc);
249362
}

OfficeIMO.Visio/VisioDocument.LoadCore.Comments.cs

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Globalization;
4+
using System.IO;
45
using System.IO.Packaging;
56
using System.Linq;
67
using System.Xml;
78
using System.Xml.Linq;
89

910
namespace OfficeIMO.Visio {
1011
public partial class VisioDocument {
12+
internal const long MaxCommentsPartBytes = 12_000_000;
13+
internal const long MaxCommentsXmlCharacters = 10_000_000;
14+
internal const int MaxLoadedComments = 10_000;
15+
internal const int MaxCommentTextCharacters = 32_768;
16+
17+
private static readonly XmlReaderSettings CommentsXmlReaderSettings = new() {
18+
DtdProcessing = DtdProcessing.Prohibit,
19+
XmlResolver = null,
20+
MaxCharactersInDocument = MaxCommentsXmlCharacters,
21+
MaxCharactersFromEntities = 0,
22+
};
23+
1124
private static void LoadComments(Package package, PackagePart documentPart, VisioDocument document) {
1225
PackageRelationship? commentsRel = documentPart.GetRelationshipsByType(CommentsRelationshipType).FirstOrDefault();
1326
if (commentsRel == null) {
@@ -20,7 +33,7 @@ private static void LoadComments(Package package, PackagePart documentPart, Visi
2033
}
2134

2235
PackagePart commentsPart = package.GetPart(commentsUri);
23-
XDocument commentsXml = XDocument.Load(commentsPart.GetStream());
36+
XDocument commentsXml = LoadCommentsXml(commentsPart);
2437
XElement? root = commentsXml.Root;
2538
if (root == null) {
2639
return;
@@ -40,7 +53,14 @@ private static void LoadComments(Package package, PackagePart documentPart, Visi
4053
}
4154

4255
XElement? commentList = root.Elements().FirstOrDefault(element => IsVisioElement(element, "CommentList"));
56+
int loadedCommentCount = 0;
4357
foreach (XElement commentElement in commentList?.Elements().Where(element => IsVisioElement(element, "CommentEntry")) ?? Enumerable.Empty<XElement>()) {
58+
loadedCommentCount++;
59+
if (loadedCommentCount > MaxLoadedComments) {
60+
throw new InvalidDataException($"Visio comments part contains more than {MaxLoadedComments} comments.");
61+
}
62+
63+
string commentText = GetBoundedCommentText(commentElement);
4464
if (!TryParseIntAttribute(commentElement, "PageID", out int pageId)) {
4565
continue;
4666
}
@@ -53,7 +73,7 @@ private static void LoadComments(Package package, PackagePart documentPart, Visi
5373
TryParseIntAttribute(commentElement, "IX", out int commentId);
5474
TryParseIntAttribute(commentElement, "AuthorID", out int authorId);
5575
authors.TryGetValue(authorId, out var author);
56-
VisioComment comment = new(commentElement.Value) {
76+
VisioComment comment = new(commentText) {
5777
Id = commentId > 0 ? commentId : GetNextLoadedCommentId(page),
5878
AuthorName = author.Name,
5979
AuthorInitials = author.Initials,
@@ -73,6 +93,25 @@ private static void LoadComments(Package package, PackagePart documentPart, Visi
7393
}
7494
}
7595

96+
private static XDocument LoadCommentsXml(PackagePart commentsPart) {
97+
using Stream commentsStream = commentsPart.GetStream();
98+
using Stream boundedStream = new BoundedReadStream(commentsStream, MaxCommentsPartBytes);
99+
using XmlReader reader = XmlReader.Create(boundedStream, CommentsXmlReaderSettings);
100+
return XDocument.Load(reader);
101+
}
102+
103+
private static string GetBoundedCommentText(XElement commentElement) {
104+
int textLength = 0;
105+
foreach (XText text in commentElement.DescendantNodes().OfType<XText>()) {
106+
textLength += text.Value.Length;
107+
if (textLength > MaxCommentTextCharacters) {
108+
throw new InvalidDataException($"Visio comment text exceeds {MaxCommentTextCharacters} characters.");
109+
}
110+
}
111+
112+
return commentElement.Value;
113+
}
114+
76115
private static bool IsVisioElement(XElement element, string localName) {
77116
return string.Equals(element.Name.LocalName, localName, StringComparison.OrdinalIgnoreCase);
78117
}
@@ -134,5 +173,55 @@ private static bool ParseCommentBool(string? value) {
134173

135174
return persistedId;
136175
}
176+
177+
private sealed class BoundedReadStream : Stream {
178+
private readonly Stream _inner;
179+
private readonly long _maxBytes;
180+
private long _bytesRead;
181+
182+
internal BoundedReadStream(Stream inner, long maxBytes) {
183+
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
184+
_maxBytes = maxBytes;
185+
}
186+
187+
public override bool CanRead => _inner.CanRead;
188+
189+
public override bool CanSeek => false;
190+
191+
public override bool CanWrite => false;
192+
193+
public override long Length => throw new NotSupportedException();
194+
195+
public override long Position {
196+
get => _bytesRead;
197+
set => throw new NotSupportedException();
198+
}
199+
200+
public override void Flush() {
201+
_inner.Flush();
202+
}
203+
204+
public override int Read(byte[] buffer, int offset, int count) {
205+
int read = _inner.Read(buffer, offset, count);
206+
_bytesRead += read;
207+
if (_bytesRead > _maxBytes) {
208+
throw new InvalidDataException($"Visio comments part exceeds {_maxBytes} bytes.");
209+
}
210+
211+
return read;
212+
}
213+
214+
public override long Seek(long offset, SeekOrigin origin) {
215+
throw new NotSupportedException();
216+
}
217+
218+
public override void SetLength(long value) {
219+
throw new NotSupportedException();
220+
}
221+
222+
public override void Write(byte[] buffer, int offset, int count) {
223+
throw new NotSupportedException();
224+
}
225+
}
137226
}
138227
}

OfficeIMO.Visio/VisioDocument.SaveCore.Comments.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,17 @@ private static void WriteCommentsPart(
4949

5050
foreach (VisioPage page in pages) {
5151
foreach (VisioComment comment in page.Comments) {
52+
ValidateCommentForSave(comment);
5253
CommentAuthorKey authorKey = new(comment.AuthorName, comment.AuthorInitials, comment.AuthorResolutionId);
5354
if (!authorIds.TryGetValue(authorKey, out int authorId)) {
5455
authorId = authorIds.Count + 1;
5556
authorIds.Add(authorKey, authorId);
5657
}
5758

5859
comments.Add((page, comment, authorId));
60+
if (comments.Count > MaxLoadedComments) {
61+
throw new InvalidDataException($"Visio comments part contains more than {MaxLoadedComments} comments.");
62+
}
5963
}
6064
}
6165

@@ -104,9 +108,30 @@ private static void WriteCommentsPart(
104108
authorList,
105109
commentList));
106110

111+
string serializedComments = commentsXml.Declaration + Environment.NewLine + commentsXml.ToString(SaveOptions.DisableFormatting);
112+
ValidateCommentsXmlForSave(serializedComments);
113+
107114
using Stream stream = commentsPart.GetStream(FileMode.Create, FileAccess.Write);
108115
using StreamWriter writer = new(stream, new UTF8Encoding(false));
109-
writer.Write(commentsXml.Declaration + Environment.NewLine + commentsXml.ToString(SaveOptions.DisableFormatting));
116+
writer.Write(serializedComments);
117+
}
118+
119+
private static void ValidateCommentForSave(VisioComment comment) {
120+
string text = comment.Text ?? string.Empty;
121+
if (text.Length > MaxCommentTextCharacters) {
122+
throw new InvalidDataException($"Visio comment text exceeds {MaxCommentTextCharacters} characters.");
123+
}
124+
}
125+
126+
private static void ValidateCommentsXmlForSave(string commentsXml) {
127+
if (commentsXml.Length > MaxCommentsXmlCharacters) {
128+
throw new InvalidDataException($"Visio comments part exceeds {MaxCommentsXmlCharacters} XML characters.");
129+
}
130+
131+
int byteCount = Encoding.UTF8.GetByteCount(commentsXml);
132+
if (byteCount > MaxCommentsPartBytes) {
133+
throw new InvalidDataException($"Visio comments part exceeds {MaxCommentsPartBytes} bytes.");
134+
}
110135
}
111136

112137
private static int AssignSaveFallbackCommentId(VisioComment comment, XElement commentList) {

0 commit comments

Comments
 (0)