fix: reject DTD and entity declarations in generated SVG to enhance security

This commit is contained in:
myhloli
2026-08-27 16:12:48 +08:00
parent 5824ca43ec
commit 98f44a7cdf
5 changed files with 60 additions and 9 deletions
+6 -3
View File
@@ -1777,12 +1777,15 @@ def _handle_wmf_record(function: int, payload: BoundedReader, playback: _Playbac
cursor += count
playback.emit_path(builder.build(), stroke=True, fill=True)
return
if function in {0x0418, 0x041B, 0x061C}:
if function == 0x061C:
rect = Rect(float(payload.i16(10)), float(payload.i16(8)), float(payload.i16(6)), float(payload.i16(4)))
path = round_rectangle_path(rect, abs(payload.i16(2)) / 2.0, abs(payload.i16(0)) / 2.0)
playback.emit_logical_path(path, stroke=True, fill=True)
return
if function in {0x0418, 0x041B}:
rect = Rect(float(payload.i16(6)), float(payload.i16(4)), float(payload.i16(2)), float(payload.i16(0)))
if function == 0x0418:
path = ellipse_path(rect)
elif function == 0x061C:
path = round_rectangle_path(rect, abs(payload.i16(10)) / 2.0, abs(payload.i16(8)) / 2.0)
else:
path = rectangle_path(rect)
playback.emit_logical_path(path, stroke=True, fill=True)
+10 -4
View File
@@ -53,6 +53,14 @@ _SAFE_SVG_ATTRIBUTES: dict[str, set[str]] = {
}
class _RejectingSvgTreeBuilder(ElementTree.TreeBuilder):
"""构造拒绝任何 DTD 的 SVG XML 树。"""
def doctype(self, _name: str, _pubid: str | None, _system: str | None) -> None:
"""在实体声明被处理前拒绝任意偏移和编码的 DOCTYPE。"""
raise ValueError("Generated SVG must not contain a DTD or entity declaration")
def normalize_image_extension(fmt: str) -> str:
"""规范化图片扩展名,保证同一图片格式生成稳定文件名。"""
normalized = fmt.lower().split("+", 1)[0]
@@ -138,11 +146,9 @@ def extract_mineru_generated_svg_fallback(payload: bytes) -> tuple[bytes, int, i
"""验证 MinerU 生成 SVG,并返回 PNG fallback 与逻辑像素尺寸。"""
if not isinstance(payload, bytes) or not payload or len(payload) > _MAX_GENERATED_SVG_BYTES:
raise ValueError("Generated SVG payload is empty or exceeds its byte limit")
lowered_prefix = payload[:4096].lower()
if b"<!doctype" in lowered_prefix or b"<!entity" in lowered_prefix:
raise ValueError("Generated SVG must not contain a DTD or entity declaration")
try:
root = ElementTree.fromstring(payload)
parser = ElementTree.XMLParser(target=_RejectingSvgTreeBuilder())
root = ElementTree.fromstring(payload, parser=parser)
except ElementTree.ParseError as exc:
raise ValueError("Generated SVG payload is not valid XML") from exc
if root.tag != f"{{{_SVG_NAMESPACE}}}svg" or root.get("data-mineru-generated") != _MINERU_SVG_MARKER:
+12
View File
@@ -47,6 +47,18 @@ def _generated_svg(*, logical_size: tuple[int, int] = (7, 5), fallback_size: tup
).encode()
def test_generated_svg_rejects_dtd_beyond_prefix_window() -> None:
"""验证 DOCX 不会嵌入在长前缀后隐藏 DTD 或实体声明的 SVG。"""
payload = (
b" " * 4097
+ b'<!DOCTYPE svg [<!ENTITY injected "expanded">]>'
+ _generated_svg().replace(b"</svg>", b'<text x="0" y="0" fill="#000">&injected;</text></svg>')
)
with pytest.raises(DocxAssetError, match="safely generated by MinerU"):
prepare_image_bytes(payload, declared_extension="svg")
def _image_block(**values: str | None) -> ImageBodyBlock:
"""构造最小图片 body block。"""
return ImageBodyBlock(type="image_body", index=0, content="", **values)
+14 -2
View File
@@ -36,8 +36,8 @@ from mineru.model.flash.office.metafile import (
)
from mineru.model.flash.office.metafile import parser as metafile_parser
from mineru.model.flash.office.metafile import render as metafile_render
from mineru.model.flash.office.metafile.geometry import FlattenBudget, PathBuilder, flatten_path
from mineru.model.flash.office.metafile.models import ClipOperation, DrawPathCommand, GraphicsPath, Matrix, Pen
from mineru.model.flash.office.metafile.geometry import FlattenBudget, PathBuilder, flatten_path, path_bounds
from mineru.model.flash.office.metafile.models import ClipOperation, DrawPathCommand, GraphicsPath, Matrix, Pen, Rect
from mineru.model.flash.office.legacy.officeart import OfficeArtRecord, decode_blip
from mineru.model.flash.office.pptx.pptx_converter import PptxConverter
from mineru.model.flash.office.xlsx.xlsx_converter import XlsxConverter
@@ -46,6 +46,7 @@ from mineru.utils.image_payload import extract_mineru_generated_svg_fallback
from _metafile_test_utils import (
basic_wmf,
build_emf,
build_placeable_wmf,
emf_begin_path,
emf_close_figure,
emf_create_brush,
@@ -68,6 +69,7 @@ from _metafile_test_utils import (
emf_stretch_dib,
emf_text,
emfplus_comment,
wmf_record,
)
from _legacy_ppt_test_utils import build_equation_ppt
from _legacy_xls_test_utils import build_equation_xls
@@ -450,6 +452,16 @@ def test_placeable_wmf_renders_vector_content() -> None:
assert image.getpixel((72, 72))[1] > 150
def test_wmf_roundrect_uses_record_parameter_order() -> None:
"""验证 META_ROUNDRECT 按 Height/Width/Bottom/Right/Top/Left 解码。"""
payload = struct.pack("<hhhhhh", 100, 300, 900, 800, 200, 100)
document = metafile_parser.parse_metafile(build_placeable_wmf([wmf_record(0x061C, payload)]))
command = next(command for command in document.commands if isinstance(command, DrawPathCommand))
assert path_bounds(command.path) == Rect(100.0, 200.0, 800.0, 900.0)
assert command.path.segments[0].points == ((250.0, 200.0),)
def test_vector_only_metafile_uses_4x_antialiasing_and_8x_svg_fallback() -> None:
"""验证矢量公式保持逻辑尺寸,同时使用 4× 栅格和 8× DOCX fallback。"""
document = metafile_parser.parse_metafile(basic_wmf())
+18
View File
@@ -275,6 +275,24 @@ def test_mineru_generated_svg_data_uri_is_allowed() -> None:
assert sanitize_image_source(source) == source
def test_mineru_generated_svg_rejects_dtd_beyond_prefix_window() -> None:
"""验证任意偏移和编码的 DTD 都不能绕过 HTML SVG 安全校验。"""
safe_svg = base64.b64decode(_generated_svg_data_uri().split(",", 1)[1])
late_doctype = (
b" " * 4097
+ b'<!DOCTYPE svg [<!ENTITY injected "expanded">]>'
+ safe_svg.replace(b"</svg>", b'<text x="0" y="0" fill="#000">&injected;</text></svg>')
)
utf16_doctype = (
'<!DOCTYPE svg [<!ENTITY injected "expanded">]>'
+ safe_svg.decode("utf-8").replace("</svg>", '<text x="0" y="0" fill="#000">&injected;</text></svg>')
).encode("utf-16")
for payload in (late_doctype, utf16_doctype):
source = f"data:image/svg+xml;base64,{base64.b64encode(payload).decode('ascii')}"
assert sanitize_image_source(source) is None
@pytest.mark.parametrize(
"extra_markup",
[