mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-04 03:30:15 +08:00
Compare commits
1 Commits
dev
...
fix/t2i-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b17af07d14 |
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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:
|
||||
"""获取用户模板的完整路径,防止路径遍历漏洞。"""
|
||||
|
||||
112
tests/unit/test_t2i_templates.py
Normal file
112
tests/unit/test_t2i_templates.py
Normal 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>',
|
||||
)
|
||||
Reference in New Issue
Block a user