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