From 11a6bf717dbff9f8a8238a7872c2f92f8c664b45 Mon Sep 17 00:00:00 2001 From: centwon Date: Thu, 1 Oct 2026 06:38:46 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=9D=BC=EC=9A=B4=EB=93=9C35=20?= =?UTF-8?q?=EC=A0=84=EC=88=98=20=EA=B0=90=EC=82=AC=20=E2=80=94=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=9D=98=20=ED=81=AC=EA=B8=B0=C2=B7=EA=B9=8A=EC=9D=B4?= =?UTF-8?q?,=20=EA=B2=B0=ED=95=A8=2016=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src 전체(약 19,500줄)를 모델 -> 포매터 -> 컨트롤 순으로 읽었다. 대부분 이전 라운드가 재지 않은 축: 붙여넣거나 여는 문서가 얼마나 크고 깊을 수 있는가. 프로세스 종료(catch 불가): - HTML
2,000겹(11 KB) -> WalkBlocks 스택 오버플로. DOM 128단 아래는 글자로 접기(비재귀), HtmlAgilityPack id 색인 끔(그 서브트리 제거가 재귀) - 중첩 표 400단 -> 렌더 스택 오버플로, RTF \itap 무제한. RTF 중첩 32단 - 행 없는 인라인 표(JSON) -> 화살표에서 키 처리기 밖으로 예외. 빈 격자는 문서에서 뺌 데이터 소실: - 인라인 표 셀로 끝나는 선택 삭제가 그 줄 전체(표 포함)를 지움 (TextRange.CoveredSpan) - 호스트의 Document 대입 뒤 Ctrl+Z가 옛 파일을 되살림 -> 새 문서 = 새 기록 - 인라인 표 섞인 한 문단 붙여넣기가 표를 버림 보안: 파일의 그림 MIME이 내보낸/클립보드 HTML 에 그대로 -> 속성 주입 메모리·시간: 픽셀 폭탄(1.5 MB -> 1.6 GB, real Skia) 1억 픽셀 상한 · 가져오는 표 1,000열/25만 셀 · .flow 미참조 항목 안 읽음(항목당 256 MB) · RTF IndexOf O(n^2) · HTML 넓은 행 예외 · ListLevel 범위(-1이면 HTML 내보내기 예외) · InsertTable 음수 · 문단 안 script/style 테스트: Round35AuditTests(31). 수정마다 개별 반증 — 깊이 반증은 별도 프로세스(호스트가 죽음). unit 1287 + render 55, 퍼즈 500시드 그린. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 49 ++ Project_Roadmap.md | 19 +- .../Controls/ImageDisplayCache.cs | 1 + .../Controls/RichEditor.Clipboard.cs | 12 +- .../Controls/RichEditor.Images.cs | 9 +- .../Controls/RichEditor.Input.cs | 2 +- src/AvaloniaRichEditor/Controls/RichEditor.cs | 46 +- .../Documents/ImageBlock.cs | 6 +- src/AvaloniaRichEditor/Documents/ImageInfo.cs | 24 + src/AvaloniaRichEditor/Documents/ImageMime.cs | 15 + .../Documents/InlineImage.cs | 6 +- .../Documents/TableBlock.cs | 15 +- src/AvaloniaRichEditor/Documents/TextRange.cs | 50 ++- .../Formatters/DocumentPackage.cs | 18 +- .../Formatters/DocumentSerializer.cs | 43 +- .../Formatters/HtmlDocumentFormatter.cs | 77 +++- .../Formatters/RtfDocumentFormatter.cs | 26 +- .../Round35AuditTests.cs | 422 ++++++++++++++++++ 18 files changed, 777 insertions(+), 63 deletions(-) create mode 100644 tests/AvaloniaRichEditor.Tests/Round35AuditTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1ae7c..0e7b13a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed — round 35: a full audit; the size and depth of untrusted input (2026-10-01) + +Every source file was read. Most of what turned up is one axis earlier rounds never measured: how LARGE and how +DEEP a pasted or opened document may be. Tests: `Round35AuditTests`; every fix was reverted once to see its test +go red (the depth ones take the test host down, which is the point). + +**Crashes no handler can catch (stack overflow = the host application exits)** +- **2,000 nested `
`s — 11 KB of HTML — killed the process on paste.** The HTML walkers recurse once per DOM + level. The DOM is now flattened to its text below 128 levels (walked without recursion), and + HtmlAgilityPack's id index — whose subtree removal recursed too — is off. 20,000 levels now parse in ~3 s. +- **Deeply nested tables overflowed the renderer** (150 levels drew, 400 did not), and RTF's `\itap` had no bound: + a few tens of KB of RTF built them. RTF nesting is read at most 32 deep; HTML's is bounded by the DOM depth. + +**Data loss** +- **Deleting a selection that ended inside an inline table deleted the whole line holding that table** — its text + after the table, and the table itself — though the selection never reached either. Document order puts an + inline table's cells after their host paragraph, so the host counted as "between" the ends. A drag or + Shift+→ from the line above makes that selection. The host now contributes only its text before the table + (after it, for a selection that starts inside one) — for delete, copy text and formatting alike. +- **A host that opened a file by assigning `Document` left the previous file undoable**: Ctrl+Z put the OLD file + back, for the next save to write over the new one. Assigning a document now starts a new history; undo and + redo, which swap documents through the same property, keep theirs. +- **Pasting one paragraph that held an inline table among text dropped the table** (HTML or RTF paste — e.g. from + another instance of this editor). + +**Security** +- **A picture's MIME type from a JSON or `.flow` file went into `` unescaped**, so + `image/png" onerror="…` became an attribute of the exported — and clipboard — HTML. Types that are not a plain + `image/…` are replaced by what the bytes say, on reading and again on writing. + +**Memory and time from a few bytes** +- **A picture header claiming 40000×40000 — 1.5 MB of PNG — took 1.6 GB to decode** (real Skia), and pastes kept + that bitmap on the model. Decoding "to a small width" was worse: 2 GB for 20000×20000 (Skia decodes whole first). + Pictures claiming over **100 million pixels** are not decoded: paste and import refuse them, and one already in + a document keeps its bytes but is not drawn. +- **Imported tables are bounded to 1,000 columns and 250,000 cells.** Every reader padded short rows to the widest, + so one wide row over many narrow ones (JSON), a `colspan` (HTML) or a run of `\cellx` (RTF) multiplied a few MB + into a billion cells. RTF also looked each boundary up with a linear search per cell. +- **A `.flow` package inflated every `images/` entry, used or not**: 63 KB held 64 MB of zeros. Only the pictures + the document refers to are read, each up to 256 MB. +- An HTML row with more than 1,000 cells threw `ArgumentOutOfRangeException` out of `ParseHtml`. + +**Smaller** +- A table with no rows (from JSON, or a host's model) is dropped: arrowing into such an inline table threw out of + the key handler. `InsertTable` with a size below 1 inserts nothing (a negative one overflowed in Measure). +- A `ListLevel` outside 0–8 from JSON (or a host) threw out of the HTML export at -1, and at a million wrote a + million `
    `s. It is read as 0–8, as RTF already did, and written clamped. +- ` more
"); + string all = string.Concat(doc.Blocks.OfType().SelectMany(p => p.Inlines.OfType()).Select(r => r.Text)); + Assert.Contains("text", all); + Assert.Contains("more", all); + Assert.DoesNotContain("secret", all); + Assert.DoesNotContain("color", all); + } + + // -1 threw out of the HTML writer — and so out of every copy — and a million wrote a million
    . + [Theory] + [InlineData(-1)] + [InlineData(-5)] + [InlineData(1_000_000)] + public void AListLevelFromAFile_IsBounded_AndExports(int level) + { + var doc = DocumentSerializer.Deserialize("{\"Blocks\":[{\"Type\":\"Paragraph\",\"ListType\":\"Bullet\",\"ListLevel\":" + level + ",\"Inlines\":[{\"Text\":\"item\"}]}]}"); + Assert.InRange(((Paragraph)doc.Blocks[0]).ListLevel, 0, 8); + Assert.True(HtmlDocumentFormatter.ToHtml(doc).Length < 2000); + } + + [Theory] + [InlineData(-1)] + [InlineData(1_000_000)] + public void AListLevelSetByAHost_Exports(int level) + { + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { ListType = ListKind.Bullet, ListLevel = level, Inlines = { new Run { Text = "item" } } }); + string? html = null; + Assert.Null(Record.Exception(() => html = HtmlDocumentFormatter.ToHtml(doc))); + Assert.True(html!.Length < 2000); + } + + // ---- editor ------------------------------------------------------------------------------------------------ + + [AvaloniaTheory] + [InlineData(-1, 2)] + [InlineData(2, -1)] + [InlineData(0, 3)] + [InlineData(3, 0)] + public void InsertTable_WithANonPositiveSize_InsertsNothing(int rows, int cols) + { + var ed = new RichEditor { Document = new FlowDocument(), PageSize = RichEditorPageSize.Continuous }; + ed.FocusDocumentEnd(); + ed.InsertTable(rows, cols); + ed.Measure(new Size(700, double.PositiveInfinity)); // a negative size overflowed an array here + Assert.Empty(ed.Document!.Blocks.OfType()); + Assert.False(ed.CanUndo); + } + + // A table with no rows — from a file, or a host's model — held nothing the caret walks could take, and + // arrowing into an inline one threw out of the key handler. + [AvaloniaFact] + public void ATableWithNoRows_IsNotInTheDocument_AndArrowsPastItsPlace() + { + var ed = new RichEditor { PageSize = RichEditorPageSize.Continuous }; + ed.LoadJson("{\"Blocks\":[{\"Type\":\"Paragraph\",\"Inlines\":[{\"Type\":\"Table\",\"Table\":{\"Type\":\"Table\",\"Cells\":[]}},{\"Text\":\"after\"}]}," + + "{\"Type\":\"Table\",\"Cells\":[]},{\"Type\":\"Paragraph\",\"Inlines\":[{\"Text\":\"end\"}]}]}"); + Assert.Empty(AllTables(ed.Document!)); + var host = InteractionHost.Create(ed); + host.Render(); + typeof(RichEditor).GetField("_caretPosition", NP)!.SetValue(ed, new TextPointer((Paragraph)ed.Document!.Blocks[0], 0)); + Assert.Null(Record.Exception(() => { for (int i = 0; i < 12; i++) host.Key(Key.Right); for (int i = 0; i < 12; i++) host.Key(Key.Left); })); + } + + [AvaloniaFact] + public void AHostModelWithAnEmptyGrid_HasItDropped() + { + var empty = new TableBlock(1, 1); + empty.Cells.Clear(); empty.Rows = 0; + var inlineEmpty = new TableBlock(1, 1); + inlineEmpty.Cells[0].Clear(); inlineEmpty.Columns = 0; + var doc = new FlowDocument(); + doc.Blocks.Add(empty); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "a" }, new InlineTable { Table = inlineEmpty } } }); + var ed = new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }; + Assert.Empty(AllTables(ed.Document!)); + Assert.Null(Record.Exception(() => InteractionHost.Create(ed).Render())); + } + + // Pasting ONE paragraph kept only its runs and pictures, so an inline table pasted with text around it — + // from another instance of this editor, through the system clipboard's HTML — vanished. + [AvaloniaFact] + public void PastingOneParagraphWithAnInlineTable_KeepsTheTable() + { + var src = new FlowDocument(); + var t = new TableBlock(1, 1); + t.Cells[0][0].Para.Inlines.Add(new Run { Text = "cell" }); + src.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "ab" }, new InlineTable { Table = t }, new Run { Text = "cd" } } }); + var ed = new RichEditor { Document = new FlowDocument(), PageSize = RichEditorPageSize.Continuous }; + ed.FocusDocumentEnd(); + + ed.InsertHtml(HtmlDocumentFormatter.ToHtml(src)); + + var host = Assert.Single(ed.Document!.Blocks.OfType(), p => p.Inlines.OfType().Any()); + Assert.Contains("ab", string.Concat(host.Inlines.OfType().Select(r => r.Text))); + Assert.Contains("cd", string.Concat(host.Inlines.OfType().Select(r => r.Text))); + } + + // A host opening a file by assigning Document left the previous file's edits undoable: Ctrl+Z put the OLD + // file back, and the next save wrote it over the new one. + [AvaloniaFact] + public void AssigningANewDocument_StartsANewHistory() + { + var first = new FlowDocument(); + first.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "OLD FILE" } } }); + var host = InteractionHost.Create(new RichEditor { Document = first, PageSize = RichEditorPageSize.Continuous }); + host.Click(new Point(600, 8)); + host.Type("!"); + Assert.True(host.Editor.CanUndo); // precondition + + var second = new FlowDocument(); + second.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "NEW FILE" } } }); + host.Editor.Document = second; + Assert.False(host.Editor.CanUndo); + host.Key(Key.Z, RawInputModifiers.Control); + Assert.DoesNotContain("OLD FILE", host.Editor.GetPlainText()); + + // Undo and redo swap documents through the same property, and keep their history. + host.Click(new Point(500, 8)); // not the spot clicked above: that would be a double-click, selecting a word + host.Type("?"); + host.Key(Key.Z, RawInputModifiers.Control); + Assert.True(host.Editor.CanRedo); + host.Key(Key.Y, RawInputModifiers.Control); + Assert.Equal("NEW FILE?", host.Editor.GetPlainText()); + Assert.True(host.Editor.CanUndo); + } +}