From c79393b67cef5facb4b7f6245931d1fa95f423f0 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 28 Oct 2026 04:15:21 +0900 Subject: [PATCH] feat(hwp): resource limits and fuzzing for the document readers Untrusted HWPX, DOCX and OFD files can no longer hang, exhaust the memory or crash Filee. The readers share Documents/DocumentLimits, which the HWP 5.0 reader and the layout engine will use too: packages are read by DocumentPackage (64 MB per part, 1 GB in all, counted while unpacking, an inflate ratio, 100,000 parts), XML by SafeXml (no DTD or resolver, 512 levels, 5 million elements; the tree builder GeoXml had, now shared), nesting of tables, text boxes, notes, groups and fields stops at 32 levels, paragraphs and objects are counted, and pictures whose header claims more than 16,384 x 16,384 pixels are left out. LayoutGuard holds the page, empty-page, time and cancellation checks and the clip-and-place rule for the layout loop. Passing a limit stops with the new error.document_too_complex. DocumentFuzzer mutates packages written in code; DocumentFuzzTests runs it briefly with every test run and for three minutes per reader every night (.github/workflows/fuzz.yml). Fixed with regression tests: HWPX column counts and spans at the end of the int range (OutOfMemoryException, ArgumentOutOfRangeException, overflowing width sums, endless row spans), DOCX duplicate numbering ids, page sizes that overflowed Math.Abs, raw XmlException from damaged DOCX parts, a footnote that refers to itself and long basedOn chains (stack overflows), and unclosed fields that slowed every run. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/fuzz.yml | 58 ++ docs/ENGINES.md | 28 +- src/Filee.App/Assets/i18n/ar.json | 1 + src/Filee.App/Assets/i18n/de.json | 1 + src/Filee.App/Assets/i18n/en.json | 1 + src/Filee.App/Assets/i18n/es.json | 1 + src/Filee.App/Assets/i18n/fr.json | 1 + src/Filee.App/Assets/i18n/hi.json | 1 + src/Filee.App/Assets/i18n/ja.json | 1 + src/Filee.App/Assets/i18n/ko.json | 1 + src/Filee.App/Assets/i18n/ne.json | 1 + src/Filee.App/Assets/i18n/pl.json | 1 + src/Filee.App/Assets/i18n/pt-BR.json | 1 + src/Filee.App/Assets/i18n/pt-PT.json | 1 + src/Filee.App/Assets/i18n/ru.json | 1 + src/Filee.App/Assets/i18n/th.json | 1 + src/Filee.App/Assets/i18n/tr.json | 1 + src/Filee.App/Assets/i18n/zh-CN.json | 1 + src/Filee.App/Assets/i18n/zh-TW.json | 1 + src/Filee.Engines/Documents/DocumentBudget.cs | 58 ++ src/Filee.Engines/Documents/DocumentLimits.cs | 96 +++ .../Documents/DocumentPackage.cs | 240 +++++++ src/Filee.Engines/Documents/LayoutGuard.cs | 71 ++ src/Filee.Engines/Documents/PictureSize.cs | 146 ++++ src/Filee.Engines/Geo/KmlFormat.cs | 61 +- .../Hwp/Hwpx/Docx/DocxReader.Drawing.cs | 15 +- .../Hwp/Hwpx/Docx/DocxReader.Styles.cs | 28 +- .../Hwp/Hwpx/Docx/DocxReader.Tables.cs | 14 +- src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs | 85 ++- src/Filee.Engines/Hwp/Hwpx/HwpxReader.Body.cs | 34 +- .../Hwp/Hwpx/HwpxReader.Objects.cs | 2 + src/Filee.Engines/Hwp/Hwpx/HwpxReader.cs | 62 +- src/Filee.Engines/Hwp/Hwpx/HwpxUnits.cs | 5 +- src/Filee.Engines/Hwp/Hwpx/StyleResolver.cs | 19 +- .../{Geo => Infrastructure}/LimitedStream.cs | 34 +- src/Filee.Engines/Infrastructure/SafeXml.cs | 130 ++++ src/Filee.Engines/Ofd/OfdPackage.cs | 89 +-- src/Filee.Engines/Ofd/OfdReader.cs | 4 + .../Filee.Engines.Tests/DocumentFuzzTests.cs | 233 +++++++ tests/Filee.Engines.Tests/DocumentFuzzer.cs | 375 +++++++++++ .../DocumentHostileInputTests.cs | 637 ++++++++++++++++++ .../GeoHostileInputTests.cs | 1 + tests/Filee.Engines.Tests/HwpxReaderTests.cs | 2 +- 43 files changed, 2328 insertions(+), 216 deletions(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 src/Filee.Engines/Documents/DocumentBudget.cs create mode 100644 src/Filee.Engines/Documents/DocumentLimits.cs create mode 100644 src/Filee.Engines/Documents/DocumentPackage.cs create mode 100644 src/Filee.Engines/Documents/LayoutGuard.cs create mode 100644 src/Filee.Engines/Documents/PictureSize.cs rename src/Filee.Engines/{Geo => Infrastructure}/LimitedStream.cs (51%) create mode 100644 src/Filee.Engines/Infrastructure/SafeXml.cs create mode 100644 tests/Filee.Engines.Tests/DocumentFuzzTests.cs create mode 100644 tests/Filee.Engines.Tests/DocumentFuzzer.cs create mode 100644 tests/Filee.Engines.Tests/DocumentHostileInputTests.cs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 000000000..caa5c0ca3 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,58 @@ +# Nightly mutation fuzzing of the document readers (tests/Filee.Engines.Tests/DocumentFuzzTests.cs): each reader gets a +# few minutes of inputs changed from seeds written in code, with a new random seed every night. Pull requests run the +# same tests briefly with a fixed seed as part of the normal test run; this job only adds the long run. A failure +# names the seed (FILEE_FUZZ_SEED repeats it) and uploads the inputs that failed. +name: Fuzz + +on: + schedule: + - cron: "17 18 * * *" # 03:17 in Korea + workflow_dispatch: + inputs: + seconds: + description: Seconds per reader + default: "180" + seed: + description: Seed to repeat (empty for a new one) + default: "" + +permissions: + contents: read + +concurrency: + group: fuzz + cancel-in-progress: true + +jobs: + fuzz: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + - name: Build + run: dotnet build tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj -c Release + + - name: Fuzz the document readers + env: + FILEE_FUZZ_SECONDS: ${{ inputs.seconds || '180' }} + FILEE_FUZZ_SEED: ${{ inputs.seed }} + FILEE_FUZZ_OUT: ${{ runner.temp }}/fuzz-failures + run: | + mkdir -p "$FILEE_FUZZ_OUT" + dotnet test tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj --no-build -c Release --filter "Category=Fuzz" \ + --blame-crash --results-directory TestResults --logger "console;verbosity=detailed" + + - name: Upload the inputs that failed + if: failure() + uses: actions/upload-artifact@v7 + with: + name: fuzz-failures + path: | + ${{ runner.temp }}/fuzz-failures/ + TestResults/ + if-no-files-found: ignore diff --git a/docs/ENGINES.md b/docs/ENGINES.md index e2b6f68e1..bf918f9f8 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -628,7 +628,8 @@ sits on the office donut (settings schema 27 teaches existing donuts `.ofd`). resource files (PublicRes, DocumentRes, a page's PageRes, and the unnamed `Res.xml` WPS writes next to them). Parts are looked up by name inside the ZIP and never written out under their own names; references are resolved against the referring part (or the package root for "/…"), "." and ".." applied, and anything that leaves the - package, a URL or a drive path finds nothing. XML is read without DTDs; a part may unpack to 64 MB, a picture to + package, a URL or a drive path finds nothing. The package is read like HWPX and DOCX (`Documents/DocumentPackage.cs`, + see [HWPX reader](#hwpx-reader)): XML without DTDs; a part may unpack to 64 MB, a picture to 32 MB (larger ones are left out), the whole package to 1 GB; at most 100,000 parts, 10,000 pages, 2,000,000 drawing objects, page blocks nested 32 deep. DeltaX / DeltaY "g n d" repeats never yield more values than the text has characters. @@ -1234,6 +1235,31 @@ OLE objects, charts, video, form controls, text art (its text is kept), arcs / p master pages, memos, character ratio, relative size and offset, paragraph borders, picture cropping and rotation. Password-protected (encrypted) HWPX and DRM-wrapped files stop with a clear error. +**Limits.** Untrusted documents must not hang, exhaust the memory or crash Filee. The HWPX, DOCX and OFD readers (and +the HWP 5.0 reader and the layout engine when they come) share `Documents/DocumentLimits.cs`; a document that passes +one stops with `error.document_too_complex` and says which: + +- the package (`Documents/DocumentPackage.cs`): at most 100,000 parts; a part may unpack to 64 MB, all parts together + to 1 GB, counted while they unpack (a ZIP header can understate a size), and a part over 4 MB to no more than 200 + times its packed size; parts are found by name inside the package only, and extracted pictures get names Filee + makes (`hwpx-media-1.png`), never the part's; +- XML (`Infrastructure/SafeXml.cs`, also used for KML and GPX): no DTD and no resolver, so no entity expands and + nothing outside the package is read; at most 512 levels deep and 5,000,000 elements in a package; +- the document (`Documents/DocumentBudget.cs`): tables in tables, text boxes, notes, groups and fields nest at most 32 + levels; at most 1,000,000 paragraphs and 200,000 tables, pictures and drawing objects; table columns, spans and + row spans are cut to the table (at most 16,384 columns); Word style chains are followed 20 styles deep; +- pictures (`Documents/PictureSize.cs`): one whose PNG, JPEG, GIF, BMP or WebP header claims more than + 16,384 × 16,384 pixels is left out before anything decodes it; +- layout (`Documents/LayoutGuard.cs`, for the layout engine): at most 20,000 pages, 64 empty pages in a row, ten minutes; + cancellation and the time are checked per paragraph, and an object larger than the page is clipped and placed. + +Embedded fonts (DOCX `word/fonts`, HWPX and OFD font files) are not read; a reader that uses them must pass them +through Filee's SFNT parser (`Fonts/SfntFont.cs`) first. `DocumentHostileInputTests` holds ZIP bombs, deep nesting, +DTDs and huge pictures; `DocumentFuzzTests` changes packages written in code at random (attributes set to extreme +values, elements removed, repeated, renamed or nested, text and bytes damaged) and allows a reader only to read a +file or refuse it with `InvalidDataException` / `ConversionRefusedException`. It runs briefly with every test run and +for a few minutes per reader every night (`.github/workflows/fuzz.yml`); `FILEE_FUZZ_SEED` repeats a run. + ## E-books The built-in e-book engine (`src/Filee.Engines/Ebooks`) reads books into the same document model as the HWPX diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 5c54c4b6c..b4d9688ab 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -129,6 +129,7 @@ "error.fon_unsupported": "هذا النوع من خطوط ‎.fon غير مدعوم", "error.fon_too_large": "خط ‎.fon كبير جدًا بحيث لا يمكن تحويله", "error.fon_damaged": "ملف ‎.fon تالف أو غير مكتمل", + "error.document_too_complex": "المستند كبير جدًا أو معقّد جدًا بحيث لا يمكن قراءته بأمان", "error.m4s_needs_init": "ملف ‎.m4s هذا جزء من فيديو متدفّق، ولا يمكن قراءته دون مقطع البداية (init) المنفصل الخاص به", "notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو", "notice.cad_newer_left_out": "حُذفت كائنات لا يدعمها إصدار AutoCAD المختار (أسهم توضيحية متعددة، شبكات …)", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 6a234a92d..b48177ad7 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -129,6 +129,7 @@ "error.fon_unsupported": "Diese Art von .fon-Schrift wird nicht unterstützt", "error.fon_too_large": "Die .fon-Schrift ist zu groß zum Konvertieren", "error.fon_damaged": "Die .fon-Datei ist beschädigt oder unvollständig", + "error.document_too_complex": "Das Dokument ist zu groß oder zu komplex, um es sicher zu lesen", "error.m4s_needs_init": "Diese .m4s-Datei ist ein Stück eines gestreamten Videos und lässt sich ohne ihr separates Startsegment (init) nicht lesen", "notice.pdf_not_smaller": "Die Datei ließ sich nicht verkleinern und wurde unverändert kopiert", "notice.cad_newer_left_out": "Objekte, die die gewählte AutoCAD-Version nicht unterstützt (Multiführungslinien, Netze …), wurden weggelassen", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index a168cd0e1..746e90251 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -135,6 +135,7 @@ "error.fon_unsupported": "This kind of .fon font isn't supported", "error.fon_too_large": "The .fon font is too large to convert", "error.fon_damaged": "The .fon file is damaged or cut short", + "error.document_too_complex": "The document is too large or too complex to read safely", "error.m4s_needs_init": "This .m4s is one piece of a streamed video: it can't be read without its separate start (init) segment", "notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is", "notice.cad_newer_left_out": "Objects the chosen AutoCAD version doesn't have (multileaders, meshes …) were left out", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index dea96a597..83c051b77 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -129,6 +129,7 @@ "error.fon_unsupported": "Este tipo de fuente .fon no es compatible", "error.fon_too_large": "La fuente .fon es demasiado grande para convertirla", "error.fon_damaged": "El archivo .fon está dañado o incompleto", + "error.document_too_complex": "El documento es demasiado grande o complejo para leerlo de forma segura", "error.m4s_needs_init": "Este .m4s es un fragmento de un vídeo en streaming y no se puede leer sin su segmento de inicio (init) aparte", "notice.pdf_not_smaller": "No se pudo reducir su tamaño, por lo que se copió tal cual", "notice.cad_newer_left_out": "Se omitieron objetos que la versión de AutoCAD elegida no tiene (directrices múltiples, mallas…)", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 948a24c14..f29a60ee0 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -135,6 +135,7 @@ "error.fon_unsupported": "Ce type de police .fon n'est pas pris en charge", "error.fon_too_large": "La police .fon est trop grande pour être convertie", "error.fon_damaged": "Le fichier .fon est endommagé ou incomplet", + "error.document_too_complex": "Le document est trop volumineux ou trop complexe pour être lu en toute sécurité", "error.m4s_needs_init": "Ce .m4s est un morceau d'une vidéo diffusée en streaming : il ne peut pas être lu sans son segment de départ (init) séparé", "notice.pdf_not_smaller": "Impossible de réduire sa taille : le fichier a été copié tel quel", "notice.cad_newer_left_out": "Des objets que la version d'AutoCAD choisie n'a pas (multilignes de repère, maillages…) ont été omis", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index f8f1ea2f7..ee0368fe7 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -129,6 +129,7 @@ "error.fon_unsupported": "इस तरह का .fon फ़ॉन्ट समर्थित नहीं है", "error.fon_too_large": ".fon फ़ॉन्ट बदलने के लिए बहुत बड़ा है", "error.fon_damaged": ".fon फ़ाइल खराब है या अधूरी है", + "error.document_too_complex": "दस्तावेज़ इतना बड़ा या जटिल है कि उसे सुरक्षित रूप से पढ़ा नहीं जा सकता", "error.m4s_needs_init": "यह .m4s स्ट्रीम किए गए वीडियो का एक हिस्सा है: इसके अलग शुरुआती (init) सेगमेंट के बिना इसे पढ़ा नहीं जा सकता", "notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया", "notice.cad_newer_left_out": "चुने गए AutoCAD संस्करण में न होने वाले ऑब्जेक्ट (मल्टीलीडर, मेश …) छोड़ दिए गए", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 2c897a5de..bd821d60d 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -129,6 +129,7 @@ "error.fon_unsupported": "この種類の.fonフォントには対応していません", "error.fon_too_large": ".fonフォントが大きすぎて変換できません", "error.fon_damaged": ".fonファイルが破損しているか、途中で切れています", + "error.document_too_complex": "文書が大きすぎるか複雑すぎるため、安全に読み込めません", "error.m4s_needs_init": "この .m4s はストリーミング動画の一部で、別にある開始(init)セグメントがないと読み込めません", "notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました", "notice.cad_newer_left_out": "選んだ AutoCAD のバージョンにないオブジェクト(マルチ引出線、メッシュ …)を省きました", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index bdf82d150..3013b8e04 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -135,6 +135,7 @@ "error.fon_unsupported": "지원하지 않는 종류의 .fon 글꼴이에요", "error.fon_too_large": ".fon 글꼴이 너무 커서 변환할 수 없어요", "error.fon_damaged": ".fon 파일이 손상되었거나 잘렸어요", + "error.document_too_complex": "문서가 너무 크거나 복잡해서 안전하게 읽을 수 없어요", "error.m4s_needs_init": "스트리밍 동영상의 한 조각인 .m4s 파일이에요. 따로 있는 시작(init) 세그먼트 없이는 읽을 수 없어요", "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요", "notice.cad_newer_left_out": "선택한 AutoCAD 버전에 없는 개체(다중 지시선, 메시 …)는 빼고 저장했어요", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index b765447f8..82fcb7340 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -129,6 +129,7 @@ "error.fon_unsupported": "यस प्रकारको .fon फन्ट समर्थित छैन", "error.fon_too_large": ".fon फन्ट रूपान्तरण गर्न धेरै ठूलो छ", "error.fon_damaged": ".fon फाइल बिग्रिएको वा अपूर्ण छ", + "error.document_too_complex": "कागजात सुरक्षित रूपमा पढ्न नसकिने गरी धेरै ठूलो वा जटिल छ", "error.m4s_needs_init": "यो .m4s स्ट्रिम गरिएको भिडियोको एउटा टुक्रा हो: छुट्टै सुरुवाती (init) खण्डबिना यसलाई पढ्न सकिँदैन", "notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो", "notice.cad_newer_left_out": "छानिएको AutoCAD संस्करणमा नभएका वस्तुहरू (मल्टिलिडर, मेस …) छोडिए", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 0bfb19c6d..23b597c83 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -135,6 +135,7 @@ "error.fon_unsupported": "Ten rodzaj czcionki .fon nie jest obsługiwany", "error.fon_too_large": "Czcionka .fon jest zbyt duża, aby ją przekonwertować", "error.fon_damaged": "Plik .fon jest uszkodzony lub niekompletny", + "error.document_too_complex": "Dokument jest zbyt duży lub zbyt złożony, aby bezpiecznie go odczytać", "error.m4s_needs_init": "Ten plik .m4s to fragment wideo strumieniowego i nie da się go odczytać bez osobnego segmentu początkowego (init)", "notice.pdf_not_smaller": "Nie udało się zmniejszyć pliku, więc skopiowano go bez zmian", "notice.cad_newer_left_out": "Pominięto obiekty, których wybrana wersja AutoCAD nie ma (multiodnośniki, siatki…)", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index c98e43d63..16eb4a1dd 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -135,6 +135,7 @@ "error.fon_unsupported": "Este tipo de fonte .fon não é compatível", "error.fon_too_large": "A fonte .fon é grande demais para converter", "error.fon_damaged": "O arquivo .fon está danificado ou incompleto", + "error.document_too_complex": "O documento é grande ou complexo demais para ser lido com segurança", "error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo transmitido por streaming e não pode ser lido sem o seu segmento inicial (init) separado", "notice.pdf_not_smaller": "Não deu para deixar menor, então foi copiado como está", "notice.cad_newer_left_out": "Objetos que a versão do AutoCAD escolhida não tem (multilinhas de chamada, malhas…) ficaram de fora", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 8692faccf..5227026e5 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -135,6 +135,7 @@ "error.fon_unsupported": "Este tipo de fonte .fon não é suportado", "error.fon_too_large": "A fonte .fon é demasiado grande para converter", "error.fon_damaged": "O ficheiro .fon está danificado ou incompleto", + "error.document_too_complex": "O documento é demasiado grande ou complexo para ser lido em segurança", "error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo em streaming e não pode ser lido sem o respetivo segmento inicial (init) separado", "notice.pdf_not_smaller": "Não foi possível reduzir o tamanho, pelo que o ficheiro foi copiado sem alterações", "notice.cad_newer_left_out": "Os objetos que a versão de AutoCAD escolhida não suporta (linhas de chamada múltiplas, malhas…) foram omitidos", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 7683e5f09..0d1e76402 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -135,6 +135,7 @@ "error.fon_unsupported": "Этот тип шрифта .fon не поддерживается", "error.fon_too_large": "Шрифт .fon слишком велик для преобразования", "error.fon_damaged": "Файл .fon повреждён или обрезан", + "error.document_too_complex": "Документ слишком большой или сложный, чтобы безопасно его прочитать", "error.m4s_needs_init": "Этот .m4s — фрагмент потокового видео: без отдельного начального сегмента (init) его не прочитать", "notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть", "notice.cad_newer_left_out": "Объекты, которых нет в выбранной версии AutoCAD (мультивыноски, сети…), не сохранены", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 0c5973197..52aaa3554 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -129,6 +129,7 @@ "error.fon_unsupported": "ไม่รองรับฟอนต์ .fon ประเภทนี้", "error.fon_too_large": "ฟอนต์ .fon มีขนาดใหญ่เกินกว่าจะแปลงได้", "error.fon_damaged": "ไฟล์ .fon เสียหายหรือไม่สมบูรณ์", + "error.document_too_complex": "เอกสารใหญ่หรือซับซ้อนเกินกว่าจะอ่านได้อย่างปลอดภัย", "error.m4s_needs_init": "ไฟล์ .m4s นี้เป็นชิ้นส่วนหนึ่งของวิดีโอสตรีมมิง และอ่านไม่ได้หากไม่มีเซกเมนต์เริ่มต้น (init) ที่แยกไว้", "notice.pdf_not_smaller": "ไม่สามารถบีบอัดให้เล็กลงได้ จึงคัดลอกไฟล์ตามเดิม", "notice.cad_newer_left_out": "ละวัตถุที่ AutoCAD เวอร์ชันที่เลือกไม่มี (มัลติลีดเดอร์ เมช …) ไว้", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index e350fbc62..efac198d4 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -129,6 +129,7 @@ "error.fon_unsupported": "Bu tür .fon yazı tipi desteklenmiyor", "error.fon_too_large": ".fon yazı tipi dönüştürmek için çok büyük", "error.fon_damaged": ".fon dosyası hasarlı veya eksik", + "error.document_too_complex": "Belge güvenli şekilde okunamayacak kadar büyük veya karmaşık", "error.m4s_needs_init": "Bu .m4s, akışla gelen bir videonun bir parçası: ayrı başlangıç (init) segmenti olmadan okunamaz", "notice.pdf_not_smaller": "Küçültülemediği için olduğu gibi kopyalandı", "notice.cad_newer_left_out": "Seçilen AutoCAD sürümünün desteklemediği nesneler (çoklu kılavuzlar, ağlar…) çıkarıldı", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 4b96b320d..4ffdde510 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -135,6 +135,7 @@ "error.fon_unsupported": "不支持此类 .fon 字体", "error.fon_too_large": ".fon 字体太大,无法转换", "error.fon_damaged": ".fon 文件已损坏或不完整", + "error.document_too_complex": "文档过大或过于复杂,无法安全读取", "error.m4s_needs_init": "此 .m4s 是流媒体视频的一个片段,没有单独的起始(init)分段就无法读取", "notice.pdf_not_smaller": "无法再缩小,已原样复制", "notice.cad_newer_left_out": "已省略所选 AutoCAD 版本没有的对象(多重引线、网格……)", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 8a56d201c..b83c0221d 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -135,6 +135,7 @@ "error.fon_unsupported": "不支援此類 .fon 字型", "error.fon_too_large": ".fon 字型太大,無法轉換", "error.fon_damaged": ".fon 檔案已損壞或不完整", + "error.document_too_complex": "文件過大或過於複雜,無法安全讀取", "error.m4s_needs_init": "此 .m4s 是串流影片的一個片段,沒有另外的起始(init)區段就無法讀取", "notice.pdf_not_smaller": "無法再縮小,已原樣複製", "notice.cad_newer_left_out": "已略過所選 AutoCAD 版本沒有的物件(複線引線、網格……)", diff --git a/src/Filee.Engines/Documents/DocumentBudget.cs b/src/Filee.Engines/Documents/DocumentBudget.cs new file mode 100644 index 000000000..631f19cb8 --- /dev/null +++ b/src/Filee.Engines/Documents/DocumentBudget.cs @@ -0,0 +1,58 @@ +// What one document reader may still build: nesting (tables in tables, text boxes, notes, groups, fields) and the +// number of paragraphs and objects. Readers call it where they recurse or add to the model, so a small file that +// expands into millions of paragraphs, or nests deeply enough to overflow the stack in a writer, is refused. + +namespace Filee.Engines.Documents; + +/// Counts what a reader builds against . +internal sealed class DocumentBudget(DocumentLimits limits) +{ + private int _depth; + private int _paragraphs; + private int _objects; + + public DocumentLimits Limits { get; } = limits; + + /// Current nesting depth. + public int Depth => _depth; + + public int Paragraphs => _paragraphs; + + public int Objects => _objects; + + /// Enters a nested level (a table cell, text box, note, group); dispose the result to leave it. + public Level Enter() + { + if (_depth >= Limits.MaxNestingDepth) + throw DocumentLimits.Exceeded($"Tables, text boxes, notes or groups are nested more than {Limits.MaxNestingDepth} levels deep."); + _depth++; + return new Level(this); + } + + /// Checks a count of open fields (fields inside fields). + public void Fields(int open) + { + if (open > Limits.MaxNestingDepth) + throw DocumentLimits.Exceeded($"Fields are nested more than {Limits.MaxNestingDepth} levels deep."); + } + + /// Counts a paragraph. + public void Paragraph() + { + if (++_paragraphs > Limits.MaxParagraphs) + throw DocumentLimits.Exceeded($"The document has more than {DocumentLimits.Count(Limits.MaxParagraphs)} paragraphs."); + } + + /// Counts a table, picture, shape, text box or equation. + public void Object() + { + if (++_objects > Limits.MaxObjects) + throw DocumentLimits.Exceeded($"The document has more than {DocumentLimits.Count(Limits.MaxObjects)} tables, pictures and drawing objects."); + } + + /// A nested level; leaves it when disposed. + public readonly struct Level(DocumentBudget budget) : IDisposable + { + public void Dispose() => budget._depth--; + } +} diff --git a/src/Filee.Engines/Documents/DocumentLimits.cs b/src/Filee.Engines/Documents/DocumentLimits.cs new file mode 100644 index 000000000..892cf8e07 --- /dev/null +++ b/src/Filee.Engines/Documents/DocumentLimits.cs @@ -0,0 +1,96 @@ +// How much of an untrusted document Filee reads and lays out, shared by the document readers (HWPX, DOCX, OFD, and +// the HWP 5.0 reader when it comes) and the layout engine, so a hostile file can't hang, exhaust the memory or crash +// Filee. Every limit is far above what real documents need: a document that passes one is refused with +// error.document_too_complex rather than read in part. +// +// Where they apply: +// * DocumentPackage ZIP packages: entries, bytes per part and in total (counted while unpacking, so a header that +// understates a part does not help), the inflate ratio, XML depth and elements (SafeXml). +// * DocumentBudget readers: nesting of tables, text boxes, notes, groups and fields; paragraphs and objects. +// * PictureSize pictures whose header claims more than MaxPicturePixels are left out. +// * LayoutGuard the layout loop: pages, empty pages in a row, the time budget and cancellation. +// A compound file (CFB) reader adds its stream sizes to MaxPartBytes / MaxPackageBytes and must check sector chains +// for cycles itself. +// +// Embedded fonts are not read by any reader (DOCX odttf parts, HWPX and OFD font files keep only their names). A +// reader that ever uses them must pass them through Filee's own SFNT parser (Fonts/SfntFont) before Skia or HarfBuzz. + +using System.Globalization; +using Filee.Core.Conversion; +using Filee.Engines.Infrastructure; + +namespace Filee.Engines.Documents; + +/// Limits for reading and laying out one document. +internal sealed record DocumentLimits +{ + /// The limits Filee uses. + public static DocumentLimits Default { get; } = new(); + + /// The error key of a document refused for passing a limit. + public const string ErrorKey = "error.document_too_complex"; + + /// Bytes one part of a package (an XML part, a picture) may unpack to. + public long MaxPartBytes { get; init; } = 64L * 1024 * 1024; + + /// Bytes all parts read from one package may unpack to together. + public long MaxPackageBytes { get; init; } = 1024L * 1024 * 1024; + + /// Entries in a package (real documents have a few per page or picture). + public int MaxPackageEntries { get; init; } = 100_000; + + /// + /// How many times its compressed size a part may unpack to, once it is larger than . + /// Deflate reaches about 1,000 on runs of one byte; XML from word processors stays far below 100. + /// + public int MaxInflateRatio { get; init; } = 200; + + /// What a part may always unpack to, whatever its ratio (small parts of repeated markup compress well). + public long InflateRatioFloor { get; init; } = 4L * 1024 * 1024; + + /// Levels of XML elements in a part. Tables nested deep stay well within. + public int MaxXmlDepth { get; init; } = 512; + + /// XML elements in all parts of a package together (each costs some 100 bytes of memory in the tree). + public int MaxXmlElements { get; init; } = 5_000_000; + + /// Levels of tables in tables, text boxes, notes, groups and fields. + public int MaxNestingDepth { get; init; } = 32; + + /// Paragraphs in a document (cells, notes, headers and text boxes included). + public int MaxParagraphs { get; init; } = 1_000_000; + + /// Tables, pictures, shapes, text boxes and equations in a document. + public int MaxObjects { get; init; } = 200_000; + + /// + /// Columns of a table (as many as a spreadsheet has). Column numbers and spans beyond are cut there, so a damaged + /// cell address can't make a table of two billion columns. + /// + public int MaxTableColumns { get; init; } = 16_384; + + /// Pages the layout engine makes of one document. + public int MaxPages { get; init; } = 20_000; + + /// Pixels of one picture (16,384 × 16,384, beyond every camera); bigger pictures are left out. + public long MaxPicturePixels { get; init; } = 16_384L * 16_384; + + /// Pages in a row with nothing placed on them before the layout counts as stuck. + public int MaxConsecutiveEmptyPages { get; init; } = 64; + + /// How long laying out one document may take. + public TimeSpan LayoutTimeBudget { get; init; } = TimeSpan.FromMinutes(10); + + /// The XML limits of one part, given how many elements the package's other parts held. + internal XmlLimits Xml(int elementsBefore, bool preserveWhitespace) => + new(MaxXmlDepth, MaxXmlElements, MaxPartBytes, preserveWhitespace, ElementsBefore: elementsBefore); + + /// The refusal for a document that passes a limit; says which (in English). + public static ConversionRefusedException Exceeded(string detail) => new(ErrorKey, detail); + + /// "64 MB" for messages. + internal static string Megabytes(long bytes) => (bytes / (1024 * 1024)).ToString("N0", CultureInfo.InvariantCulture) + " MB"; + + /// "20,000" for messages. + internal static string Count(long count) => count.ToString("N0", CultureInfo.InvariantCulture); +} diff --git a/src/Filee.Engines/Documents/DocumentPackage.cs b/src/Filee.Engines/Documents/DocumentPackage.cs new file mode 100644 index 000000000..0cccc7131 --- /dev/null +++ b/src/Filee.Engines/Documents/DocumentPackage.cs @@ -0,0 +1,240 @@ +// The ZIP package of a document (HWPX, DOCX, OFD), read within DocumentLimits: parts are looked up by name inside the +// package only, every part is counted while it is unpacked (its own size, the package's total and the inflate ratio), +// XML is read by SafeXml without DTDs or a resolver, and parts are only ever written out under names Filee makes. +// A damaged package fails with InvalidDataException, one that passes a limit with error.document_too_complex. + +using System.IO.Compression; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Filee.Engines.Infrastructure; + +namespace Filee.Engines.Documents; + +/// A document package opened for reading. +internal sealed class DocumentPackage : IDisposable +{ + private readonly ZipArchive _zip; + private long _total; + private int _elements; + + private DocumentPackage(ZipArchive zip, DocumentLimits limits) + { + _zip = zip; + Limits = limits; + } + + public DocumentLimits Limits { get; } + + /// Bytes unpacked so far. + public long BytesRead => _total; + + /// XML elements read so far. + public int ElementsRead => _elements; + + public IReadOnlyCollection Entries => _zip.Entries; + + /// Opens the package at ; makes the error for a file that is no ZIP. + public static DocumentPackage Open(string path, DocumentLimits? limits = null, Func? notAPackage = null) => + Open(() => ZipFile.OpenRead(path), limits ?? DocumentLimits.Default, notAPackage); + + /// A package in a stream (left open). + public static DocumentPackage Open(Stream stream, DocumentLimits? limits = null, Func? notAPackage = null) => + Open(() => new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true), limits ?? DocumentLimits.Default, notAPackage); + + private static DocumentPackage Open(Func open, DocumentLimits limits, Func? notAPackage) + { + ZipArchive zip; + try + { + zip = open(); + } + catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException or ArgumentException or NotSupportedException + || ex is IOException and not (FileNotFoundException or DirectoryNotFoundException)) + { + throw notAPackage?.Invoke() ?? new InvalidDataException("The file is not a ZIP package. It may be damaged.", ex); + } + try + { + // The central directory is read when the entries are first listed. + if (zip.Entries.Count > limits.MaxPackageEntries) + throw DocumentLimits.Exceeded($"The package has more than {DocumentLimits.Count(limits.MaxPackageEntries)} parts."); + } + catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException or ArgumentException or IOException) + { + zip.Dispose(); + throw notAPackage?.Invoke() ?? new InvalidDataException("The file is not a ZIP package. It may be damaged.", ex); + } + catch + { + zip.Dispose(); + throw; + } + return new DocumentPackage(zip, limits); + } + + public void Dispose() => _zip.Dispose(); + + /// The entry of that exact name; null when there is none. + public ZipArchiveEntry? GetEntry(string name) + { + try + { + return name.Length == 0 ? null : _zip.GetEntry(name); + } + catch (ArgumentException) + { + return null; + } + } + + /// + /// The unpacked bytes of , counted against the part, package and ratio limits. A part whose + /// header already claims too much is refused before it is unpacked. + /// + public Stream Open(ZipArchiveEntry entry) + { + var name = entry.FullName; + if (entry.Length > Limits.MaxPartBytes) + throw PartTooLarge(name); + if (_total + entry.Length > Limits.MaxPackageBytes) + throw PackageTooLarge(); + Stream inner; + try + { + inner = entry.Open(); + } + catch (Exception ex) when (ex is IOException or NotSupportedException or ArgumentException) + { + throw new InvalidDataException($"The part {name} of the package can't be unpacked ({ex.Message}).", ex); + } + var compressed = Math.Max(1, entry.CompressedLength); + long counted = 0; + return new LimitedStream(new DamageStream(inner, name), Limits.MaxPartBytes, () => PartTooLarge(name), read => + { + _total += read - counted; + counted = read; + if (_total > Limits.MaxPackageBytes) + throw PackageTooLarge(); + if (read > Limits.InflateRatioFloor && read / compressed > Limits.MaxInflateRatio) + throw DocumentLimits.Exceeded($"The part {name} unpacks to more than {Limits.MaxInflateRatio} times its packed size."); + }); + } + + /// + /// An XML part read by ; its elements count against the package's. Throws + /// for a part that is not well-formed XML (callers say which part of which format). + /// + public XDocument LoadXml(ZipArchiveEntry entry, bool preserveWhitespace = true) + { + using var stream = Open(entry); + try + { + var document = SafeXml.Load(stream, Limits.Xml(_elements, preserveWhitespace), + message => DocumentLimits.Exceeded($"{entry.FullName}: {message}"), out var elements); + _elements += elements; + return document; + } + catch (Exception ex) when (ex is DecoderFallbackException or ArgumentException) + { + throw new XmlException(ex.Message, ex); + } + } + + /// The bytes of a part; null when it holds more than (the part is then left out). + public byte[]? ReadBytes(ZipArchiveEntry entry, long max) + { + if (entry.Length > max) + return null; + using var stream = Open(entry); + using var copy = new MemoryStream((int)Math.Min(entry.Length, 1024 * 1024)); + var buffer = new byte[81920]; + int count; + while ((count = stream.Read(buffer)) > 0) + { + if (copy.Length + count > max) + return null; + copy.Write(buffer, 0, count); + } + return copy.ToArray(); + } + + /// + /// Unpacks a picture into a new file at . Returns false (and leaves no file) when its + /// header claims more than . + /// + public bool ExtractPicture(ZipArchiveEntry entry, string path) + { + using (var source = Open(entry)) + using (var target = File.Create(path)) + source.CopyTo(target); + if (PictureSize.Read(path) is { } size && (long)size.Width * size.Height > Limits.MaxPicturePixels) + { + File.Delete(path); + return false; + } + return true; + } + + /// + /// The extension of a part name for a file Filee writes: "." and up to 8 ASCII letters or digits, else "" (a name + /// in a hostile package may hold anything, ":stream" included). + /// + public static string SafeExtension(string name) + { + var dot = name.LastIndexOf('.'); + if (dot < 0 || name.Length - dot - 1 is < 1 or > 8 || name.AsSpan(dot + 1).ContainsAnyExcept(AsciiLettersAndDigits)) + return ""; + return name[dot..].ToLowerInvariant(); + } + + private static readonly System.Buffers.SearchValues AsciiLettersAndDigits = + System.Buffers.SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"); + + /// Reads a part; a package cut short or damaged inside a part is invalid data, not a failing disk. + private sealed class DamageStream(Stream inner, string name) : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + public override int Read(Span buffer) + { + try + { + return inner.Read(buffer); + } + catch (IOException ex) + { + throw new InvalidDataException($"The part {name} of the package is damaged ({ex.Message}).", ex); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + inner.Dispose(); + base.Dispose(disposing); + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + private Exception PartTooLarge(string name) => + DocumentLimits.Exceeded($"The part {name} unpacks to more than {DocumentLimits.Megabytes(Limits.MaxPartBytes)}."); + + private Exception PackageTooLarge() => + DocumentLimits.Exceeded($"The package unpacks to more than {DocumentLimits.Megabytes(Limits.MaxPackageBytes)}."); +} diff --git a/src/Filee.Engines/Documents/LayoutGuard.cs b/src/Filee.Engines/Documents/LayoutGuard.cs new file mode 100644 index 000000000..79c142fac --- /dev/null +++ b/src/Filee.Engines/Documents/LayoutGuard.cs @@ -0,0 +1,71 @@ +// Guards for the layout loop, so a document can't keep the layout engine busy forever: every paragraph checks the +// cancellation token and the time budget, every page counts against MaxPages, and pages in a row that receive nothing +// mean the layout is stuck. An object larger than every page is clipped to the area it goes into and placed, so it +// can't push itself from page to page without end. + +using System.Diagnostics; + +namespace Filee.Engines.Documents; + +/// Checks a layout run against . +internal sealed class LayoutGuard +{ + private readonly DocumentLimits _limits; + private readonly CancellationToken _cancellationToken; + private readonly Func _elapsed; + private int _pages; + private int _emptyInARow; + + /// Time since the layout started (tests pass their own clock). + public LayoutGuard(DocumentLimits limits, CancellationToken cancellationToken, Func? elapsed = null) + { + _limits = limits; + _cancellationToken = cancellationToken; + if (elapsed is null) + { + var start = Stopwatch.GetTimestamp(); + elapsed = () => Stopwatch.GetElapsedTime(start); + } + _elapsed = elapsed; + } + + /// Pages finished so far. + public int Pages => _pages; + + /// Called before each paragraph is laid out: throws when the job was cancelled or the time is up. + /// The job was cancelled. + public void Paragraph() + { + _cancellationToken.ThrowIfCancellationRequested(); + if (_elapsed() > _limits.LayoutTimeBudget) + throw DocumentLimits.Exceeded($"Laying out the document took longer than {Describe(_limits.LayoutTimeBudget)}."); + } + + private static string Describe(TimeSpan time) => time.TotalMinutes >= 1 + ? time.TotalMinutes == 1 ? "1 minute" : $"{time.TotalMinutes:0} minutes" + : time.TotalSeconds == 1 ? "1 second" : $"{time.TotalSeconds:0} seconds"; + + /// Called when a page is finished; when nothing was placed on it. + public void PageFinished(bool empty) + { + if (++_pages > _limits.MaxPages) + throw DocumentLimits.Exceeded($"The document has more than {DocumentLimits.Count(_limits.MaxPages)} pages."); + _emptyInARow = empty ? _emptyInARow + 1 : 0; + if (_emptyInARow > _limits.MaxConsecutiveEmptyPages) + throw DocumentLimits.Exceeded($"The layout made more than {_limits.MaxConsecutiveEmptyPages} empty pages in a row."); + } + + /// + /// The size an object is placed with in an area of × + /// (the body of an empty page): its own size when it fits, else clipped to the area. Clipped tells the + /// layout to draw only that part rather than move the object to the next page again. Sizes that are not finite + /// numbers or negative count as 0. + /// + public static (double Width, double Height, bool Clipped) Fit(double width, double height, double areaWidth, double areaHeight) + { + static double Clean(double value) => double.IsFinite(value) && value > 0 ? value : 0; + (width, height, areaWidth, areaHeight) = (Clean(width), Clean(height), Clean(areaWidth), Clean(areaHeight)); + var clipped = width > areaWidth || height > areaHeight; + return (Math.Min(width, areaWidth), Math.Min(height, areaHeight), clipped); + } +} diff --git a/src/Filee.Engines/Documents/PictureSize.cs b/src/Filee.Engines/Documents/PictureSize.cs new file mode 100644 index 000000000..ce944b0a1 --- /dev/null +++ b/src/Filee.Engines/Documents/PictureSize.cs @@ -0,0 +1,146 @@ +// The pixel size a picture's header claims, read without decoding it, so a picture that would take gigabytes once +// decoded (a few kilobytes of PNG can claim 100,000 × 100,000 pixels) is left out before anything decodes it. PNG, +// JPEG, GIF, BMP and WebP; other formats (TIFF, EMF, WMF, SVG) give no size here and are checked where they are drawn. + +using System.Buffers.Binary; + +namespace Filee.Engines.Documents; + +/// Reads the width and height from a picture's header. +internal static class PictureSize +{ + /// JPEG segments looked at before the frame header (files have a dozen). + private const int MaxJpegSegments = 1000; + + /// The size in pixels; null for formats it does not know and headers it can't read. + public static (int Width, int Height)? Read(string path) + { + try + { + using var stream = File.OpenRead(path); + return Read(stream); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + /// + public static (int Width, int Height)? Read(Stream stream) + { + Span head = stackalloc byte[30]; + var length = ReadUpTo(stream, head); + head = head[..length]; + if (head is [0x89, (byte)'P', (byte)'N', (byte)'G', ..] && head.Length >= 24) + return Size(BinaryPrimitives.ReadUInt32BigEndian(head[16..]), BinaryPrimitives.ReadUInt32BigEndian(head[20..])); + if (head is [(byte)'G', (byte)'I', (byte)'F', (byte)'8', ..] && head.Length >= 10) + return Size(BinaryPrimitives.ReadUInt16LittleEndian(head[6..]), BinaryPrimitives.ReadUInt16LittleEndian(head[8..])); + if (head is [(byte)'B', (byte)'M', ..] && head.Length >= 26) + { + var header = BinaryPrimitives.ReadUInt32LittleEndian(head[14..]); + return header == 12 + ? Size(BinaryPrimitives.ReadUInt16LittleEndian(head[18..]), BinaryPrimitives.ReadUInt16LittleEndian(head[20..])) + : Size((uint)Math.Abs((long)BinaryPrimitives.ReadInt32LittleEndian(head[18..])), (uint)Math.Abs((long)BinaryPrimitives.ReadInt32LittleEndian(head[22..]))); + } + if (head is [(byte)'R', (byte)'I', (byte)'F', (byte)'F', _, _, _, _, (byte)'W', (byte)'E', (byte)'B', (byte)'P', ..] && head.Length >= 30) + return WebP(head); + if (head is [0xFF, 0xD8, ..]) + return Jpeg(stream, head); + return null; + } + + private static (int Width, int Height)? WebP(ReadOnlySpan head) + { + var chunk = head.Slice(12, 4); + if (chunk.SequenceEqual("VP8X"u8)) + { + var width = head[24] | head[25] << 8 | head[26] << 16; + var height = head[27] | head[28] << 8 | head[29] << 16; + return Size((uint)width + 1, (uint)height + 1); + } + if (chunk.SequenceEqual("VP8L"u8) && head[20] == 0x2F) + { + var bits = BinaryPrimitives.ReadUInt32LittleEndian(head[21..]); + return Size((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1); + } + if (chunk.SequenceEqual("VP8 "u8)) + return Size(BinaryPrimitives.ReadUInt16LittleEndian(head[26..]) & 0x3FFFu, BinaryPrimitives.ReadUInt16LittleEndian(head[28..]) & 0x3FFFu); + return null; + } + + /// The first frame header (SOFn) after the segments before it. + private static (int Width, int Height)? Jpeg(Stream stream, ReadOnlySpan head) + { + // Continue from byte 2 of the file: the head was read already. + var buffered = new Queue(head[2..].ToArray()); + int Next() + { + if (buffered.Count > 0) + return buffered.Dequeue(); + return stream.ReadByte(); + } + + Span frame = stackalloc byte[5]; + for (var segment = 0; segment < MaxJpegSegments; segment++) + { + var marker = Next(); + if (marker < 0) + return null; + if (marker != 0xFF) + return null; + var code = Next(); + while (code == 0xFF) + code = Next(); + if (code < 0 || code is 0xD9 or 0xDA) + return null; // end of image or scan data before any frame header + if (code is 0x01 or (>= 0xD0 and <= 0xD7)) + continue; // markers without a length + var high = Next(); + var low = Next(); + if (high < 0 || low < 0) + return null; + var length = high << 8 | low; + if (length < 2) + return null; + if (code is >= 0xC0 and <= 0xCF and not (0xC4 or 0xC8 or 0xCC)) + { + for (var i = 0; i < frame.Length; i++) + { + var b = Next(); + if (b < 0) + return null; + frame[i] = (byte)b; + } + return Size(BinaryPrimitives.ReadUInt16BigEndian(frame[3..]), BinaryPrimitives.ReadUInt16BigEndian(frame[1..])); + } + var skip = length - 2; + for (; skip > 0 && buffered.Count > 0; skip--) + buffered.Dequeue(); + if (stream.CanSeek) + { + if (stream.Seek(skip, SeekOrigin.Current) > stream.Length) + return null; + continue; + } + for (; skip > 0; skip--) + { + if (Next() < 0) + return null; + } + } + return null; + } + + private static (int Width, int Height)? Size(uint width, uint height) => + width == 0 || height == 0 ? null : ((int)Math.Min(width, int.MaxValue), (int)Math.Min(height, int.MaxValue)); + + private static int ReadUpTo(Stream stream, Span buffer) + { + var total = 0; + int read; + while (total < buffer.Length && (read = stream.Read(buffer[total..])) > 0) + total += read; + return total; + } +} diff --git a/src/Filee.Engines/Geo/KmlFormat.cs b/src/Filee.Engines/Geo/KmlFormat.cs index c6c35c379..c22612bdc 100644 --- a/src/Filee.Engines/Geo/KmlFormat.cs +++ b/src/Filee.Engines/Geo/KmlFormat.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Xml; using System.Xml.Linq; +using Filee.Engines.Infrastructure; namespace Filee.Engines.Geo; @@ -409,67 +410,15 @@ internal static class GeoXml /// /// Parses XML without DTDs or external entities, so a hostile file can't read files or expand into gigabytes, and - /// refuses elements nested more than levels. The tree is built here from the reader - /// rather than by XDocument.Load, which takes time quadratic in the depth (a 1.4 MB file nested 100,000 deep - /// kept it busy for 48 seconds, the reader alone needs a quarter of a second) and so couldn't be allowed to see such - /// a file. Namespace declarations and prefixes are not kept; names are the local name and namespace. + /// refuses elements nested more than levels (see ). Namespace + /// declarations and prefixes are not kept; names are the local name and namespace. /// public static XDocument Load(Stream stream, int maxElements = MaxElements) { - var settings = new XmlReaderSettings - { - DtdProcessing = DtdProcessing.Prohibit, - XmlResolver = null, - MaxCharactersFromEntities = 0, - CheckCharacters = false, - // About 1 GB of memory at the most: the tree takes some 16 bytes for every character. - MaxCharactersInDocument = 1L << 26, - }; try { - using var reader = XmlReader.Create(stream, settings); - var document = new XDocument(); - XContainer current = document; - var depth = 0; - var elements = 0; - while (reader.Read()) - { - switch (reader.NodeType) - { - case XmlNodeType.Element: - if (++depth > MaxElementDepth) - throw new InvalidDataException($"The file nests elements more than {MaxElementDepth} levels deep."); - if (++elements > maxElements) - throw new InvalidDataException("The file has more than " + maxElements.ToString("N0", System.Globalization.CultureInfo.InvariantCulture) + " elements."); - var element = new XElement(XName.Get(reader.LocalName, reader.NamespaceURI)); - var empty = reader.IsEmptyElement; - if (reader.HasAttributes) - { - while (reader.MoveToNextAttribute()) - { - if (reader.NamespaceURI != "http://www.w3.org/2000/xmlns/") - element.SetAttributeValue(XName.Get(reader.LocalName, reader.NamespaceURI), reader.Value); - } - reader.MoveToElement(); - } - current.Add(element); - if (empty) - depth--; - else - current = element; - break; - case XmlNodeType.EndElement: - current = current.Parent ?? (XContainer)document; - depth--; - break; - case XmlNodeType.Text or XmlNodeType.CDATA or XmlNodeType.SignificantWhitespace or XmlNodeType.Whitespace: - // Text outside the root element (white space between declarations) is no content. - if (current is XElement) - current.Add(new XText(reader.Value)); - break; - } - } - return document; + // About 1 GB of memory at the most: the tree takes some 16 bytes for every character. + return SafeXml.Load(stream, new XmlLimits(MaxElementDepth, maxElements, 1L << 26, KeepNamespaceDeclarations: false, CheckCharacters: false)); } catch (XmlException ex) { diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs index c70003a59..0d8a43885 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Xml.Linq; +using Filee.Engines.Documents; namespace Filee.Engines.Hwp.Hwpx.Docx; @@ -58,6 +59,7 @@ private IEnumerable GraphicObjects(IEnumerable objects, int w { foreach (var obj in objects) { + _budget.Object(); if (obj.Name == Pic + "pic") { if (Picture(obj, width, height, anchor) is { } picture) @@ -79,6 +81,7 @@ private IEnumerable GraphicObjects(IEnumerable objects, int w private IEnumerable Group(XElement group, int width, int height, HAnchor? anchor, (double X, double Y, double ScaleX, double ScaleY) parent) { + using var level = _budget.Enter(); var xfrm = group.Element(Wpg + "grpSpPr")?.Element(A + "xfrm") ?? group.Element(A + "grpSpPr")?.Element(A + "xfrm"); var chOff = xfrm?.Element(A + "chOff"); var chExt = xfrm?.Element(A + "chExt"); @@ -230,6 +233,7 @@ private HTextBox TextBox(XElement wsp, XElement content, int width, int height, /// Text box paragraphs; Word's document grid does not apply inside text boxes. private void ReadTextBoxContent(XElement content, List output) { + using var level = _budget.Enter(); _textBoxDepth++; try { @@ -400,13 +404,12 @@ private static int OffsetOf(XElement? position) => return null; if (_media.TryGetValue(rel.Target, out var existing)) return existing; - if (_zip.GetEntry(rel.Target) is not { } entry) + if (_package.GetEntry(rel.Target) is not { } entry) return null; Directory.CreateDirectory(_mediaFolder); - var path = Path.Combine(_mediaFolder, $"docx-media-{_media.Count + 1}{Path.GetExtension(rel.Target)}"); - using (var source = entry.Open()) - using (var target = File.Create(path)) - source.CopyTo(target); + string? path = Path.Combine(_mediaFolder, $"docx-media-{_media.Count + 1}{DocumentPackage.SafeExtension(rel.Target)}"); + if (!_package.ExtractPicture(entry, path)) + path = null; // larger than any picture should be: left out _media[rel.Target] = path; return path; } @@ -422,10 +425,12 @@ private IEnumerable Vml(XElement container, HCharFormat format) continue; if (shape.Name.LocalName == "group") { + using var level = _budget.Enter(); foreach (var inline in Vml(shape, format)) yield return inline; continue; } + _budget.Object(); if (shape.Name.LocalName is "shapetype") continue; diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs index 328980fe2..537fcc603 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs @@ -294,8 +294,10 @@ private void LoadNumbering(Part? part) var root = part?.Xml.Root; if (root is null) return; - var abstracts = root.Elements(W + "abstractNum") - .ToDictionary(a => Attr(a, "abstractNumId") ?? "", a => a); + // Of two definitions with one id (a damaged file) the first counts. + var abstracts = new Dictionary(); + foreach (var abstractNum in root.Elements(W + "abstractNum")) + abstracts.TryAdd(Attr(abstractNum, "abstractNumId") ?? "", abstractNum); foreach (var num in root.Elements(W + "num")) { var numId = Attr(num, "numId") ?? ""; @@ -335,7 +337,11 @@ static void ApplyLevel(NumberingLevel[] levels, XElement lvl) // ───────────────────────── Resolution ───────────────────────── /// Merged properties of a paragraph or character style including everything it is based on. - private (ParaProps Para, RunProps Run) ResolveStyle(string? styleId) + private (ParaProps Para, RunProps Run) ResolveStyle(string? styleId) => ResolveStyle(styleId, 0); + + /// Styles below this one in the chain: a chain longer than (a + /// damaged or hostile file; real chains are a few long) starts from the defaults instead of overflowing the stack. + private (ParaProps Para, RunProps Run) ResolveStyle(string? styleId, int depth) { if (styleId is null || !_styles.TryGetValue(styleId, out var style)) return (_defaultPara.Clone(), _defaultRun.Clone()); @@ -344,7 +350,9 @@ static void ApplyLevel(NumberingLevel[] levels, XElement lvl) // Guard against basedOn cycles in damaged files. _resolvedStyles[styleId] = (_defaultPara.Clone(), _defaultRun.Clone()); - var (para, run) = style.BasedOn is { } parent && parent != styleId ? ResolveStyle(parent) : (_defaultPara.Clone(), _defaultRun.Clone()); + var (para, run) = style.BasedOn is { } parent && parent != styleId && depth < MaxStyleChain + ? ResolveStyle(parent, depth + 1) + : (_defaultPara.Clone(), _defaultRun.Clone()); para.Apply(style.PPr); run.Apply(style.RPr); _resolvedStyles[styleId] = (para.Clone(), run.Clone()); @@ -387,7 +395,7 @@ private RunProps RunProperties(XElement? rPr, RunProps paragraphRun) { // Character styles only carry run properties; apply their chain on top of the paragraph's. var chain = new List