mirror of
https://github.com/opendatalab/MinerU.git
synced 2026-08-31 01:35:42 +08:00
fix: reject DTD and entity declarations in generated SVG to enhance security
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user