diff --git a/astrbot/core/utils/t2i/network_strategy.py b/astrbot/core/utils/t2i/network_strategy.py index 1191e154a..c2b5475ce 100644 --- a/astrbot/core/utils/t2i/network_strategy.py +++ b/astrbot/core/utils/t2i/network_strategy.py @@ -10,7 +10,10 @@ import aiohttp from astrbot.core.config import VERSION from astrbot.core.utils.http_ssl import build_tls_connector from astrbot.core.utils.io import download_image_by_url -from astrbot.core.utils.t2i.template_manager import TemplateManager +from astrbot.core.utils.t2i.template_manager import ( + TemplateManager, + harden_t2i_template_content, +) from . import RenderStrategy @@ -225,6 +228,7 @@ class NetworkRenderStrategy(RenderStrategy): @staticmethod def _prepare_template_sync(tmpl_str: str, tmpl_data: dict) -> tuple[str, dict]: """在线程池中执行的同步模板预处理(避免阻塞事件循环)""" + tmpl_str = harden_t2i_template_content(tmpl_str) if SHIKI_RUNTIME_TEMPLATE_PATTERN.search(tmpl_str): tmpl_data = {"shiki_runtime": get_shiki_runtime()} | tmpl_data tmpl_str = inject_shiki_runtime(tmpl_str) diff --git a/astrbot/core/utils/t2i/template/astrbot_powershell.html b/astrbot/core/utils/t2i/template/astrbot_powershell.html index 3bfa014c0..283225bba 100644 --- a/astrbot/core/utils/t2i/template/astrbot_powershell.html +++ b/astrbot/core/utils/t2i/template/astrbot_powershell.html @@ -175,15 +175,22 @@ + - + + - + + - + " +) +_MARKED_SCRIPT = ( + '' +) +_UNSAFE_TEXT_SAFE_FILTER = re.compile(r"\{\{\s*text\s*\|\s*safe\s*\}\}") +_DIRECT_MARKED_RENDER = re.compile( + r"(?P[ \t]*)contentElement\.innerHTML\s*=\s*marked\.parse\(source\);" +) +_MARKDOWN_SOURCE_TEXTAREA = re.compile( + r'\s*' + r"\{\{\s*text\s*\|\s*(?:safe|e|escape)\s*\}\}" + r"\s*" +) +_MARKDOWN_SOURCE_VALUE_READ = re.compile( + r'(?P[ \t]*)const\s+source\s*=\s*document\.getElementById\("markdown-source"\)\.value;' +) _SSTI_BLACKLIST: list[tuple[str, re.Pattern]] = [ ( @@ -30,7 +49,55 @@ _SSTI_BLACKLIST: list[tuple[str, re.Pattern]] = [ _VAR_RE = re.compile(r"\{\{\s*(\w+)\s*(\|[^}]*)?\}\}") +def harden_t2i_template_content(content: str) -> str: + """Patch legacy T2I template sinks that render user text as trusted HTML.""" + hardened = _UNSAFE_TEXT_SAFE_FILTER.sub("{{ text | e }}", content) + hardened = _MARKDOWN_SOURCE_TEXTAREA.sub( + '', + hardened, + ) + + def _replace_source_value_read(match: re.Match) -> str: + indent = match.group("indent") + return ( + f'{indent}const sourceNode = document.getElementById("markdown-source");\n' + f"{indent}const source = JSON.parse(sourceNode.textContent || '\"\"');" + ) + + hardened = _MARKDOWN_SOURCE_VALUE_READ.sub(_replace_source_value_read, hardened) + + def _sanitize_marked_render(match: re.Match) -> str: + indent = match.group("indent") + return ( + f"{indent}const renderedHtml = marked.parse(source);\n" + f"{indent}if (window.DOMPurify) {{\n" + f"{indent} contentElement.innerHTML = window.DOMPurify.sanitize(renderedHtml);\n" + f"{indent}}} else {{\n" + f"{indent} contentElement.textContent = source;\n" + f"{indent}}}" + ) + + if "window.DOMPurify.sanitize" not in hardened: + hardened = _DIRECT_MARKED_RENDER.sub(_sanitize_marked_render, hardened) + + if ( + "window.DOMPurify.sanitize" in hardened + and "purify.min.js" not in hardened + and _MARKED_SCRIPT in hardened + ): + hardened = hardened.replace( + _MARKED_SCRIPT, + f"{_MARKED_SCRIPT}\n {_DOMPURIFY_SCRIPT}", + 1, + ) + + return hardened + + def validate_template_content(content: str, *, strict: bool = False) -> None: + if _UNSAFE_TEXT_SAFE_FILTER.search(content): + logger.warning("SSTI validation blocked template: unsafe text|safe filter") + raise ValueError("Template renders text with the unsafe safe filter.") for label, pattern in _SSTI_BLACKLIST: if pattern.search(content): logger.warning(f"SSTI validation blocked template: matched rule [{label}]") @@ -85,6 +152,22 @@ class TemplateManager: def _initialize_user_templates(self) -> None: """如果用户目录下缺少核心模板,则进行复制。""" self._copy_core_templates(overwrite=False) + self._harden_core_user_templates() + + def _harden_core_user_templates(self) -> None: + """Patch copied core templates from older releases without overwriting users.""" + for filename in self.CORE_TEMPLATES: + path = os.path.join(self.user_template_dir, filename) + if not os.path.exists(path): + continue + + content = self._read_file(path) + hardened = harden_t2i_template_content(content) + if hardened == content: + continue + + with open(path, "w", encoding="utf-8") as f: + f.write(hardened) def _get_user_template_path(self, name: str) -> str: """获取用户模板的完整路径,防止路径遍历漏洞。""" diff --git a/tests/unit/test_t2i_templates.py b/tests/unit/test_t2i_templates.py new file mode 100644 index 000000000..5a63a2778 --- /dev/null +++ b/tests/unit/test_t2i_templates.py @@ -0,0 +1,112 @@ +from pathlib import Path + +import pytest +from jinja2 import Environment + +from astrbot.core.utils.t2i import template_manager +from astrbot.core.utils.t2i.network_strategy import NetworkRenderStrategy + +TEMPLATE_DIR = ( + Path(__file__).resolve().parents[2] + / "astrbot" + / "core" + / "utils" + / "t2i" + / "template" +) + + +def test_bundled_t2i_templates_encode_markdown_source_as_json_data(): + env = Environment(autoescape=False) + payload = "" + + for template_name in ( + "base.html", + "astrbot_powershell.html", + "astrbot_vitepress.html", + ): + template_text = (TEMPLATE_DIR / template_name).read_text(encoding="utf-8") + rendered = env.from_string(template_text).render( + text=payload, + version="vtest", + ) + + assert "{{ text | safe }}" not in template_text + assert "{{ text | tojson }}" in template_text + assert 'type="application/json"' in template_text + assert "window.DOMPurify.sanitize" in template_text + assert "contentElement.innerHTML = marked.parse(source)" not in template_text + assert ".value" not in template_text + assert "" not in rendered + assert rendered.count("") == 0 + assert "\\u003c/script\\u003e" in rendered + + +def test_template_manager_hardens_existing_core_user_templates(tmp_path, monkeypatch): + root_dir = tmp_path / "root" + data_dir = tmp_path / "data" + builtin_dir = root_dir / "astrbot" / "core" / "utils" / "t2i" / "template" + user_dir = data_dir / "t2i_templates" + builtin_dir.mkdir(parents=True) + user_dir.mkdir(parents=True) + + unsafe_template = """ + + + +""" + (builtin_dir / "base.html").write_text(unsafe_template, encoding="utf-8") + (user_dir / "base.html").write_text(unsafe_template, encoding="utf-8") + + monkeypatch.setattr(template_manager, "get_astrbot_path", lambda: str(root_dir)) + monkeypatch.setattr( + template_manager, + "get_astrbot_data_path", + lambda: str(data_dir), + ) + + template_manager.TemplateManager() + hardened = (user_dir / "base.html").read_text(encoding="utf-8") + + assert "{{ text | safe }}" not in hardened + assert "{{ text | tojson }}" in hardened + assert 'type="application/json"' in hardened + assert "JSON.parse(sourceNode.textContent" in hardened + assert "window.DOMPurify.sanitize" in hardened + assert "contentElement.innerHTML = marked.parse(source)" not in hardened + + +def test_network_strategy_hardens_custom_templates_before_rendering(): + unsafe_template = """ + + + +""" + + hardened, tmpl_data = NetworkRenderStrategy._prepare_template_sync( + unsafe_template, + {"text": ""}, + ) + + assert tmpl_data["text"] == "" + assert "{{ text | safe }}" not in hardened + assert "{{ text | tojson }}" in hardened + assert 'type="application/json"' in hardened + assert "JSON.parse(sourceNode.textContent" in hardened + assert "window.DOMPurify.sanitize" in hardened + assert "contentElement.innerHTML = marked.parse(source)" not in hardened + + +def test_template_validation_rejects_unsafe_text_safe_filter(): + with pytest.raises(ValueError, match="unsafe safe filter"): + template_manager.validate_template_content( + '', + )