Compare commits

...

1 Commits

Author SHA1 Message Date
Soulter
b17af07d14 fix: harden T2I template rendering 2026-06-18 23:48:08 +08:00
6 changed files with 230 additions and 10 deletions

View File

@@ -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)

View File

@@ -175,15 +175,22 @@
</main>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.7/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>
<script id="markdown-source" type="application/json">{{ text | tojson }}</script>
<script>
(function () {
const contentElement = document.getElementById("content");
const source = document.getElementById("markdown-source").value;
const sourceNode = document.getElementById("markdown-source");
const source = JSON.parse(sourceNode.textContent || '""');
contentElement.innerHTML = marked.parse(source);
const renderedHtml = marked.parse(source);
if (window.DOMPurify) {
contentElement.innerHTML = window.DOMPurify.sanitize(renderedHtml);
} else {
contentElement.textContent = source;
}
if (window.AstrBotT2IShiki) {
window.AstrBotT2IShiki.highlightAllCodeBlocks(contentElement, "github-dark");

View File

@@ -416,20 +416,27 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.7/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>
<script id="markdown-source" type="application/json">{{ text | tojson }}</script>
<script>
(function () {
const contentElement = document.getElementById("content");
const source = document.getElementById("markdown-source").value;
const sourceNode = document.getElementById("markdown-source");
const source = JSON.parse(sourceNode.textContent || '""');
marked.setOptions({
gfm: true,
breaks: false,
});
contentElement.innerHTML = marked.parse(source);
const renderedHtml = marked.parse(source);
if (window.DOMPurify) {
contentElement.innerHTML = window.DOMPurify.sanitize(renderedHtml);
} else {
contentElement.textContent = source;
}
assignHeadingIds(contentElement);
enhanceCodeBlocks(contentElement);

View File

@@ -243,15 +243,22 @@
<article style="margin-top: 32px" id="content"></article>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.7/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>
<script id="markdown-source" type="application/json">{{ text | tojson }}</script>
<script>
(function () {
const contentElement = document.getElementById("content");
const source = document.getElementById("markdown-source").value;
const sourceNode = document.getElementById("markdown-source");
const source = JSON.parse(sourceNode.textContent || '""');
contentElement.innerHTML = marked.parse(source);
const renderedHtml = marked.parse(source);
if (window.DOMPurify) {
contentElement.innerHTML = window.DOMPurify.sanitize(renderedHtml);
} else {
contentElement.textContent = source;
}
if (window.AstrBotT2IShiki) {
window.AstrBotT2IShiki.highlightAllCodeBlocks(contentElement, "github-light");

View File

@@ -10,6 +10,25 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_p
logger = logging.getLogger("astrbot")
_ALLOWED_VARS = frozenset({"text", "version", "shiki_runtime"})
_DOMPURIFY_SCRIPT = (
'<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.7/dist/purify.min.js">'
"</script>"
)
_MARKED_SCRIPT = (
'<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>'
)
_UNSAFE_TEXT_SAFE_FILTER = re.compile(r"\{\{\s*text\s*\|\s*safe\s*\}\}")
_DIRECT_MARKED_RENDER = re.compile(
r"(?P<indent>[ \t]*)contentElement\.innerHTML\s*=\s*marked\.parse\(source\);"
)
_MARKDOWN_SOURCE_TEXTAREA = re.compile(
r'<textarea\s+id="markdown-source"\s+hidden>\s*'
r"\{\{\s*text\s*\|\s*(?:safe|e|escape)\s*\}\}"
r"\s*</textarea>"
)
_MARKDOWN_SOURCE_VALUE_READ = re.compile(
r'(?P<indent>[ \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(
'<script id="markdown-source" type="application/json">{{ text | tojson }}</script>',
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:
"""获取用户模板的完整路径,防止路径遍历漏洞。"""

View File

@@ -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 = "</script></textarea><script>window.__astrbot_t2i_xss = true</script>"
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 "<script>window.__astrbot_t2i_xss = true</script>" not in rendered
assert rendered.count("</textarea>") == 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 = """
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>
<script>
const contentElement = document.getElementById("content");
const source = document.getElementById("markdown-source").value;
contentElement.innerHTML = marked.parse(source);
</script>
"""
(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 = """
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>
<script>
const contentElement = document.getElementById("content");
const source = document.getElementById("markdown-source").value;
contentElement.innerHTML = marked.parse(source);
</script>
"""
hardened, tmpl_data = NetworkRenderStrategy._prepare_template_sync(
unsafe_template,
{"text": "</textarea><script>x</script>"},
)
assert tmpl_data["text"] == "</textarea><script>x</script>"
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(
'<textarea id="markdown-source">{{ text | safe }}</textarea>',
)