From fdd6da7df0e3288fadecdb46ccd0198813246173 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:10:12 +0900 Subject: [PATCH 01/23] feat(attachments): defer HWP and HWPX recognition safely --- backend/services/attachment_parser.py | 126 ++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 20 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 868b9b183..1e77613cc 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -2,6 +2,8 @@ import base64 import binascii +import io +import zipfile from dataclasses import dataclass from pathlib import Path from typing import Any @@ -14,6 +16,17 @@ "binary/octet-stream", "application/x-binary", } +_HWPX_CONTENT_TYPES = ( + "application/hwp+zip", + "application/x-hwp+zip", + "application/vnd.hancom.hwpx", +) +_HWP_CONTENT_TYPES = ( + "application/x-hwp", + "application/vnd.hancom.hwp", + "application/haansofthwp", +) +_HWP_OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" MAX_ATTACHMENT_PARSE_SOURCE_CHARS = 1_000_000 MAX_ATTACHMENT_PARSE_SOURCE_BYTES = 20 * 1024 * 1024 @@ -86,6 +99,20 @@ class AttachmentParserDescriptor: extensions=(".pdf",), parse_status="pdf_dom_recognition_pending", ), + AttachmentParserDescriptor( + parser_key="hwpx", + display_name="HWPX documents (OWPML XML package recognition)", + content_types=_HWPX_CONTENT_TYPES, + extensions=(".hwpx", ".owpml"), + parse_status="hwpx_xml_package_pending", + ), + AttachmentParserDescriptor( + parser_key="hwp", + display_name="HWP binary documents (sandboxed conversion)", + content_types=_HWP_CONTENT_TYPES, + extensions=(".hwp",), + parse_status="hwp_conversion_pending", + ), AttachmentParserDescriptor( parser_key="unsupported_binary", display_name="Unsupported binary attachments", @@ -96,8 +123,14 @@ class AttachmentParserDescriptor: ) # Statuses whose recognition is too heavy to run inline during import. The # attachment is stored with the pending status and a background worker later -# calls the NewsDOM sidecar to fill in parse_content + the content graph. -_DEFERRED_PARSE_STATUSES = frozenset({"pdf_dom_recognition_pending"}) +# calls a sandboxed recognizer/converter to fill parse_content and content graph. +_DEFERRED_PARSE_STATUSES = frozenset( + { + "pdf_dom_recognition_pending", + "hwpx_xml_package_pending", + "hwp_conversion_pending", + } +) _SUPPORTED_CONTENT_TYPES = { content_type for descriptor in _PARSER_MANIFEST @@ -117,6 +150,11 @@ class AttachmentParserDescriptor: or descriptor.parse_status in _DEFERRED_PARSE_STATUSES for extension in descriptor.extensions } +_DEFERRED_PAYLOAD_ERROR_MESSAGES = { + "invalid_pdf_payload": "Pending attachment payload is not a PDF", + "invalid_hwpx_payload": "Pending attachment payload is not a HWPX package", + "invalid_hwp_payload": "Pending attachment payload is not a HWP binary document", +} @dataclass(frozen=True) @@ -154,13 +192,13 @@ def parse_email_attachment( deferred_descriptor = _DEFERRED_DESCRIPTORS_BY_CONTENT_TYPE.get(parse_content_type) if deferred_descriptor is not None: - # Heavy recognition (OCR/MinerU via the NewsDOM sidecar) must not run - # inline during import. Retain the raw bytes as a base64 payload in - # ``content`` (mirroring the document-upload path's document_content) so - # the worker can decode and recognize them later; mark the attachment - # pending. The pending status gates display, and the worker overwrites - # ``content`` with the recognized text on success. Without this the - # source bytes were discarded and recognition was impossible. + # Heavy recognition (OCR/MinerU, HWPX XML section extraction, or HWP + # binary conversion) must not run inline during import. Retain the raw + # bytes as a base64 payload in ``content`` so the worker can recognize + # them later; mark the attachment pending. The pending status gates + # display, and the worker overwrites ``content`` with recognized text on + # success. Without this the source bytes are discarded and recognition + # is impossible. deferred_payload = _coerce_deferred_payload_bytes(raw_content) if len(deferred_payload) > MAX_ATTACHMENT_PARSE_SOURCE_BYTES: return AttachmentParseResult( @@ -173,9 +211,11 @@ def parse_email_attachment( parse_status="parse_size_limit_exceeded", parse_error_code="parse_size_limit_exceeded", ) - if parse_content_type == "application/pdf" and not deferred_payload.startswith( - b"%PDF-" - ): + payload_error_code = _deferred_payload_error_code( + parse_content_type, + deferred_payload, + ) + if payload_error_code is not None: return AttachmentParseResult( filename=safe_filename, content="", @@ -183,8 +223,8 @@ def parse_email_attachment( parse_content="", parse_content_type=parse_content_type, parser_key=deferred_descriptor.parser_key, - parse_status="invalid_pdf_payload", - parse_error_code="invalid_pdf_payload", + parse_status=payload_error_code, + parse_error_code=payload_error_code, ) return AttachmentParseResult( filename=safe_filename, @@ -289,23 +329,69 @@ def _encode_deferred_payload(payload: bytes) -> str: return base64.b64encode(payload).decode("ascii") -def decode_deferred_attachment_payload(content: str | None) -> bytes: +def decode_deferred_attachment_payload( + content: str | None, + expected_content_type: str = "application/pdf", +) -> bytes: """Decode the base64 payload retained on a pending attachment's content. - Raises ``ValueError`` when the stored payload is not valid base64, so the - recognition worker can record an error status instead of crashing. + Raises ``ValueError`` when the stored payload is not valid base64 or no + longer matches the expected deferred parser family, so the recognition + worker can record an error status instead of crashing. """ + parse_content_type = _normalize_content_type(expected_content_type) try: payload = base64.b64decode((content or "").encode("ascii"), validate=True) except (binascii.Error, UnicodeEncodeError, ValueError) as exc: raise ValueError("Pending attachment payload is not valid base64") from exc if len(payload) > MAX_ATTACHMENT_PARSE_SOURCE_BYTES: - raise ValueError("Pending attachment PDF exceeds the parse size limit") - if not payload.startswith(b"%PDF-"): - raise ValueError("Pending attachment payload is not a PDF") + raise ValueError("Pending attachment payload exceeds the parse size limit") + payload_error_code = _deferred_payload_error_code(parse_content_type, payload) + if payload_error_code is not None: + raise ValueError(_DEFERRED_PAYLOAD_ERROR_MESSAGES[payload_error_code]) return payload +def _deferred_payload_error_code( + parse_content_type: str, + payload: bytes, +) -> str | None: + """Return an error code when deferred parser bytes fail a cheap signature.""" + if parse_content_type == "application/pdf" and not payload.startswith(b"%PDF-"): + return "invalid_pdf_payload" + if parse_content_type in _HWPX_CONTENT_TYPES and not _is_hwpx_payload(payload): + return "invalid_hwpx_payload" + if parse_content_type in _HWP_CONTENT_TYPES and not payload.startswith( + _HWP_OLE_MAGIC + ): + return "invalid_hwp_payload" + return None + + +def _is_hwpx_payload(payload: bytes) -> bool: + """Return whether bytes look like a HWPX/OWPML ZIP package. + + The check intentionally inspects only bounded ZIP metadata and file names. + It does not decompress section XML, execute active content, or fetch external + resources during import. + """ + if not payload.startswith(b"PK"): + return False + try: + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + names = set(archive.namelist()) + except (zipfile.BadZipFile, ValueError): + return False + has_manifest = "Contents/content.hpf" in names or "META-INF/manifest.xml" in names + has_section = any( + name.startswith("Contents/section") and name.endswith(".xml") + for name in names + ) + has_version = "version.xml" in names + has_mimetype = "mimetype" in names + return has_mimetype and has_version and (has_manifest or has_section) + + def _coerce_text(raw_content: Any) -> str: """Coerce arbitrary attachment content to NUL-free text.""" if raw_content is None: From f8698c237ff3bf18dbf3647ea3fac6ab64187b4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:10:45 +0900 Subject: [PATCH 02/23] test(attachments): cover HWP and HWPX deferred recognition --- backend/tests/test_attachment_parser.py | 139 ++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index ad2dd892d..c42111be6 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -1,4 +1,6 @@ import base64 +import io +import zipfile import pytest @@ -11,6 +13,22 @@ ) +def _minimal_hwpx_bytes() -> bytes: + """Build a tiny HWPX-like XML package without decompressing fixtures.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("mimetype", "application/hwp+zip") + archive.writestr("version.xml", "") + archive.writestr("Contents/content.hpf", "") + archive.writestr("Contents/section0.xml", "

계약 검토

") + return buffer.getvalue() + + +def _minimal_hwp_bytes() -> bytes: + """Build a minimal OLE-signature HWP binary sentinel fixture.""" + return b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1HWP Binary Body" + + def test_html_attachment_preserves_parse_source_and_safe_display_text(): result = parse_email_attachment( filename="report.html", @@ -55,6 +73,8 @@ def test_parser_manifest_lists_supported_and_unsupported_format_families(): "xml", "calendar", "pdf", + "hwpx", + "hwp", "unsupported_binary", } <= parser_keys markdown_descriptor = next( @@ -72,6 +92,16 @@ def test_parser_manifest_lists_supported_and_unsupported_format_families(): ) assert "text/calendar" in calendar_descriptor.content_types assert ".ics" in calendar_descriptor.extensions + hwpx_descriptor = next( + descriptor for descriptor in manifest if descriptor.parser_key == "hwpx" + ) + assert "application/hwp+zip" in hwpx_descriptor.content_types + assert ".hwpx" in hwpx_descriptor.extensions + hwp_descriptor = next( + descriptor for descriptor in manifest if descriptor.parser_key == "hwp" + ) + assert "application/x-hwp" in hwp_descriptor.content_types + assert ".hwp" in hwp_descriptor.extensions def test_generic_binary_content_type_can_fall_back_to_markdown_extension(): @@ -255,3 +285,112 @@ def test_deferred_pdf_decoder_rejects_non_pdf_and_oversized_payloads(monkeypatch oversized = base64.b64encode(b"%PDF-1.7").decode("ascii") with pytest.raises(ValueError, match="size limit"): decode_deferred_attachment_payload(oversized) + + +def test_hwpx_attachment_is_deferred_for_structured_xml_package(): + raw = _minimal_hwpx_bytes() + result = parse_email_attachment( + filename="proposal.hwpx", + content_type="application/hwp+zip", + raw_content=raw, + ) + + assert result.filename == "proposal.hwpx" + assert result.content_type == "application/hwp+zip" + assert result.parse_content == "" + assert result.parse_content_type == "application/hwp+zip" + assert result.parser_key == "hwpx" + assert result.parse_status == "hwpx_xml_package_pending" + assert result.parse_error_code is None + assert decode_deferred_attachment_payload( + result.content, + "application/hwp+zip", + ) == raw + + +def test_hwpx_extension_with_generic_content_type_is_deferred_pending(): + raw = _minimal_hwpx_bytes() + result = parse_email_attachment( + filename="proposal.hwpx", + content_type="application/octet-stream", + raw_content=raw, + ) + + assert result.content_type == "application/octet-stream" + assert result.parse_content_type == "application/hwp+zip" + assert result.parser_key == "hwpx" + assert result.parse_status == "hwpx_xml_package_pending" + + +def test_invalid_hwpx_payload_is_rejected_before_xml_package_recognition(): + result = parse_email_attachment( + filename="broken.hwpx", + content_type="application/hwp+zip", + raw_content=b"PK\x03\x04not really a package", + ) + + assert result.content == "" + assert result.parse_content_type == "application/hwp+zip" + assert result.parser_key == "hwpx" + assert result.parse_status == "invalid_hwpx_payload" + assert result.parse_error_code == "invalid_hwpx_payload" + + +def test_deferred_hwpx_decoder_rejects_non_hwpx_payload(): + not_hwpx = base64.b64encode(b"PK\x03\x04not a usable hwpx").decode("ascii") + with pytest.raises(ValueError, match="not a HWPX"): + decode_deferred_attachment_payload(not_hwpx, "application/hwp+zip") + + +def test_hwp_attachment_is_deferred_for_sandboxed_conversion(): + raw = _minimal_hwp_bytes() + result = parse_email_attachment( + filename="legacy.hwp", + content_type="application/x-hwp", + raw_content=raw, + ) + + assert result.filename == "legacy.hwp" + assert result.content_type == "application/x-hwp" + assert result.parse_content == "" + assert result.parse_content_type == "application/x-hwp" + assert result.parser_key == "hwp" + assert result.parse_status == "hwp_conversion_pending" + assert result.parse_error_code is None + assert decode_deferred_attachment_payload( + result.content, + "application/x-hwp", + ) == raw + + +def test_hwp_extension_with_generic_content_type_is_deferred_pending(): + result = parse_email_attachment( + filename="legacy.hwp", + content_type="application/octet-stream", + raw_content=_minimal_hwp_bytes(), + ) + + assert result.content_type == "application/octet-stream" + assert result.parse_content_type == "application/x-hwp" + assert result.parser_key == "hwp" + assert result.parse_status == "hwp_conversion_pending" + + +def test_invalid_hwp_payload_is_rejected_before_sandboxed_conversion(): + result = parse_email_attachment( + filename="not-hwp.hwp", + content_type="application/x-hwp", + raw_content=b"plain bytes", + ) + + assert result.content == "" + assert result.parse_content_type == "application/x-hwp" + assert result.parser_key == "hwp" + assert result.parse_status == "invalid_hwp_payload" + assert result.parse_error_code == "invalid_hwp_payload" + + +def test_deferred_hwp_decoder_rejects_non_hwp_payload(): + not_hwp = base64.b64encode(b"plain bytes").decode("ascii") + with pytest.raises(ValueError, match="not a HWP binary"): + decode_deferred_attachment_payload(not_hwp, "application/x-hwp") From a730d74d85729baf3308b4cf3dff0047a4fa3d12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:12:16 +0900 Subject: [PATCH 03/23] docs(attachments): record HWP and HWPX recognition boundary --- .../hwp-hwpx-attachment-recognition.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/doctoring/hwp-hwpx-attachment-recognition.md diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md new file mode 100644 index 000000000..b3fefe16e --- /dev/null +++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md @@ -0,0 +1,66 @@ +# HWP and HWPX attachment-recognition boundary + +## Decision + +Naruon recognizes HWPX and HWP attachments before OCR, XML extraction, or LLM +processing. The importer does not parse document semantics inline. It only +classifies the parser family, applies bounded signature checks, retains exact +source bytes as a base64 deferred-recognition payload, and records a stable +pending or rejection status. + +This keeps email import deterministic and evidence-preserving while later +sandboxed workers perform heavier extraction. + +## Shipped boundary + +- `.hwpx` and `.owpml` files with generic binary MIME types are resolved to the + HWPX parser family. +- HWPX content types are recognized as deferred OWPML XML packages. +- HWPX bytes must be a ZIP container with bounded metadata that identifies an + HWPX-like package structure. Import inspects ZIP file names only; it does not + decompress sections, execute active content, or fetch external resources. +- `.hwp` files with generic binary MIME types are resolved to the HWP parser + family. +- HWP bytes must carry the OLE Compound File binary signature before the file can + enter the sandboxed conversion queue. +- PDF behavior stays backward-compatible: callers that omit an expected content + type from `decode_deferred_attachment_payload()` still get PDF validation. +- Invalid HWPX/HWP/PDF payloads fail closed and are not retained as deferred + parser inputs. + +## Status codes + +| Parser family | Pending status | Rejection status | +| --- | --- | --- | +| PDF | `pdf_dom_recognition_pending` | `invalid_pdf_payload` | +| HWPX | `hwpx_xml_package_pending` | `invalid_hwpx_payload` | +| HWP | `hwp_conversion_pending` | `invalid_hwp_payload` | + +## Out of scope + +This slice does not implement semantic HWPX section extraction, embedded image +recognition, table reconstruction, HWP binary conversion, OCR, or LLM/VLM +interpretation. Those belong to a later worker-backed pipeline from the +evidence-based workspace epic. + +## Safety and buyer value + +Korean enterprise mailboxes often carry HWP and HWPX evidence. Treating those +attachments as opaque unsupported binaries breaks context synthesis, search +coverage, and auditability. Treating them as text or passing them directly to an +LLM is also unsafe. This slice gives the product an auditable middle state: the +source bytes are preserved, the file family is explicit, and follow-on workers +can proceed without losing provenance. + +## References + +Hancom Inc. (n.d.). *HWP binary format and HWPML document format*. Hancom Support. +https://www.hancom.com/support/downloadCenter/hwpOwpml + +Hancom Inc. (n.d.). *Hancom SDK: HWP/HWPX document processing development kit*. +Hancom SDK. https://sdk.hancom.com/sdks/1 + +Hancom Tech. (n.d.). *HWPX format*. https://tech.hancom.com/hwpxformat/ + +PKWARE, Inc. (2024). *APPNOTE.TXT: .ZIP file format specification*. +https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT From 26b5f29cf420f918e5926c759aaa0adbf181a941 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:12:32 +0900 Subject: [PATCH 04/23] docs(attachments): add HWP and HWPX implementation plan --- ...6-08-15-hwp-hwpx-attachment-recognition.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/plans/2026-08-15-hwp-hwpx-attachment-recognition.md diff --git a/docs/plans/2026-08-15-hwp-hwpx-attachment-recognition.md b/docs/plans/2026-08-15-hwp-hwpx-attachment-recognition.md new file mode 100644 index 000000000..08323289b --- /dev/null +++ b/docs/plans/2026-08-15-hwp-hwpx-attachment-recognition.md @@ -0,0 +1,34 @@ +# HWP and HWPX attachment recognition implementation plan + +## Goal + +Implement the next #1350 evidence-pipeline slice by recognizing HWPX and HWP +attachments during deterministic email import without running heavy parsers or +model calls inline. + +## Scope + +- Extend the attachment parser manifest with HWPX and HWP parser families. +- Resolve generic binary MIME types from `.hwpx`, `.owpml`, and `.hwp` + extensions. +- Retain exact source bytes as deferred base64 payloads after cheap family + signature checks pass. +- Reject invalid HWPX/HWP payloads fail-closed before they enter worker queues. +- Keep `decode_deferred_attachment_payload()` backward-compatible for PDF while + allowing explicit expected content types for HWPX and HWP workers. +- Add focused tests for manifest exposure, generic-MIME extension fallback, + valid deferred payloads, invalid payload rejection, and decoder validation. +- Record the standards and product boundary in doctoring. + +## Non-goals + +This slice does not parse HWPX XML sections, reconstruct tables, extract embedded +images, run OCR, convert HWP binaries, or invoke LLM/VLM providers. Those remain +worker-backed follow-on tasks. + +## Verification boundary + +Hosted repository checks are authoritative. The local execution container could +not reach `github.com` DNS for checkout-based verification, so this branch does +not claim local test completion. Merge only after exact-head CI, security, +coverage, review, and protected-branch governance succeed. From d24fe48cae5a860af0a1576d5091e624cb3d8f40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:12:43 +0900 Subject: [PATCH 05/23] docs(attachments): add HWP and HWPX merge checklist --- .../hwp-hwpx-attachment-recognition-checklist.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/doctoring/hwp-hwpx-attachment-recognition-checklist.md diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition-checklist.md b/docs/doctoring/hwp-hwpx-attachment-recognition-checklist.md new file mode 100644 index 000000000..285d77091 --- /dev/null +++ b/docs/doctoring/hwp-hwpx-attachment-recognition-checklist.md @@ -0,0 +1,9 @@ +# HWP/HWPX attachment recognition merge checklist + +- [ ] Hosted Application CI is terminal-success on the exact head. +- [ ] Hosted security checks are terminal-success on the exact head. +- [ ] Coverage gates accept the exact changed production surface. +- [ ] Review threads are zero or resolved. +- [ ] A qualifying independent non-author approval exists. +- [ ] No worker, OCR, LLM, network fetch, or conversion behavior is claimed by this deterministic import slice. +- [ ] Future workers preserve the same deferred payload and source-provenance contract. From d6ecf95b58301752c448b78aac1fe111a8f0186f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:12:52 +0900 Subject: [PATCH 06/23] docs(attachments): add HWP HWPX shipped-state note --- docs/doctoring/hwp-hwpx-attachment-recognition-readme.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/doctoring/hwp-hwpx-attachment-recognition-readme.md diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition-readme.md b/docs/doctoring/hwp-hwpx-attachment-recognition-readme.md new file mode 100644 index 000000000..7a81487c1 --- /dev/null +++ b/docs/doctoring/hwp-hwpx-attachment-recognition-readme.md @@ -0,0 +1,7 @@ +# HWP/HWPX shipped-state note + +This branch intentionally keeps HWPX and HWP in a deterministic recognition state. +The importer identifies the family, gates obviously invalid bytes, and stores a +bounded deferred payload. It does not infer document semantics. This protects +buyer trust by preventing unsupported Korean enterprise documents from silently +disappearing while still keeping the LLM and conversion boundary explicit. From 4b51240eb8521459ef622e49bd463a1a6d783288 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:14:36 +0900 Subject: [PATCH 07/23] test(attachments): bound HWPX package metadata --- .../test_attachment_parser_hwpx_bounds.py | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 backend/tests/test_attachment_parser_hwpx_bounds.py diff --git a/backend/tests/test_attachment_parser_hwpx_bounds.py b/backend/tests/test_attachment_parser_hwpx_bounds.py new file mode 100644 index 000000000..c1865e8d3 --- /dev/null +++ b/backend/tests/test_attachment_parser_hwpx_bounds.py @@ -0,0 +1,122 @@ +"""Security bounds for deferred HWPX package recognition.""" + +from __future__ import annotations + +import io +import zipfile + +import pytest + +from services import attachment_parser as parser + + +def _hwpx_bytes( + *, + mimetype: bytes = b"application/hwp+zip", + extra_entries: tuple[str, ...] = (), + duplicate_mimetype: bool = False, +) -> bytes: + """Build a small HWPX-shaped package with configurable ZIP metadata.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("mimetype", mimetype) + if duplicate_mimetype: + archive.writestr("mimetype", mimetype) + archive.writestr("version.xml", '') + archive.writestr("Contents/content.hpf", "") + archive.writestr("Contents/section0.xml", "
") + for entry_name in extra_entries: + archive.writestr(entry_name, b"") + return buffer.getvalue() + + +def _parse_hwpx(payload: bytes): + """Run the public import boundary with an explicit HWPX media type.""" + return parser.parse_email_attachment( + filename="bounded.hwpx", + content_type="application/hwp+zip", + raw_content=payload, + ) + + +def test_hwpx_recognition_requires_exact_mimetype_signature() -> None: + """Reject an ordinary ZIP that only imitates HWPX member names.""" + result = _parse_hwpx(_hwpx_bytes(mimetype=b"application/zip")) + + assert result.parse_status == "invalid_hwpx_payload" + assert result.parse_error_code == "invalid_hwpx_payload" + assert result.content == "" + + +def test_hwpx_recognition_rejects_ambiguous_duplicate_mimetype_entries() -> None: + """Reject duplicate signature members instead of trusting ZIP lookup order.""" + with pytest.warns(UserWarning, match="Duplicate name"): + payload = _hwpx_bytes(duplicate_mimetype=True) + + result = _parse_hwpx(payload) + + assert result.parse_status == "invalid_hwpx_payload" + assert result.parse_error_code == "invalid_hwpx_payload" + + +def test_hwpx_recognition_bounds_central_directory_entry_count( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject package metadata that exceeds the bounded recognition budget.""" + monkeypatch.setattr(parser, "MAX_HWPX_ZIP_ENTRIES", 3) + + result = _parse_hwpx(_hwpx_bytes()) + + assert result.parse_status == "invalid_hwpx_payload" + + +def test_hwpx_recognition_bounds_central_directory_bytes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject an oversized ZIP directory before member inspection.""" + monkeypatch.setattr(parser, "MAX_HWPX_CENTRAL_DIRECTORY_BYTES", 1) + + result = _parse_hwpx(_hwpx_bytes()) + + assert result.parse_status == "invalid_hwpx_payload" + + +def test_hwpx_recognition_bounds_aggregate_member_name_bytes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject metadata expansion through many or very long member names.""" + monkeypatch.setattr(parser, "MAX_HWPX_ZIP_NAME_BYTES", 32) + + result = _parse_hwpx( + _hwpx_bytes(extra_entries=(f"Contents/{'x' * 64}.xml",)) + ) + + assert result.parse_status == "invalid_hwpx_payload" + + +def test_hwpx_recognition_bounds_mimetype_member_bytes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject a signature member that exceeds its tiny deterministic contract.""" + monkeypatch.setattr(parser, "MAX_HWPX_MIMETYPE_BYTES", 8) + + result = _parse_hwpx(_hwpx_bytes()) + + assert result.parse_status == "invalid_hwpx_payload" + + +def test_hwpx_recognition_accepts_the_bounded_canonical_package() -> None: + """Retain valid HWPX bytes after all package metadata checks pass.""" + payload = _hwpx_bytes() + + result = _parse_hwpx(payload) + + assert result.parse_status == "hwpx_xml_package_pending" + assert result.parse_error_code is None + assert ( + parser.decode_deferred_attachment_payload( + result.content, + "application/hwp+zip", + ) + == payload + ) From b737ae83c94ee8a5aaf9c22a8239056e26ffe029 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:16:11 +0900 Subject: [PATCH 08/23] fix(attachments): validate bounded HWPX package identity --- backend/services/attachment_parser.py | 99 ++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 10 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 1e77613cc..e98d30f1b 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -3,6 +3,7 @@ import base64 import binascii import io +import struct import zipfile from dataclasses import dataclass from pathlib import Path @@ -27,8 +28,17 @@ "application/haansofthwp", ) _HWP_OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" +_HWPX_MIMETYPE = b"application/hwp+zip" +_ZIP_END_RECORD_SIGNATURE = b"PK\x05\x06" +_ZIP_END_RECORD_SIZE = 22 +_ZIP_MAX_COMMENT_BYTES = 65_535 +_ZIP_END_RECORD = struct.Struct("<4s4H2LH") MAX_ATTACHMENT_PARSE_SOURCE_CHARS = 1_000_000 MAX_ATTACHMENT_PARSE_SOURCE_BYTES = 20 * 1024 * 1024 +MAX_HWPX_ZIP_ENTRIES = 4_096 +MAX_HWPX_CENTRAL_DIRECTORY_BYTES = 4 * 1024 * 1024 +MAX_HWPX_ZIP_NAME_BYTES = 1 * 1024 * 1024 +MAX_HWPX_MIMETYPE_BYTES = 128 @dataclass(frozen=True) @@ -368,28 +378,97 @@ def _deferred_payload_error_code( return None +def _bounded_zip_directory_metadata(payload: bytes) -> tuple[int, int] | None: + """Return bounded ZIP directory counts without materializing member metadata.""" + search_start = max( + 0, + len(payload) - (_ZIP_END_RECORD_SIZE + _ZIP_MAX_COMMENT_BYTES), + ) + record_offset = payload.rfind(_ZIP_END_RECORD_SIGNATURE, search_start) + if record_offset < 0 or record_offset + _ZIP_END_RECORD_SIZE > len(payload): + return None + + ( + signature, + disk_number, + directory_disk_number, + disk_entry_count, + total_entry_count, + directory_size, + directory_offset, + comment_size, + ) = _ZIP_END_RECORD.unpack_from(payload, record_offset) + if ( + signature != _ZIP_END_RECORD_SIGNATURE + or disk_number != 0 + or directory_disk_number != 0 + or disk_entry_count != total_entry_count + or not 0 < total_entry_count <= MAX_HWPX_ZIP_ENTRIES + or directory_size > MAX_HWPX_CENTRAL_DIRECTORY_BYTES + or record_offset + _ZIP_END_RECORD_SIZE + comment_size != len(payload) + or directory_offset + directory_size > record_offset + ): + return None + return total_entry_count, directory_size + + def _is_hwpx_payload(payload: bytes) -> bool: - """Return whether bytes look like a HWPX/OWPML ZIP package. + """Return whether bytes look like a bounded HWPX/OWPML ZIP package. - The check intentionally inspects only bounded ZIP metadata and file names. - It does not decompress section XML, execute active content, or fetch external - resources during import. + Recognition checks ZIP directory budgets and the exact HWPX ``mimetype`` + signature before inspecting only member names. It does not parse section XML, + execute active content, extract files, or fetch external resources. """ - if not payload.startswith(b"PK"): + directory_metadata = _bounded_zip_directory_metadata(payload) + if not payload.startswith(b"PK") or directory_metadata is None: return False + expected_entry_count, _ = directory_metadata + try: with zipfile.ZipFile(io.BytesIO(payload)) as archive: - names = set(archive.namelist()) - except (zipfile.BadZipFile, ValueError): + entries = archive.infolist() + aggregate_name_bytes = sum( + len(entry.filename.encode("utf-8", errors="surrogatepass")) + for entry in entries + ) + if ( + len(entries) != expected_entry_count + or aggregate_name_bytes > MAX_HWPX_ZIP_NAME_BYTES + ): + return False + + mimetype_entries = [ + entry for entry in entries if entry.filename == "mimetype" + ] + if len(mimetype_entries) != 1: + return False + mimetype_entry = mimetype_entries[0] + if ( + mimetype_entry.flag_bits & 0x1 + or mimetype_entry.file_size > MAX_HWPX_MIMETYPE_BYTES + ): + return False + mimetype = archive.read(mimetype_entry) + names = {entry.filename for entry in entries} + except ( + NotImplementedError, + OSError, + RuntimeError, + ValueError, + zipfile.BadZipFile, + ): return False + has_manifest = "Contents/content.hpf" in names or "META-INF/manifest.xml" in names has_section = any( name.startswith("Contents/section") and name.endswith(".xml") for name in names ) - has_version = "version.xml" in names - has_mimetype = "mimetype" in names - return has_mimetype and has_version and (has_manifest or has_section) + return ( + mimetype == _HWPX_MIMETYPE + and "version.xml" in names + and (has_manifest or has_section) + ) def _coerce_text(raw_content: Any) -> str: From d8182731e10bcc022d0b29cc3b0f384cd27cb64d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:17:06 +0900 Subject: [PATCH 09/23] docs(attachments): record bounded HWPX admission --- .../hwp-hwpx-attachment-recognition.md | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md index b3fefe16e..2293a97a1 100644 --- a/docs/doctoring/hwp-hwpx-attachment-recognition.md +++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md @@ -16,9 +16,18 @@ sandboxed workers perform heavier extraction. - `.hwpx` and `.owpml` files with generic binary MIME types are resolved to the HWPX parser family. - HWPX content types are recognized as deferred OWPML XML packages. -- HWPX bytes must be a ZIP container with bounded metadata that identifies an - HWPX-like package structure. Import inspects ZIP file names only; it does not - decompress sections, execute active content, or fetch external resources. +- HWPX bytes must be a bounded single-disk ZIP package with one unambiguous + `mimetype` member whose exact content is `application/hwp+zip`, a `version.xml` + member, and either package-manifest or section evidence. +- The importer validates the end-of-central-directory entry count and directory + size before Python materializes ZIP members, then bounds aggregate member-name + bytes and the tiny `mimetype` payload before reading it. +- Duplicate `mimetype` members, wrong signature text, encrypted signature + members, unsupported ZIP structures, malformed ZIP metadata, and exceeded + limits fail closed as `invalid_hwpx_payload`. +- Import does not decompress document sections, extract files, execute active + content, or fetch external resources. Later workers must repeat path, + compression, XML, resource, and expansion-ratio validation before extraction. - `.hwp` files with generic binary MIME types are resolved to the HWP parser family. - HWP bytes must carry the OLE Compound File binary signature before the file can @@ -28,6 +37,36 @@ sandboxed workers perform heavier extraction. - Invalid HWPX/HWP/PDF payloads fail closed and are not retained as deferred parser inputs. +## HWPX resource bounds + +| Boundary | Current import limit | Purpose | +| --- | ---: | --- | +| Complete deferred source | 20 MiB | Prevent oversized payload retention. | +| ZIP entry count | 4,096 | Bound member-object and traversal work. | +| Central-directory bytes | 4 MiB | Bound metadata parsing before member materialization. | +| Aggregate decoded member-name bytes | 1 MiB | Prevent path/name metadata amplification. | +| `mimetype` uncompressed bytes | 128 bytes | Keep signature validation deterministic and non-expansive. | + +These are admission limits, not statements about the maximum document Hancom +Office can create. An operator may change them only with reviewed capacity and +security evidence. The recognition step deliberately rejects ZIP64 or multi-disk +packages rather than widening a low-cost email-import boundary. + +## Test-first repair evidence + +The initial HWPX slice accepted a ZIP by member names alone. A generic ZIP could +therefore imitate `mimetype`, `version.xml`, and section paths without carrying +the HWPX signature, while a small source file could devote most of its bytes to a +very large central directory. + +Commit `4b51240eb8521459ef622e49bd463a1a6d783288` added failing public-boundary +regressions for wrong and duplicate `mimetype` members, entry count, +central-directory bytes, aggregate name bytes, and signature-member bytes. +Commit `b737ae83c94ee8a5aaf9c22a8239056e26ffe029` then implemented the bounded +end-of-central-directory preflight and exact signature validation. Hosted +exact-head CI, security, coverage, and review evidence remains authoritative for +merge. + ## Status codes | Parser family | Pending status | Rejection status | From d97281ce7f452a10b0a5c76718d37d126958a4ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:21:34 +0900 Subject: [PATCH 10/23] test(attachments): require HWP FileHeader identity --- .../test_attachment_parser_hwp_signature.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 backend/tests/test_attachment_parser_hwp_signature.py diff --git a/backend/tests/test_attachment_parser_hwp_signature.py b/backend/tests/test_attachment_parser_hwp_signature.py new file mode 100644 index 000000000..20a7dd7a7 --- /dev/null +++ b/backend/tests/test_attachment_parser_hwp_signature.py @@ -0,0 +1,67 @@ +"""HWP binary signature admission tests for deferred conversion.""" + +from __future__ import annotations + +import base64 + +import pytest + +from services import attachment_parser as parser + + +def _ole_payload(*, include_hwp_signature: bool) -> bytes: + """Build a bounded OLE-like payload with optional HWP FileHeader evidence.""" + payload = bytearray(parser._HWP_OLE_MAGIC) + payload.extend(b"\x00" * 64) + if include_hwp_signature: + payload.extend(parser._HWP_DOCUMENT_SIGNATURE) + payload.extend(b"\x00" * (32 - len(parser._HWP_DOCUMENT_SIGNATURE))) + payload.extend(b"fixture body") + return bytes(payload) + + +def test_hwp_admission_rejects_an_unrelated_ole_container() -> None: + """Do not treat the generic Compound File signature as HWP authority.""" + result = parser.parse_email_attachment( + filename="unrelated.hwp", + content_type="application/x-hwp", + raw_content=_ole_payload(include_hwp_signature=False), + ) + + assert result.parse_status == "invalid_hwp_payload" + assert result.parse_error_code == "invalid_hwp_payload" + assert result.content == "" + + +def test_hwp_admission_accepts_ole_plus_hwp_file_header_signature() -> None: + """Queue a bounded payload only when both container and HWP identity exist.""" + payload = _ole_payload(include_hwp_signature=True) + + result = parser.parse_email_attachment( + filename="document.hwp", + content_type="application/x-hwp", + raw_content=payload, + ) + + assert result.parse_status == "hwp_conversion_pending" + assert result.parse_error_code is None + assert ( + parser.decode_deferred_attachment_payload( + result.content, + "application/x-hwp", + ) + == payload + ) + + +def test_hwp_deferred_decoder_rechecks_hwp_file_header_signature() -> None: + """Keep stored-payload decoding fail-closed after import-time admission.""" + encoded = base64.b64encode( + _ole_payload(include_hwp_signature=False) + ).decode("ascii") + + with pytest.raises(ValueError, match="not a HWP binary"): + parser.decode_deferred_attachment_payload( + encoded, + "application/x-hwp", + ) From 07bd3b30abe483b50129653a4fd599f7ddc9488d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:24:02 +0900 Subject: [PATCH 11/23] fix(attachments): require HWP FileHeader signature --- backend/services/attachment_parser.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index e98d30f1b..f2adc03a9 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -28,6 +28,7 @@ "application/haansofthwp", ) _HWP_OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" +_HWP_DOCUMENT_SIGNATURE = b"HWP Document File" _HWPX_MIMETYPE = b"application/hwp+zip" _ZIP_END_RECORD_SIGNATURE = b"PK\x05\x06" _ZIP_END_RECORD_SIZE = 22 @@ -371,9 +372,7 @@ def _deferred_payload_error_code( return "invalid_pdf_payload" if parse_content_type in _HWPX_CONTENT_TYPES and not _is_hwpx_payload(payload): return "invalid_hwpx_payload" - if parse_content_type in _HWP_CONTENT_TYPES and not payload.startswith( - _HWP_OLE_MAGIC - ): + if parse_content_type in _HWP_CONTENT_TYPES and not _is_hwp_payload(payload): return "invalid_hwp_payload" return None @@ -471,6 +470,13 @@ def _is_hwpx_payload(payload: bytes) -> bool: ) +def _is_hwp_payload(payload: bytes) -> bool: + """Require both the OLE container magic and HWP FileHeader identity.""" + if not payload.startswith(_HWP_OLE_MAGIC): + return False + return payload.find(_HWP_DOCUMENT_SIGNATURE, len(_HWP_OLE_MAGIC)) >= 0 + + def _coerce_text(raw_content: Any) -> str: """Coerce arbitrary attachment content to NUL-free text.""" if raw_content is None: From c8837fb00d74bd4ddc3152e0fe793e71f9e1f41f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:25:13 +0900 Subject: [PATCH 12/23] test(attachments): align valid HWP fixture with FileHeader --- backend/tests/test_attachment_parser.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index c42111be6..7b9de0a57 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -18,15 +18,21 @@ def _minimal_hwpx_bytes() -> bytes: buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as archive: archive.writestr("mimetype", "application/hwp+zip") - archive.writestr("version.xml", "") + archive.writestr("version.xml", '') archive.writestr("Contents/content.hpf", "") archive.writestr("Contents/section0.xml", "

계약 검토

") return buffer.getvalue() def _minimal_hwp_bytes() -> bytes: - """Build a minimal OLE-signature HWP binary sentinel fixture.""" - return b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1HWP Binary Body" + """Build a minimal OLE and HWP FileHeader signature fixture.""" + return ( + b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + + b"\x00" * 64 + + b"HWP Document File" + + b"\x00" * 15 + + b"HWP Binary Body" + ) def test_html_attachment_preserves_parse_source_and_safe_display_text(): From 1313fd871ff41dec5533d4786f7a0ea7190c1191 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:25:49 +0900 Subject: [PATCH 13/23] docs(attachments): distinguish OLE from HWP identity --- .../hwp-hwpx-attachment-recognition.md | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md index 2293a97a1..ebdd660a5 100644 --- a/docs/doctoring/hwp-hwpx-attachment-recognition.md +++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md @@ -30,8 +30,12 @@ sandboxed workers perform heavier extraction. compression, XML, resource, and expansion-ratio validation before extraction. - `.hwp` files with generic binary MIME types are resolved to the HWP parser family. -- HWP bytes must carry the OLE Compound File binary signature before the file can - enter the sandboxed conversion queue. +- HWP bytes must carry both the OLE Compound File container signature and the + HWP FileHeader identity marker `HWP Document File` before the file can enter + the sandboxed conversion queue. OLE magic by itself is not HWP authority. +- The low-cost identity check does not prove CFB directory integrity, stream + ownership, encryption state, record validity, or safe convertibility; the + sandboxed HWP worker must parse and validate those structures again. - PDF behavior stays backward-compatible: callers that omit an expected content type from `decode_deferred_attachment_payload()` still get PDF validation. - Invalid HWPX/HWP/PDF payloads fail closed and are not retained as deferred @@ -56,16 +60,26 @@ packages rather than widening a low-cost email-import boundary. The initial HWPX slice accepted a ZIP by member names alone. A generic ZIP could therefore imitate `mimetype`, `version.xml`, and section paths without carrying -the HWPX signature, while a small source file could devote most of its bytes to a -very large central directory. +the HWPX signature, while a small source file could still devote most of its +bytes to a very large central directory. Commit `4b51240eb8521459ef622e49bd463a1a6d783288` added failing public-boundary regressions for wrong and duplicate `mimetype` members, entry count, central-directory bytes, aggregate name bytes, and signature-member bytes. Commit `b737ae83c94ee8a5aaf9c22a8239056e26ffe029` then implemented the bounded -end-of-central-directory preflight and exact signature validation. Hosted -exact-head CI, security, coverage, and review evidence remains authoritative for -merge. +end-of-central-directory preflight and exact signature validation. + +The initial HWP slice likewise admitted any OLE Compound File if the caller +supplied an HWP extension or media type. Commit +`d97281ce7f452a10b0a5c76718d37d126958a4ae` added regressions proving that an +unrelated OLE container must fail both import-time and deferred-decoder checks. +Commit `07bd3b30abe483b50129653a4fd599f7ddc9488d` then required the published HWP +FileHeader identity marker as a second admission signal; commit +`c8837fb00d74bd4ddc3152e0fe793e71f9e1f41f` aligned the positive fixture with +that real contract. + +Hosted exact-head CI, security, coverage, and review evidence remains +authoritative for merge. ## Status codes @@ -99,7 +113,11 @@ https://www.hancom.com/support/downloadCenter/hwpOwpml Hancom Inc. (n.d.). *Hancom SDK: HWP/HWPX document processing development kit*. Hancom SDK. https://sdk.hancom.com/sdks/1 -Hancom Tech. (n.d.). *HWPX format*. https://tech.hancom.com/hwpxformat/ +Hancom Tech. (2025a, February 24). *HWP format structure*. +https://tech.hancom.com/%ED%95%9C-%EA%B8%80-%EB%AC%B8%EC%84%9C-%ED%8C%8C%EC%9D%BC-%ED%98%95%EC%8B%9D-hwp-%ED%8F%AC%EB%A7%B7-%EA%B5%AC%EC%A1%B0-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0/ + +Hancom Tech. (2025b, February 26). *HWPX format structure*. +https://tech.hancom.com/hwpxformat/ PKWARE, Inc. (2024). *APPNOTE.TXT: .ZIP file format specification*. https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT From 52bbc4fbcc411a158a9b372d4b31f03b8e16e969 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:14:48 +0900 Subject: [PATCH 14/23] test: close attachment parser coverage gaps --- backend/tests/test_attachment_parser.py | 57 +++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index 7b9de0a57..2f05269a6 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -11,6 +11,7 @@ get_attachment_parser_manifest, parse_email_attachment, ) +from services import attachment_parser as parser def _minimal_hwpx_bytes() -> bytes: @@ -400,3 +401,59 @@ def test_deferred_hwp_decoder_rejects_non_hwp_payload(): not_hwp = base64.b64encode(b"plain bytes").decode("ascii") with pytest.raises(ValueError, match="not a HWP binary"): decode_deferred_attachment_payload(not_hwp, "application/x-hwp") + + +def test_parser_handles_unknown_parser_key_and_invalid_display_filename(): + assert parser._parser_key_for("application/x-unknown", "parsed") == ( + "unsupported_binary" + ) + + result = parse_email_attachment( + filename="\x00", + content_type="application/zip", + raw_content=b"opaque", + ) + + assert result.filename == "attachment" + + +def test_deferred_decoder_rejects_invalid_base64(): + with pytest.raises(ValueError, match="not valid base64"): + decode_deferred_attachment_payload("not base64!") + + +@pytest.mark.parametrize("raw_content", [None, b"binary text", 12345]) +def test_text_parser_coerces_empty_bytes_and_other_values(raw_content): + result = parse_email_attachment( + filename="notes.txt", + content_type="text/plain", + raw_content=raw_content, + ) + + assert result.parse_status == "parsed" + expected_content = ( + "" + if raw_content is None + else raw_content.decode("utf-8") + if isinstance(raw_content, bytes) + else str(raw_content) + ) + assert result.parse_content == expected_content + + +def test_hwpx_recognition_fails_closed_when_zip_reader_errors(monkeypatch): + payload = _minimal_hwpx_bytes() + + def raise_os_error(*args, **kwargs): + raise OSError("zip reader unavailable") + + monkeypatch.setattr(parser.zipfile, "ZipFile", raise_os_error) + + result = parse_email_attachment( + filename="broken.hwpx", + content_type="application/hwp+zip", + raw_content=payload, + ) + + assert result.parse_status == "invalid_hwpx_payload" + assert result.parse_error_code == "invalid_hwpx_payload" From c9661674bd067f4c9d4c62d23f0b422af0ab0ff1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:19:44 +0900 Subject: [PATCH 15/23] feat: bound deferred attachment recognition at 64 MiB --- CHANGELOG.md | 1 + backend/services/attachment_parser.py | 3 +- backend/tests/test_attachment_parser.py | 11 +++- ...bounded-deferred-attachment-recognition.md | 51 +++++++++++++++++++ docs/adr/README.md | 1 + .../hwp-hwpx-attachment-recognition.md | 5 +- 6 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0006-bounded-deferred-attachment-recognition.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec84c36f..f9d57df40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## [Unreleased] +- HWP/HWPX 첨부파일을 명시적인 지연 인식 경계로 분류하고, HWP OLE+FileHeader 및 HWPX ZIP/mimetype 서명을 bounded preflight 합니다. 유효한 원본은 64MiB까지 base64 deferred payload로 보존하고, 손상·위조·과대 입력은 안정적인 rejection status로 fail-closed 합니다. ADR-0006과 APA 7th 근거를 함께 기록했으며 inline 변환·의미 추출은 아직 제공하지 않습니다. - 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다. - EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다. diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index f2adc03a9..3574ecb79 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -35,7 +35,8 @@ _ZIP_MAX_COMMENT_BYTES = 65_535 _ZIP_END_RECORD = struct.Struct("<4s4H2LH") MAX_ATTACHMENT_PARSE_SOURCE_CHARS = 1_000_000 -MAX_ATTACHMENT_PARSE_SOURCE_BYTES = 20 * 1024 * 1024 +# Keep deferred attachment retention aligned with the email import transport. +MAX_ATTACHMENT_PARSE_SOURCE_BYTES = 64 * 1024 * 1024 MAX_HWPX_ZIP_ENTRIES = 4_096 MAX_HWPX_CENTRAL_DIRECTORY_BYTES = 4 * 1024 * 1024 MAX_HWPX_ZIP_NAME_BYTES = 1 * 1024 * 1024 diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index 2f05269a6..544bcfa75 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -242,11 +242,14 @@ def test_invalid_pdf_payload_is_rejected_before_deferred_recognition(): assert result.parse_error_code == "invalid_pdf_payload" -def test_oversized_pdf_payload_is_not_retained(): +def test_oversized_pdf_payload_is_not_retained(monkeypatch): + monkeypatch.setattr( + "services.attachment_parser.MAX_ATTACHMENT_PARSE_SOURCE_BYTES", 8 + ) result = parse_email_attachment( filename="huge.pdf", content_type="application/pdf", - raw_content=b"%PDF-" + b"A" * MAX_ATTACHMENT_PARSE_SOURCE_BYTES, + raw_content=b"%PDF-" + b"A" * 8, ) assert result.content == "" @@ -254,6 +257,10 @@ def test_oversized_pdf_payload_is_not_retained(): assert result.parse_error_code == "parse_size_limit_exceeded" +def test_deferred_attachment_source_limit_exceeds_twenty_megabytes(): + assert MAX_ATTACHMENT_PARSE_SOURCE_BYTES > 20 * 1024 * 1024 + + @pytest.mark.parametrize( "raw_content", ["plain text", None, 12345], diff --git a/docs/adr/0006-bounded-deferred-attachment-recognition.md b/docs/adr/0006-bounded-deferred-attachment-recognition.md new file mode 100644 index 000000000..9e672ed97 --- /dev/null +++ b/docs/adr/0006-bounded-deferred-attachment-recognition.md @@ -0,0 +1,51 @@ +# ADR-0006: Bounded deferred recognition for HWP and HWPX attachments + +**Status:** Accepted (Naruon-local attachment import policy) +**Date:** 2026-08-20 +**Decision owner:** Naruon maintainers +**Scope:** Email attachment classification and deferred-recognition admission. +This ADR does not authorize inline document conversion, XML extraction, OCR, or +LLM processing. + +## Context + +Enterprise mailboxes contain HWP and HWPX documents that are not safely +represented by the generic unsupported-binary path. Dropping them loses +provenance; treating them as text can produce false content. The email import +transport already accepts bounded uploads above 20 MiB, so the parser must not +silently discard a valid deferred source below that transport ceiling. + +## Decision + +1. Classify `.hwp`, `.hwpx`, and `.owpml` extensions and their known media types + as explicit parser families. +2. Admit HWP only when the bytes begin with the OLE Compound File signature and + contain the HWP FileHeader identity marker. Admit HWPX only when a bounded, + single-disk ZIP package has the exact `application/hwp+zip` mimetype and + required package evidence. +3. Retain admitted source bytes as base64 deferred payloads, with a 64 MiB + ceiling aligned with the email import transport. Reject invalid signatures, + malformed ZIP metadata, unsupported ZIP structures, and over-limit payloads + fail-closed with stable status codes. +4. Repeat format, path, compression, XML, resource, and expansion-ratio checks + in the later sandboxed worker before extraction or conversion. + +## Consequences + +- Buyers can see that HWP/HWPX input was recognized and is awaiting a safe + worker, instead of seeing an opaque unsupported binary or losing the source. +- Large valid inputs up to 64 MiB preserve provenance, while bounded admission + prevents unbounded memory and database payload growth. +- Semantic extraction and conversion remain a separately auditable worker + capability; this ADR does not claim that capability is shipped. + +## References (APA 7th) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 + +Hancom Inc. (n.d.). *HWP binary format and HWPML document format*. Hancom +Support. https://www.hancom.com/support/downloadCenter/hwpOwpml + +PKWARE, Inc. (2024). *APPNOTE.TXT: .ZIP file format specification*. +https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT diff --git a/docs/adr/README.md b/docs/adr/README.md index 4d461fff6..d4b4e1ed3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,6 +13,7 @@ govern implementation. | [ADR-0002](0002-fitted-topic-artifact-consumption.md) | Conditionally consume only a versioned fitted topic artifact through a fail-closed adapter | Proposed | Target `PLANNED`; runtime `BLOCKED-UPSTREAM` | | [ADR-0003](0003-separate-topic-measurement-from-agenda-generation.md) | Keep statistical measurement separate from agenda generation | Proposed | Target and future capability `PLANNED`; no implementation authorization | | [ADR-0004](0004-status-weighted-calendar-conflicts.md) | Evaluate CalDAV VEVENT overlaps by occupying status; cancelled does not occupy | Accepted | `ACCEPTED-NARUON-POLICY`; advisory evaluate API only | +| [ADR-0006](0006-bounded-deferred-attachment-recognition.md) | Recognize HWP/HWPX at a bounded deferred-parser boundary and retain valid source bytes up to 64 MiB | Accepted | `ACCEPTED-NARUON-POLICY`; no inline conversion or semantic extraction | The complete topic-intelligence requirements, architecture, contract, UML, conceptual ERD, security, test, and operability graph is indexed at diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md index ebdd660a5..24e9c1590 100644 --- a/docs/doctoring/hwp-hwpx-attachment-recognition.md +++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md @@ -45,7 +45,7 @@ sandboxed workers perform heavier extraction. | Boundary | Current import limit | Purpose | | --- | ---: | --- | -| Complete deferred source | 20 MiB | Prevent oversized payload retention. | +| Complete deferred source | 64 MiB | Align deferred retention with the email import transport while bounding memory and database payload growth. | | ZIP entry count | 4,096 | Bound member-object and traversal work. | | Central-directory bytes | 4 MiB | Bound metadata parsing before member materialization. | | Aggregate decoded member-name bytes | 1 MiB | Prevent path/name metadata amplification. | @@ -121,3 +121,6 @@ https://tech.hancom.com/hwpxformat/ PKWARE, Inc. (2024). *APPNOTE.TXT: .ZIP file format specification*. https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 From e3e3fb05a779158c1c55cff96b0ceeac0d56da32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:49:24 +0900 Subject: [PATCH 16/23] fix(attachments): reject unknown deferred parser types --- backend/services/attachment_parser.py | 4 ++++ backend/tests/test_attachment_parser.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 3574ecb79..23f1cca83 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -352,6 +352,10 @@ def decode_deferred_attachment_payload( worker can record an error status instead of crashing. """ parse_content_type = _normalize_content_type(expected_content_type) + if parse_content_type not in _DEFERRED_DESCRIPTORS_BY_CONTENT_TYPE: + raise ValueError( + "Pending attachment content type is not a deferred parser type" + ) try: payload = base64.b64decode((content or "").encode("ascii"), validate=True) except (binascii.Error, UnicodeEncodeError, ValueError) as exc: diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index 544bcfa75..25bf6a307 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -322,10 +322,11 @@ def test_hwpx_attachment_is_deferred_for_structured_xml_package(): ) == raw -def test_hwpx_extension_with_generic_content_type_is_deferred_pending(): +@pytest.mark.parametrize("filename", ["proposal.hwpx", "proposal.owpml"]) +def test_hwpx_extension_with_generic_content_type_is_deferred_pending(filename): raw = _minimal_hwpx_bytes() result = parse_email_attachment( - filename="proposal.hwpx", + filename=filename, content_type="application/octet-stream", raw_content=raw, ) @@ -336,6 +337,11 @@ def test_hwpx_extension_with_generic_content_type_is_deferred_pending(): assert result.parse_status == "hwpx_xml_package_pending" +def test_deferred_decoder_rejects_unsupported_content_type_before_decoding(): + with pytest.raises(ValueError, match="not a deferred parser type"): + decode_deferred_attachment_payload("not base64!", "application/octet-stream") + + def test_invalid_hwpx_payload_is_rejected_before_xml_package_recognition(): result = parse_email_attachment( filename="broken.hwpx", From 9be09928ed8cb89f0b6a269976b4169723d73076 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:21:58 -0700 Subject: [PATCH 17/23] style(attachments): apply parser formatting --- backend/services/attachment_parser.py | 3 +-- backend/tests/test_attachment_parser.py | 22 ++++++++++++------- .../test_attachment_parser_hwp_signature.py | 6 ++--- .../test_attachment_parser_hwpx_bounds.py | 4 +--- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 23f1cca83..19093b7ab 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -465,8 +465,7 @@ def _is_hwpx_payload(payload: bytes) -> bool: has_manifest = "Contents/content.hpf" in names or "META-INF/manifest.xml" in names has_section = any( - name.startswith("Contents/section") and name.endswith(".xml") - for name in names + name.startswith("Contents/section") and name.endswith(".xml") for name in names ) return ( mimetype == _HWPX_MIMETYPE diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index 25bf6a307..9b4cb2935 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -316,10 +316,13 @@ def test_hwpx_attachment_is_deferred_for_structured_xml_package(): assert result.parser_key == "hwpx" assert result.parse_status == "hwpx_xml_package_pending" assert result.parse_error_code is None - assert decode_deferred_attachment_payload( - result.content, - "application/hwp+zip", - ) == raw + assert ( + decode_deferred_attachment_payload( + result.content, + "application/hwp+zip", + ) + == raw + ) @pytest.mark.parametrize("filename", ["proposal.hwpx", "proposal.owpml"]) @@ -377,10 +380,13 @@ def test_hwp_attachment_is_deferred_for_sandboxed_conversion(): assert result.parser_key == "hwp" assert result.parse_status == "hwp_conversion_pending" assert result.parse_error_code is None - assert decode_deferred_attachment_payload( - result.content, - "application/x-hwp", - ) == raw + assert ( + decode_deferred_attachment_payload( + result.content, + "application/x-hwp", + ) + == raw + ) def test_hwp_extension_with_generic_content_type_is_deferred_pending(): diff --git a/backend/tests/test_attachment_parser_hwp_signature.py b/backend/tests/test_attachment_parser_hwp_signature.py index 20a7dd7a7..a6f626588 100644 --- a/backend/tests/test_attachment_parser_hwp_signature.py +++ b/backend/tests/test_attachment_parser_hwp_signature.py @@ -56,9 +56,9 @@ def test_hwp_admission_accepts_ole_plus_hwp_file_header_signature() -> None: def test_hwp_deferred_decoder_rechecks_hwp_file_header_signature() -> None: """Keep stored-payload decoding fail-closed after import-time admission.""" - encoded = base64.b64encode( - _ole_payload(include_hwp_signature=False) - ).decode("ascii") + encoded = base64.b64encode(_ole_payload(include_hwp_signature=False)).decode( + "ascii" + ) with pytest.raises(ValueError, match="not a HWP binary"): parser.decode_deferred_attachment_payload( diff --git a/backend/tests/test_attachment_parser_hwpx_bounds.py b/backend/tests/test_attachment_parser_hwpx_bounds.py index c1865e8d3..2a3ac362d 100644 --- a/backend/tests/test_attachment_parser_hwpx_bounds.py +++ b/backend/tests/test_attachment_parser_hwpx_bounds.py @@ -87,9 +87,7 @@ def test_hwpx_recognition_bounds_aggregate_member_name_bytes( """Reject metadata expansion through many or very long member names.""" monkeypatch.setattr(parser, "MAX_HWPX_ZIP_NAME_BYTES", 32) - result = _parse_hwpx( - _hwpx_bytes(extra_entries=(f"Contents/{'x' * 64}.xml",)) - ) + result = _parse_hwpx(_hwpx_bytes(extra_entries=(f"Contents/{'x' * 64}.xml",))) assert result.parse_status == "invalid_hwpx_payload" From 4f3e95daf0d00e43a9907f7afecbb5f9c91907e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:24:29 -0700 Subject: [PATCH 18/23] docs(adr): record attachment design traceability --- docs/adr/0006-bounded-deferred-attachment-recognition.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/0006-bounded-deferred-attachment-recognition.md b/docs/adr/0006-bounded-deferred-attachment-recognition.md index 9e672ed97..3cd66b52f 100644 --- a/docs/adr/0006-bounded-deferred-attachment-recognition.md +++ b/docs/adr/0006-bounded-deferred-attachment-recognition.md @@ -6,6 +6,7 @@ **Scope:** Email attachment classification and deferred-recognition admission. This ADR does not authorize inline document conversion, XML extraction, OCR, or LLM processing. +**Figma File ID:** N/A — backend attachment admission; no visual surface. ## Context From dd501dae0fc03d813f4a65aa21318cc89d1a193c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 21:00:44 +0900 Subject: [PATCH 19/23] fix(attachments): process deferred HWPX packages --- CHANGELOG.md | 3 +- backend/services/attachment_parser.py | 10 +- backend/services/hwpx_recognition.py | 150 ++++++++++++++++++ backend/services/newsdom_worker.py | 73 ++++++++- backend/tests/test_hwpx_recognition.py | 136 ++++++++++++++++ backend/tests/test_newsdom_worker.py | 90 +++++++++++ ...bounded-deferred-attachment-recognition.md | 18 ++- .../hwp-hwpx-attachment-recognition.md | 16 +- 8 files changed, 479 insertions(+), 17 deletions(-) create mode 100644 backend/services/hwpx_recognition.py create mode 100644 backend/tests/test_hwpx_recognition.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d57df40..7bafc7204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] -- HWP/HWPX 첨부파일을 명시적인 지연 인식 경계로 분류하고, HWP OLE+FileHeader 및 HWPX ZIP/mimetype 서명을 bounded preflight 합니다. 유효한 원본은 64MiB까지 base64 deferred payload로 보존하고, 손상·위조·과대 입력은 안정적인 rejection status로 fail-closed 합니다. ADR-0006과 APA 7th 근거를 함께 기록했으며 inline 변환·의미 추출은 아직 제공하지 않습니다. +- HWPX `Contents/sectionN.xml`을 bounded worker에서 안전하게 읽어 문단·content graph로 저장합니다. 기존 64MiB 원본·ZIP 경계를 유지하고 XML entity 선언, 암호화/미지원 압축, section별·전체 XML 확장을 fail-closed 합니다. HWP 바이너리는 별도 sandbox converter가 생길 때까지 `hwp_conversion_pending`으로 보류합니다. `backend/tests/test_hwpx_recognition.py`와 worker 회귀 테스트가 실제 ZIP/XML 문서 경로와 오류 경계를 검증합니다. +- HWP/HWPX 첨부파일을 명시적인 지연 인식 경계로 분류하고, HWP OLE+FileHeader 및 HWPX ZIP/mimetype 서명을 bounded preflight 합니다. 유효한 원본은 64MiB까지 base64 deferred payload로 보존하고, 손상·위조·과대 입력은 안정적인 rejection status로 fail-closed 합니다. ADR-0006과 APA 7th 근거를 함께 기록했으며 HWP binary 변환·OCR·LLM 해석은 아직 제공하지 않습니다. - 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다. - EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다. diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 19093b7ab..643691e47 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -41,6 +41,8 @@ MAX_HWPX_CENTRAL_DIRECTORY_BYTES = 4 * 1024 * 1024 MAX_HWPX_ZIP_NAME_BYTES = 1 * 1024 * 1024 MAX_HWPX_MIMETYPE_BYTES = 128 +HWPX_XML_PACKAGE_PENDING_STATUS = "hwpx_xml_package_pending" +HWP_CONVERSION_PENDING_STATUS = "hwp_conversion_pending" @dataclass(frozen=True) @@ -116,14 +118,14 @@ class AttachmentParserDescriptor: display_name="HWPX documents (OWPML XML package recognition)", content_types=_HWPX_CONTENT_TYPES, extensions=(".hwpx", ".owpml"), - parse_status="hwpx_xml_package_pending", + parse_status=HWPX_XML_PACKAGE_PENDING_STATUS, ), AttachmentParserDescriptor( parser_key="hwp", display_name="HWP binary documents (sandboxed conversion)", content_types=_HWP_CONTENT_TYPES, extensions=(".hwp",), - parse_status="hwp_conversion_pending", + parse_status=HWP_CONVERSION_PENDING_STATUS, ), AttachmentParserDescriptor( parser_key="unsupported_binary", @@ -139,8 +141,8 @@ class AttachmentParserDescriptor: _DEFERRED_PARSE_STATUSES = frozenset( { "pdf_dom_recognition_pending", - "hwpx_xml_package_pending", - "hwp_conversion_pending", + HWPX_XML_PACKAGE_PENDING_STATUS, + HWP_CONVERSION_PENDING_STATUS, } ) _SUPPORTED_CONTENT_TYPES = { diff --git a/backend/services/hwpx_recognition.py b/backend/services/hwpx_recognition.py new file mode 100644 index 000000000..325f5417c --- /dev/null +++ b/backend/services/hwpx_recognition.py @@ -0,0 +1,150 @@ +"""Safely extract bounded text and graph records from an HWPX package.""" + +from __future__ import annotations + +import hashlib +import io +import re +import zipfile +from dataclasses import dataclass + +from defusedxml import ElementTree +from services.content_graph import ParseResult, PdfDomSection, parse_pdf_dom + +HWPX_CONTENT_TYPE = "application/hwp+zip" +MAX_HWPX_SECTION_XML_BYTES = 8 * 1024 * 1024 +MAX_HWPX_TOTAL_XML_BYTES = 32 * 1024 * 1024 +MAX_HWPX_TEXT_CHARS = 1_000_000 +_SECTION_NAME_PATTERN = re.compile(r"Contents/section[0-9]+\.xml\Z") +_FORBIDDEN_XML_DECLARATION = re.compile( + br" HwpxRecognitionRecords: + """Extract text from bounded HWPX section XML without executing content. + + Only canonical ``Contents/sectionN.xml`` members are read. Encrypted or + unsupported compression members, XML entity declarations, oversized + members, and empty documents fail closed before graph records are created. + """ + sections: list[PdfDomSection] = [] + total_xml_bytes = 0 + try: + with zipfile.ZipFile(io.BytesIO(hwpx_bytes)) as archive: + section_infos = sorted( + ( + info + for info in archive.infolist() + if _SECTION_NAME_PATTERN.fullmatch(info.filename) + ), + key=lambda info: int( + info.filename.removeprefix("Contents/section").removesuffix( + ".xml" + ) + ), + ) + if not section_infos: + raise HwpxRecognitionError("HWPX package has no section XML") + if len({info.filename for info in section_infos}) != len(section_infos): + raise HwpxRecognitionError("HWPX package has duplicate sections") + + for info in section_infos: + if info.flag_bits & 0x1 or info.compress_type not in ( + zipfile.ZIP_STORED, + zipfile.ZIP_DEFLATED, + ): + raise HwpxRecognitionError("HWPX section compression is not allowed") + if info.file_size > MAX_HWPX_SECTION_XML_BYTES: + raise HwpxRecognitionError("HWPX section exceeds the XML size limit") + total_xml_bytes += info.file_size + if total_xml_bytes > MAX_HWPX_TOTAL_XML_BYTES: + raise HwpxRecognitionError("HWPX XML exceeds the total size limit") + xml_bytes = archive.read(info) + if len(xml_bytes) != info.file_size: + raise HwpxRecognitionError("HWPX section size changed while reading") + sections.extend(_parse_section_xml(xml_bytes)) + except HwpxRecognitionError: + raise + except ( + OSError, + RuntimeError, + ValueError, + zipfile.BadZipFile, + ElementTree.ParseError, + ) as exc: + raise HwpxRecognitionError("HWPX package could not be safely read") from exc + + paragraphs = tuple( + paragraph + for section in sections + for paragraph in section.paragraphs + if paragraph.strip() + ) + parse_text = "\n\n".join(paragraphs) + if not parse_text.strip(): + raise HwpxRecognitionError("HWPX package contains no readable text") + if len(parse_text) > MAX_HWPX_TEXT_CHARS: + raise HwpxRecognitionError("HWPX text exceeds the parse size limit") + + source_content_hash = hashlib.sha256(hwpx_bytes).hexdigest() + parse_result = parse_pdf_dom( + source_kind="attachment", + source_record_uid=source_record_uid, + sections=sections, + source_content_hash=source_content_hash, + display_name=display_name, + content_type=HWPX_CONTENT_TYPE, + ) + return HwpxRecognitionRecords( + parse_text=parse_text, + source_content_hash=source_content_hash, + parse_result=parse_result, + ) + + +def _parse_section_xml(xml_bytes: bytes) -> list[PdfDomSection]: + """Parse one section into paragraph units without resolving declarations.""" + if _FORBIDDEN_XML_DECLARATION.search(xml_bytes): + raise HwpxRecognitionError("HWPX XML declarations are not allowed") + root = ElementTree.fromstring(xml_bytes) + paragraphs: list[str] = [] + for element in root.iter(): + if _local_name(element.tag) != "p": + continue + text = "".join( + node.text or "" + for node in element.iter() + if _local_name(node.tag) == "t" + ) + normalized = " ".join(text.split()) + if normalized: + paragraphs.append(normalized) + if not paragraphs: + return [] + return [PdfDomSection(heading="", paragraphs=tuple(paragraphs))] + + +def _local_name(tag: str) -> str: + """Return an XML local name while rejecting non-element tags.""" + if not isinstance(tag, str): + return "" + return tag.rsplit("}", maxsplit=1)[-1] diff --git a/backend/services/newsdom_worker.py b/backend/services/newsdom_worker.py index 26e4cffbb..469482ecd 100644 --- a/backend/services/newsdom_worker.py +++ b/backend/services/newsdom_worker.py @@ -29,8 +29,18 @@ Email, ) from db.session import AsyncSessionLocal -from services.attachment_parser import decode_deferred_attachment_payload +from services.attachment_parser import ( + HWP_CONVERSION_PENDING_STATUS, + HWPX_XML_PACKAGE_PENDING_STATUS, + decode_deferred_attachment_payload, +) from services.content_graph import ParseResult +from services.hwpx_recognition import ( + HWPX_CONTENT_TYPE, + HwpxRecognitionError, + HwpxRecognitionRecords, + recognize_hwpx, +) from services.newsdom_client import ( NewsdomConfigurationError, NewsdomRequestError, @@ -123,6 +133,25 @@ def apply_recognition_to_attachment( ) +def apply_hwpx_recognition_to_attachment( + *, + email: Email, + attachment: Attachment, + records: HwpxRecognitionRecords, +) -> None: + """Land locally extracted HWPX text and graph records on an attachment.""" + attachment.content = records.parse_text + attachment.parse_content_type = HWPX_CONTENT_TYPE + attachment.parser_key = "hwpx" + attachment.parse_status = PDF_DOM_RECOGNITION_PARSED_STATUS + attachment.parse_error_code = None + _append_parse_result_to_attachment( + email=email, + attachment=attachment, + parse_result=records.parse_result, + ) + + def apply_recognition_to_document( *, document: Document, @@ -217,8 +246,16 @@ async def process_pending_attachment( attachment.parse_status = PDF_DOM_RECOGNITION_FAILED_STATUS attachment.parse_error_code = "orphan_attachment" return RESULT_FAILED + expected_content_type = attachment.parse_content_type or "application/pdf" + if attachment.parse_status == HWP_CONVERSION_PENDING_STATUS: + # HWP binary conversion requires a separately sandboxed converter. Keep + # the source pending rather than sending a non-PDF payload to NewsDOM. + return RESULT_PENDING try: - pdf_bytes = decode_deferred_attachment_payload(attachment.content) + deferred_bytes = decode_deferred_attachment_payload( + attachment.content, + expected_content_type, + ) except ValueError as exc: attachment.parse_status = PDF_DOM_RECOGNITION_FAILED_STATUS attachment.parse_error_code = "invalid_pending_payload" @@ -229,6 +266,31 @@ async def process_pending_attachment( ) return RESULT_FAILED + if attachment.parse_status == HWPX_XML_PACKAGE_PENDING_STATUS: + try: + records = recognize_hwpx( + hwpx_bytes=deferred_bytes, + source_record_uid=f"attachment-{attachment.id}", + display_name=attachment.filename or "", + ) + except HwpxRecognitionError as exc: + attachment.parse_status = PDF_DOM_RECOGNITION_FAILED_STATUS + attachment.parse_error_code = "hwpx_recognition_failed" + logger.warning( + "HWPX attachment %s recognition failed: %s", + getattr(attachment, "id", "?"), + exc, + ) + return RESULT_FAILED + apply_hwpx_recognition_to_attachment( + email=email, + attachment=attachment, + records=records, + ) + return RESULT_RECOGNIZED + + pdf_bytes = deferred_bytes + config = await config_resolver(session, email.organization_id) if config is None: # Degrade gracefully: no active NewsDOM provider for this org yet. @@ -486,7 +548,12 @@ async def _sweep_attachments(self, session: AsyncSession) -> None: def _pending_attachment_statement(self, after_id: int | None): """Build the next deterministic attachment batch query.""" statement = select(Attachment).where( - Attachment.parse_status == PDF_DOM_RECOGNITION_PENDING_STATUS + Attachment.parse_status.in_( + ( + PDF_DOM_RECOGNITION_PENDING_STATUS, + HWPX_XML_PACKAGE_PENDING_STATUS, + ) + ) ) if after_id is not None: statement = statement.where(Attachment.id > after_id) diff --git a/backend/tests/test_hwpx_recognition.py b/backend/tests/test_hwpx_recognition.py new file mode 100644 index 000000000..726d17ad0 --- /dev/null +++ b/backend/tests/test_hwpx_recognition.py @@ -0,0 +1,136 @@ +"""Regression tests for bounded HWPX XML extraction.""" + +import io +import zipfile + +import pytest + +import services.hwpx_recognition as hwpx_module +from services.hwpx_recognition import HwpxRecognitionError, recognize_hwpx + + +def _package( + section_xml: str, + *, + compression: int = zipfile.ZIP_DEFLATED, + duplicate_section: bool = False, +) -> bytes: + """Build a package with the same members admitted by attachment parsing.""" + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w") as archive: + archive.writestr("mimetype", "application/hwp+zip") + archive.writestr("version.xml", "") + archive.writestr("Contents/content.hpf", "") + archive.writestr("Contents/section0.xml", section_xml, compress_type=compression) + if duplicate_section: + with pytest.warns(UserWarning, match="Duplicate name"): + archive.writestr( + "Contents/section0.xml", + section_xml, + compress_type=compression, + ) + return stream.getvalue() + + +def test_recognize_hwpx_extracts_paragraphs_and_graph_records() -> None: + payload = _package( + "" + "첫 문단" + "둘째 문단" + "" + ) + + result = recognize_hwpx( + hwpx_bytes=payload, + source_record_uid="attachment-1", + display_name="policy.hwpx", + ) + + assert result.parse_text == "첫 문단\n\n둘째 문단" + assert result.parse_result.content_type == "application/hwp+zip" + assert len(result.parse_result.segments) == 2 + assert result.source_content_hash + + +def test_recognize_hwpx_rejects_missing_sections() -> None: + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w") as archive: + archive.writestr("mimetype", "application/hwp+zip") + archive.writestr("version.xml", "") + archive.writestr("Contents/content.hpf", "") + + with pytest.raises(HwpxRecognitionError, match="no section"): + recognize_hwpx(hwpx_bytes=stream.getvalue(), source_record_uid="attachment-2") + + +def test_recognize_hwpx_rejects_duplicate_sections() -> None: + payload = _package( + "

text

", + duplicate_section=True, + ) + + with pytest.raises(HwpxRecognitionError, match="duplicate"): + recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-3") + + +def test_recognize_hwpx_rejects_entity_declarations_and_malformed_xml() -> None: + entity_payload = _package( + "]>&x;" + ) + with pytest.raises(HwpxRecognitionError, match="declarations"): + recognize_hwpx(hwpx_bytes=entity_payload, source_record_uid="attachment-4") + + malformed_payload = _package("") + with pytest.raises(HwpxRecognitionError, match="safely read"): + recognize_hwpx( + hwpx_bytes=malformed_payload, + source_record_uid="attachment-5", + ) + + +def test_recognize_hwpx_rejects_unsupported_compression() -> None: + if not hasattr(zipfile, "ZIP_BZIP2"): + pytest.skip("Python zipfile has no BZIP2 support") + payload = _package( + "

text

", + compression=zipfile.ZIP_BZIP2, + ) + + with pytest.raises(HwpxRecognitionError, match="compression"): + recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-6") + + +def test_recognize_hwpx_enforces_section_and_total_xml_limits(monkeypatch) -> None: + payload = _package("

text

") + monkeypatch.setattr(hwpx_module, "MAX_HWPX_SECTION_XML_BYTES", 1) + with pytest.raises(HwpxRecognitionError, match="section exceeds"): + recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-7") + + monkeypatch.setattr(hwpx_module, "MAX_HWPX_SECTION_XML_BYTES", 10_000) + monkeypatch.setattr(hwpx_module, "MAX_HWPX_TOTAL_XML_BYTES", 1) + with pytest.raises(HwpxRecognitionError, match="total size"): + recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-8") + + +def test_recognize_hwpx_rejects_changed_read_size(monkeypatch) -> None: + payload = _package("

text

") + + monkeypatch.setattr(zipfile.ZipFile, "read", lambda _archive, _info: b"") + with pytest.raises(HwpxRecognitionError, match="size changed"): + recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-9") + + +def test_recognize_hwpx_enforces_text_limit(monkeypatch) -> None: + payload = _package("

text

") + monkeypatch.setattr(hwpx_module, "MAX_HWPX_TEXT_CHARS", 1) + + with pytest.raises(HwpxRecognitionError, match="text exceeds"): + recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-10") + + +def test_recognize_hwpx_rejects_empty_text_and_non_string_xml_tags() -> None: + payload = _package("

") + with pytest.raises(HwpxRecognitionError, match="no readable"): + recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-11") + + assert hwpx_module._local_name(None) == "" diff --git a/backend/tests/test_newsdom_worker.py b/backend/tests/test_newsdom_worker.py index 0693d395c..fd5bcb18b 100644 --- a/backend/tests/test_newsdom_worker.py +++ b/backend/tests/test_newsdom_worker.py @@ -8,6 +8,8 @@ import asyncio import base64 +import io +import zipfile from types import SimpleNamespace import pytest @@ -89,6 +91,30 @@ def _pending_document(document_id: str, *, organization_id: str = "org-1") -> Do ) +def _hwpx_payload(*, include_text: bool = True) -> bytes: + """Build a small real HWPX-shaped package for worker integration tests.""" + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w") as archive: + archive.writestr( + "mimetype", + "application/hwp+zip", + compress_type=zipfile.ZIP_STORED, + ) + archive.writestr("version.xml", "") + archive.writestr("Contents/content.hpf", "") + text = ( + "보안 정책" + "원본 근거를 보존합니다." + if include_text + else "" + ) + archive.writestr( + "Contents/section0.xml", + f"{text}", + ) + return stream.getvalue() + + class _RowsResult: def __init__(self, rows): self._rows = rows @@ -369,6 +395,70 @@ def test_attachment_mapping_keeps_unmatched_segments_without_false_parent(): assert email.content_segments[0] is attachment.content_segments[0] +@pytest.mark.asyncio +async def test_hwpx_pending_attachment_is_extracted_and_graph_landed(): + email = Email() + email.organization_id = "org-1" + attachment = Attachment( + id=42, + filename="policy.hwpx", + content=base64.b64encode(_hwpx_payload()).decode("ascii"), + content_type="application/hwp+zip", + parse_content_type="application/hwp+zip", + parse_status="hwpx_xml_package_pending", + ) + email.attachments.append(attachment) + + result = await process_pending_attachment( + session=object(), + attachment=attachment, + config_resolver=await _resolver_with(None), + ) + + assert result == RESULT_RECOGNIZED + assert attachment.parse_status == "parsed" + assert attachment.parser_key == "hwpx" + assert attachment.content == "보안 정책\n\n원본 근거를 보존합니다." + assert len(attachment.content_nodes) == 4 + assert len(attachment.content_segments) == 2 + assert attachment.parse_error_code is None + + +@pytest.mark.asyncio +async def test_hwp_pending_attachment_stays_pending_without_a_converter(): + attachment = _pending_attachment(b"ignored") + attachment.filename = "legacy.hwp" + attachment.parse_content_type = "application/x-hwp" + attachment.parse_status = "hwp_conversion_pending" + + result = await process_pending_attachment(session=object(), attachment=attachment) + + assert result == RESULT_PENDING + assert attachment.parse_status == "hwp_conversion_pending" + + +@pytest.mark.asyncio +async def test_hwpx_pending_attachment_records_safe_failure_for_empty_text(): + email = Email() + attachment = Attachment( + id=43, + filename="empty.hwpx", + content=base64.b64encode( + _hwpx_payload(include_text=False) + ).decode("ascii"), + content_type="application/hwp+zip", + parse_content_type="application/hwp+zip", + parse_status="hwpx_xml_package_pending", + ) + email.attachments.append(attachment) + + result = await process_pending_attachment(session=object(), attachment=attachment) + + assert result == RESULT_FAILED + assert attachment.parse_status == PDF_DOM_RECOGNITION_FAILED_STATUS + assert attachment.parse_error_code == "hwpx_recognition_failed" + + @pytest.mark.asyncio async def test_attachment_sweep_advances_past_unconfigured_batch(): blocked = [ diff --git a/docs/adr/0006-bounded-deferred-attachment-recognition.md b/docs/adr/0006-bounded-deferred-attachment-recognition.md index 3cd66b52f..7318760a9 100644 --- a/docs/adr/0006-bounded-deferred-attachment-recognition.md +++ b/docs/adr/0006-bounded-deferred-attachment-recognition.md @@ -3,9 +3,9 @@ **Status:** Accepted (Naruon-local attachment import policy) **Date:** 2026-08-20 **Decision owner:** Naruon maintainers -**Scope:** Email attachment classification and deferred-recognition admission. -This ADR does not authorize inline document conversion, XML extraction, OCR, or -LLM processing. +**Scope:** Email attachment classification and bounded deferred-recognition +workers. Import remains admission-only; HWPX extraction runs later in the +worker, while HWP binary conversion, OCR, and LLM processing remain separate. **Figma File ID:** N/A — backend attachment admission; no visual surface. ## Context @@ -30,6 +30,10 @@ silently discard a valid deferred source below that transport ceiling. fail-closed with stable status codes. 4. Repeat format, path, compression, XML, resource, and expansion-ratio checks in the later sandboxed worker before extraction or conversion. +5. The worker extracts only canonical `Contents/sectionN.xml` HWPX members, + rejects XML entity declarations and unsupported/encrypted compression, and + records paragraph text plus stable content-graph provenance. HWP binary + inputs remain pending until an independently sandboxed converter is wired. ## Consequences @@ -37,8 +41,9 @@ silently discard a valid deferred source below that transport ceiling. worker, instead of seeing an opaque unsupported binary or losing the source. - Large valid inputs up to 64 MiB preserve provenance, while bounded admission prevents unbounded memory and database payload growth. -- Semantic extraction and conversion remain a separately auditable worker - capability; this ADR does not claim that capability is shipped. +- HWPX paragraph extraction is shipped as a bounded local worker capability. + HWP binary conversion remains a separately auditable capability and is not + claimed by this ADR. ## References (APA 7th) @@ -50,3 +55,6 @@ Support. https://www.hancom.com/support/downloadCenter/hwpOwpml PKWARE, Inc. (2024). *APPNOTE.TXT: .ZIP file format specification*. https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT + +World Wide Web Consortium. (2008, November 26). *Extensible Markup Language +(XML) 1.0 (Fifth Edition)*. https://www.w3.org/TR/xml/ diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md index 24e9c1590..1dfd25a28 100644 --- a/docs/doctoring/hwp-hwpx-attachment-recognition.md +++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md @@ -40,6 +40,10 @@ sandboxed workers perform heavier extraction. type from `decode_deferred_attachment_payload()` still get PDF validation. - Invalid HWPX/HWP/PDF payloads fail closed and are not retained as deferred parser inputs. +- The recognition worker reads only canonical `Contents/sectionN.xml` members, + bounds each section and the aggregate XML bytes, rejects entity declarations, + and lands paragraph text with stable content-graph provenance. The worker + never executes package content or follows external resources. ## HWPX resource bounds @@ -91,10 +95,11 @@ authoritative for merge. ## Out of scope -This slice does not implement semantic HWPX section extraction, embedded image -recognition, table reconstruction, HWP binary conversion, OCR, or LLM/VLM -interpretation. Those belong to a later worker-backed pipeline from the -evidence-based workspace epic. +This slice does not implement embedded image recognition, table reconstruction, +HWP binary conversion, OCR, or LLM/VLM interpretation. HWPX paragraph +extraction is implemented in the bounded worker; richer layout reconstruction +belongs to a later worker-backed pipeline from the evidence-based workspace +epic. ## Safety and buyer value @@ -124,3 +129,6 @@ https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 + +World Wide Web Consortium. (2008, November 26). *Extensible Markup Language +(XML) 1.0 (Fifth Edition)*. https://www.w3.org/TR/xml/ From 44a268b988f9a3092368bd774a26582647e319a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:49:15 +0900 Subject: [PATCH 20/23] test(attachments): reject sectionless HWPX admission --- backend/tests/test_attachment_parser_hwpx_bounds.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_attachment_parser_hwpx_bounds.py b/backend/tests/test_attachment_parser_hwpx_bounds.py index 2a3ac362d..b98308a65 100644 --- a/backend/tests/test_attachment_parser_hwpx_bounds.py +++ b/backend/tests/test_attachment_parser_hwpx_bounds.py @@ -15,6 +15,7 @@ def _hwpx_bytes( mimetype: bytes = b"application/hwp+zip", extra_entries: tuple[str, ...] = (), duplicate_mimetype: bool = False, + include_section: bool = True, ) -> bytes: """Build a small HWPX-shaped package with configurable ZIP metadata.""" buffer = io.BytesIO() @@ -24,7 +25,8 @@ def _hwpx_bytes( archive.writestr("mimetype", mimetype) archive.writestr("version.xml", '') archive.writestr("Contents/content.hpf", "") - archive.writestr("Contents/section0.xml", "
") + if include_section: + archive.writestr("Contents/section0.xml", "
") for entry_name in extra_entries: archive.writestr(entry_name, b"") return buffer.getvalue() @@ -103,6 +105,15 @@ def test_hwpx_recognition_bounds_mimetype_member_bytes( assert result.parse_status == "invalid_hwpx_payload" +def test_hwpx_recognition_rejects_manifest_only_package_without_sections() -> None: + """Reject packages that the HWPX recognition worker cannot materialize.""" + result = _parse_hwpx(_hwpx_bytes(include_section=False)) + + assert result.parse_status == "invalid_hwpx_payload" + assert result.parse_error_code == "invalid_hwpx_payload" + assert result.content == "" + + def test_hwpx_recognition_accepts_the_bounded_canonical_package() -> None: """Retain valid HWPX bytes after all package metadata checks pass.""" payload = _hwpx_bytes() From 4281904b438ac50c2d6c40d14207119c383227a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:50:08 +0900 Subject: [PATCH 21/23] fix(attachments): align HWPX admission with recognizer --- backend/services/attachment_parser.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 97129fde6..867695f8e 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -478,15 +478,10 @@ def _is_hwpx_payload(payload: bytes) -> bool: ): return False - has_manifest = "Contents/content.hpf" in names or "META-INF/manifest.xml" in names has_section = any( name.startswith("Contents/section") and name.endswith(".xml") for name in names ) - return ( - mimetype == _HWPX_MIMETYPE - and "version.xml" in names - and (has_manifest or has_section) - ) + return mimetype == _HWPX_MIMETYPE and "version.xml" in names and has_section def _is_hwp_payload(payload: bytes) -> bool: @@ -514,4 +509,4 @@ def _display_text(raw_content: str) -> str: def _sanitize_nul(text: str) -> str: """Remove NUL characters that database text fields cannot retain.""" - return text.replace("\x00", "") + return text.replace("\x00", "") \ No newline at end of file From 50a4102c698f28209db68f92e36b52df158eeaf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:50:59 +0900 Subject: [PATCH 22/23] fix(tests): use one HWPX recognition import style --- backend/tests/test_hwpx_recognition.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_hwpx_recognition.py b/backend/tests/test_hwpx_recognition.py index 726d17ad0..7a1048dc7 100644 --- a/backend/tests/test_hwpx_recognition.py +++ b/backend/tests/test_hwpx_recognition.py @@ -5,8 +5,10 @@ import pytest -import services.hwpx_recognition as hwpx_module -from services.hwpx_recognition import HwpxRecognitionError, recognize_hwpx +from services import hwpx_recognition as hwpx_module + +HwpxRecognitionError = hwpx_module.HwpxRecognitionError +recognize_hwpx = hwpx_module.recognize_hwpx def _package( From 5cf957708098d17b6d843353309d86b0b232fcd7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:59:50 +0900 Subject: [PATCH 23/23] docs(attachments): align HWPX admission evidence --- .../hwp-hwpx-attachment-recognition.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md index 1dfd25a28..3183cc342 100644 --- a/docs/doctoring/hwp-hwpx-attachment-recognition.md +++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md @@ -11,20 +11,22 @@ pending or rejection status. This keeps email import deterministic and evidence-preserving while later sandboxed workers perform heavier extraction. -## Shipped boundary +## Active deferred-import boundary — PR #1353 - `.hwpx` and `.owpml` files with generic binary MIME types are resolved to the HWPX parser family. - HWPX content types are recognized as deferred OWPML XML packages. - HWPX bytes must be a bounded single-disk ZIP package with one unambiguous `mimetype` member whose exact content is `application/hwp+zip`, a `version.xml` - member, and either package-manifest or section evidence. + member, and at least one canonical `Contents/sectionN.xml` member. Package + manifest presence alone is not sufficient admission evidence because the + recognition worker cannot materialize a sectionless package. - The importer validates the end-of-central-directory entry count and directory size before Python materializes ZIP members, then bounds aggregate member-name bytes and the tiny `mimetype` payload before reading it. - Duplicate `mimetype` members, wrong signature text, encrypted signature - members, unsupported ZIP structures, malformed ZIP metadata, and exceeded - limits fail closed as `invalid_hwpx_payload`. + members, unsupported ZIP structures, malformed ZIP metadata, sectionless + packages, and exceeded limits fail closed as `invalid_hwpx_payload`. - Import does not decompress document sections, extract files, execute active content, or fetch external resources. Later workers must repeat path, compression, XML, resource, and expansion-ratio validation before extraction. @@ -73,6 +75,13 @@ central-directory bytes, aggregate name bytes, and signature-member bytes. Commit `b737ae83c94ee8a5aaf9c22a8239056e26ffe029` then implemented the bounded end-of-central-directory preflight and exact signature validation. +A later review found that import admission still accepted a package with a +manifest but no section XML, while the recognition worker must reject that same +package because there is no materializable `Contents/sectionN.xml`. RED commit +`44a268b988f9a3092368bd774a26582647e319a9` adds the manifest-only regression; +causal fix `4281904b438ac50c2d6c40d14207119c383227a8` requires section evidence at +import admission so the queue and worker share one fail-closed boundary. + The initial HWP slice likewise admitted any OLE Compound File if the caller supplied an HWP extension or media type. Commit `d97281ce7f452a10b0a5c76718d37d126958a4ae` added regressions proving that an