From b17af07d1438bbc2892a7dc88bc13b9bea0ac9c9 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Thu, 18 Jun 2026 23:42:21 +0800
Subject: [PATCH] fix: harden T2I template rendering
---
astrbot/core/utils/t2i/network_strategy.py | 6 +-
.../t2i/template/astrbot_powershell.html | 13 +-
.../utils/t2i/template/astrbot_vitepress.html | 13 +-
astrbot/core/utils/t2i/template/base.html | 13 +-
astrbot/core/utils/t2i/template_manager.py | 83 +++++++++++++
tests/unit/test_t2i_templates.py | 112 ++++++++++++++++++
6 files changed, 230 insertions(+), 10 deletions(-)
create mode 100644 tests/unit/test_t2i_templates.py
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'"
+)
+_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(
+ '',
+ )