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
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div>`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 `<img src="data:…">` 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 `<ul>`s. It is read as 0–8, as RTF already did, and written clamped.
- `<script>`/`<style>` inside a paragraph's content came in as text.

### Changed — the layout cache holds what is on screen, not the whole document (2026-09-30)

Measure, pagination and every walk that only steps past a paragraph (hit-tests, link lookup, block lookup)
Expand Down
19 changes: 18 additions & 1 deletion Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ WPF `RichTextBox`/`FlowDocument`를 **순수 C# + Avalonia `TextLayout`**로 바
- **게시**: `v1.2.1`(2026-09-08) — nuget.org. 이력: 0.9.0(07-12) → 1.0.0(07-31, API 동결) → 1.1.0(08-08) → 1.2.0/1.2.1(09-08).
- **미릴리스**: 09-09 이후 라운드(대체 텍스트 ~ 라운드34), **호스트 API 3건**, 여백 실기 후속(PR #52)이 전부 `CHANGELOG.md` `[Unreleased]`.
공개 API **추가만**(`PublicAPI.Unshipped.txt`, 제거·시그니처 변경 없음) → 다음은 **minor(1.3.0)**.
- **검증**: 빌드 0 warn · unit **1255** + render **55** 그린(2026-09-30, 레이아웃 캐시 상한 브랜치) · 3-OS CI(Windows/Linux/macOS) 그린(라운드34 전 기준).
- **검증**: 빌드 0 warn · unit **1287** + render **56** 그린(2026-10-01, 라운드35) · 3-OS CI(Windows/Linux/macOS) 그린(라운드34 전 기준).
- **레이아웃 캐시 = 화면에 있는 것만** (2026-09-30): 측정·페이지 나눔·지나가는 워크는 높이 캐시를 읽고, 렌더 뒤 LRU 256개로 트림.
8,000문단 270 → 39 MB. 대가: 다시 화면에 들어온 문단은 재셰이핑(2,000문단 스크롤 +4 ms/쪽). 퍼즈에 **높이 오라클**
(편집한 편집기 = 새 편집기 측정 높이)이 생겨 목록 토글 측정 누락·서명 해시 상쇄를 잡았다. 포트에 같은 두 결함이 있는지는 미측정.
Expand Down Expand Up @@ -46,6 +46,21 @@ PR #48·#49·#50), 실기 후속도 PR #52로 들어왔다. 남은 것은 게시
게시 절차: `<Version>` → Unshipped를 Shipped로 이관 → CHANGELOG `[Unreleased]` → 버전 → README 배지(en/ko) → PR로 3-OS CI →
`v1.3.0` 태그 push(**사람이 실행**, Trusted Publishing) → **nuget.org 패키지 페이지 육안 확인**.

**1a. 라운드35 · 전수 감사 (2026-10-01, PR 대기)** — `src` 약 19,500줄을 모델 → 포매터 → 컨트롤 순으로 읽었다. 결함 **16건**
(프로세스 종료 3·데이터 소실 3·보안 1), 전부 빨강 먼저 → 수정 → 개별 반증. 대부분 이전 라운드가 재지 않은 **입력의 크기·깊이** 축이다. 테스트: `Round35AuditTests`.
- 치명(프로세스 종료, catch 불가): HTML `<div>` 2,000겹(11 KB) 붙여넣기, 중첩 표 400단(RTF `\itap` 무제한) 렌더 — 스택 오버플로.
수정: DOM 128단 아래는 글자로 접기(비재귀) + HtmlAgilityPack id 색인 끔(그 제거가 재귀) · RTF 중첩 32단.
- 데이터 소실: 인라인 표 셀로 끝나는 선택 삭제가 그 줄 전체(표 포함)를 지움 · 호스트가 `Document`를 대입해도 undo가 옛 파일로 돌아감 ·
인라인 표 섞인 한 문단 붙여넣기가 표를 버림.
- 보안: 파일의 그림 MIME이 내보낸 HTML `<img src>`에 그대로 → 속성 주입.
- 메모리·시간: 픽셀 폭탄(1.5 MB → 1.6 GB) · 표 증폭(행 × 가장 넓은 행) · `.flow` 미참조 항목 전부 풀기 · RTF `IndexOf` O(n²).
- **사용자 확인 필요**: 그림 디코드 상한 **1억 픽셀**(넘으면 붙여넣기 거절, 문서 안 것은 바이트 보존·미표시) — 200 MP 폰 사진은 걸린다.
가져오는 표 상한 1,000열 / 25만 셀, HTML DOM 128단, RTF 표 중첩 32단, `.flow` 그림 항목 256 MB.
- 기각(실측): `file:///%5C%5Chost` UNC 우회(경로가 `\\\host`가 되어 SMB 안 탐) · 링크 셸 실행(`http(s)`만 연다) · 셀 문단 여백의 서명 누락(셀 측정이 안 씀).
- 남긴 것(미수정, 저): JPEG 헤더 스캔이 채움 바이트에 약함 · 앵커 셀이 중첩 표로 시작하면 병합한 글자가 그 안으로 · RTF 글꼴명의 `;`가 글꼴 표를 깸 ·
RTF 병합 플래그가 셀마다 `MergeCells`(격자 전체 스캔)를 불러 적대적 입력에서 느림(상한 25만 셀로 유한).
- 포트(WinUIRichEditor)에 같은 결함이 있는지는 미측정 — 포매터가 같은 코드라 대부분 있을 것으로 본다.

**2. 라운드34 · 테스트가 닿지 않던 파일 감사 (2026-09-23, 완료 — PR #53 머지, 포트는 WinUIRichEditor PR #52 머지)** — 선정 기준: 파일에 선언된 메서드 이름이
`tests/`에 몇 번 나오는지 + 로드맵에 파일명이 나오는지. 1순위 8개(ContextMenu·Tables·Toolbar.PageFile·Formatting·Images·
FindBar·FindReplace·IncomingContent) 결함 **19건**, 2순위 5개(Rendering·Pagination·HitTesting·DragBlock·Clipboard) 결함 **10건**.
Expand Down Expand Up @@ -107,6 +122,8 @@ FindBar·FindReplace·IncomingContent) 결함 **19건**, 2순위 5개(Rendering
- **자체 왕복은 writer·reader가 공유하는 방언을 못 본다.** Word/HWP/브라우저 측정만이 잡았다(RTF 테두리 없음, `\clmgf` 병합, 용지).
육안보다 측정 — 브라우저 `getBoundingClientRect`, Word 객체 모델.
- **포매터는 전부 붙여넣기로 닿는 신뢰 불가 입력이다.** 정상 왕복 말고 깨진·악성 입력을 넣을 것. 누적형 결함은 **반복 왕복(고정점)**으로만 보인다.
**값만이 아니라 크기와 깊이도 입력이다**(라운드35): 재귀 깊이(스택 오버플로는 catch 불가 = 호스트 종료), 패딩·증폭(행 × 최대 열),
헤더가 주장하는 픽셀 수. 헤드리스 단위 테스트의 `Bitmap`은 디코드하지 않는 가짜라 메모리 측정은 반드시 real Skia(Tests.Render)에서.
- **생성 쪽이 만들지 않는 형태는 조용히 0% 커버다.** 불변식을 늘리는 것과 그 불변식이 실행되게 하는 것은 다른 일(퍼즈에 병합이 없었다).
- **반증(수정을 되돌려 테스트가 빨개지는지)을 매번 한다.** 폴백이 "틀린 이유로 맞는 답"을 주는 공허한 테스트가 여러 번 잡혔다.
라운드34에서도 셋: 캐럿 없는 편집기(문단 명령이 조기 반환), 착지점이 인라인 표를 빗나간 ↓, 투명 배경을 흰색으로 가정한 픽셀 판정.
Expand Down
1 change: 1 addition & 0 deletions src/AvaloniaRichEditor/Controls/ImageDisplayCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ private static (Bitmap? bitmap, bool atSourceSize) Decode(byte[] rawBytes, int b
try
{
var (natW, natH) = ImageInfo.GetPixelSize(rawBytes);
if (natW * natH > ImageInfo.MaxDecodePixels) return (null, true); // DecodeToWidth decodes whole first
using var ms = new System.IO.MemoryStream(rawBytes);
double s = natW > 0 && natH > 0 ? Math.Max(boxW / natW, boxH / natH) : 1;
if (s >= 1) return (new Bitmap(ms), true);
Expand Down
12 changes: 4 additions & 8 deletions src/AvaloniaRichEditor/Controls/RichEditor.Clipboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,7 @@ private void InsertImageBytes(byte[] bytes, double displayW, double displayH, bo
{
if (Document == null || IsReadOnly || !AllowImages) return;
Avalonia.Media.Imaging.Bitmap bmp;
try
{
using var ms = new System.IO.MemoryStream(bytes);
bmp = new Avalonia.Media.Imaging.Bitmap(ms);
}
try { bmp = ImageInfo.Decode(bytes); } // bounded: see ImageInfo.MaxDecodePixels
catch (Exception ex) { RichEditorDiagnostics.Report(ex); return; }

var scaled = Downscale(bmp);
Expand Down Expand Up @@ -442,7 +438,7 @@ private async Task CopyImageToClipboardAsync(byte[]? rawBytes, Bitmap? bmp, bool
// The Bitmap representation is best-effort (for other apps); never let it break the copy.
try
{
if (bmp == null) { using var ms = new System.IO.MemoryStream(bytes); bmp = new Bitmap(ms); }
bmp ??= ImageInfo.Decode(bytes);
item.Set(DataFormat.Bitmap, bmp);
}
catch (Exception ex) { RichEditorDiagnostics.Report(ex); }
Expand Down Expand Up @@ -503,7 +499,7 @@ private static (bool Inline, double W, double H)? ParseImageMeta(string? meta)
}
if (own != null)
{
try { using var ms = new System.IO.MemoryStream(own); return (new Bitmap(ms), own, meta); }
try { return (ImageInfo.Decode(own), own, meta); }
catch (Exception ex) { RichEditorDiagnostics.Report(ex); }
}
}
Expand Down Expand Up @@ -532,7 +528,7 @@ private static (bool Inline, double W, double H)? ParseImageMeta(string? meta)
}
if (bytes is { Length: > 0 })
{
try { using var ms = new System.IO.MemoryStream(bytes); return (new Avalonia.Media.Imaging.Bitmap(ms), bytes, null); }
try { return (ImageInfo.Decode(bytes), bytes, null); }
catch (Exception ex) { RichEditorDiagnostics.Report(ex); }
}
}
Expand Down
9 changes: 3 additions & 6 deletions src/AvaloniaRichEditor/Controls/RichEditor.Images.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,7 @@ private async Task ReplaceImageAsync(ImageBlock img)
using var ms = new System.IO.MemoryStream();
await s.CopyToAsync(ms);
var bytes = ms.ToArray();
using var ms2 = new System.IO.MemoryStream(bytes);
using (new Avalonia.Media.Imaging.Bitmap(ms2)) { } // validate before committing — not kept (drawn from ImageDisplayCache)
using (ImageInfo.Decode(bytes)) { } // validate before committing — not kept (drawn from ImageDisplayCache)
if (Document != null) PushUndo();
img.SetImageData(bytes, ImageMime.Detect(bytes));
InvalidateVisual();
Expand Down Expand Up @@ -201,8 +200,7 @@ private async Task SaveBitmapAsync(byte[]? rawBytes, Avalonia.Media.Imaging.Bitm
{
await using var s = await file.OpenWriteAsync();
if (bmp != null) { bmp.Save(s, Avalonia.Media.Imaging.PngBitmapEncoderOptions.Default); return; }
using var ms = new System.IO.MemoryStream(rawBytes!);
using var decoded = new Avalonia.Media.Imaging.Bitmap(ms);
using var decoded = ImageInfo.Decode(rawBytes!);
decoded.Save(s, Avalonia.Media.Imaging.PngBitmapEncoderOptions.Default);
}
catch (Exception ex) { RichEditorDiagnostics.Report(ex); }
Expand Down Expand Up @@ -337,8 +335,7 @@ private async Task ReplaceInlineImageAsync(InlineImage img)
using var ms = new System.IO.MemoryStream();
await s.CopyToAsync(ms);
var bytes = ms.ToArray();
using var ms2 = new System.IO.MemoryStream(bytes);
using (new Avalonia.Media.Imaging.Bitmap(ms2)) { } // validate before committing — not kept (drawn from ImageDisplayCache)
using (ImageInfo.Decode(bytes)) { } // validate before committing — not kept (drawn from ImageDisplayCache)
if (Document != null) PushUndo();
img.SetImageData(bytes, ImageMime.Detect(bytes));
InvalidateVisual();
Expand Down
2 changes: 1 addition & 1 deletion src/AvaloniaRichEditor/Controls/RichEditor.Input.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ private Rect DrawnTableRect(Point start, Point end)

private void InsertTableDrawn(int rows, int cols, double totalWidth, double totalHeight)
{
if (Document == null || IsReadOnly || !AllowTables) return;
if (Document == null || IsReadOnly || !AllowTables || rows < 1 || cols < 1) return; // see InsertTable
PushUndo();
var tb = new TableBlock(rows, cols);
double w = Math.Max(20, totalWidth / cols);
Expand Down
Loading
Loading