fix(security): 路径穿越防护+导出行数上限+并发同步锁(3 critical/important)

- media.py: GET /api/media/{hash} 现强制 file 必须位于 Settings.media_dir
  沙箱内(resolve + relative_to),杜绝 local_path 越权读取任意文件。
- export.py: /api/export/chat-html 加 EXPORT_MAX_ROWS=5000 硬上限(.limit()),
  超出时仅渲染前 5000 条并插入 .export-warning 横幅,防止 OOM。
- scheduler.py: _safe_run 增加 SyncRun advisory lock(60min TTL),发现同
  entity 存在 status='running' 且在 TTL 内的记录即返回 skipped SyncResult;
  同时为本轮 stake 一行 wrapping SyncRun,让并发 CLI/scheduler/手动触发
  能相互避让。崩溃场景由 TTL 自动恢复。
- tests: 新增 path-traversal 拒绝/export 截断警告/advisory lock skip+TTL
  恢复 5 个测试;test_api_media 的 fixture 改为 MEDIA_DIR=tmp_path 以适配
  新沙箱语义。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-12 10:37:13 +08:00
parent 461e9ff081
commit caef32891e
6 changed files with 388 additions and 6 deletions
+54
View File
@@ -6,8 +6,10 @@
from __future__ import annotations
import functools
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.routers.export import EXPORT_MAX_ROWS
from app.db.models import Message
@@ -39,6 +41,26 @@ async def _seed(session: AsyncSession) -> None:
await session.commit()
async def _seed_many(session: AsyncSession, n: int) -> None:
"""Seed ``n`` DM messages so the row cap can be exercised."""
session.add_all([
Message(
msg_id=f"cap{i}",
channel="wechat",
owner_account_id="wx_owner",
talker_id="wx_friend",
room_id=None,
text=f"msg{i}",
timestamp=1000 + i,
is_send=i % 2 == 0,
type="text",
type_raw=1,
)
for i in range(n)
])
await session.commit()
async def test_export_dm_returns_html(make_client):
client = await make_client(_seed)
r = await client.get("/api/export/chat-html", params={
@@ -83,3 +105,35 @@ async def test_export_embeds_media(make_client):
# 本地路径 → 占位提示,不嵌 <audio>
assert "[本地媒体]" in body
assert "/api/media/abc123" not in body
async def test_export_caps_rows_and_emits_warning(make_client):
"""When the cap is hit, only EXPORT_MAX_ROWS rows render + a warning shows."""
# Seed twice the cap so the truncation is unambiguous.
seed = functools.partial(_seed_many, n=EXPORT_MAX_ROWS * 2)
client = await make_client(seed)
r = await client.get("/api/export/chat-html", params={
"channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "wx_friend"})
assert r.status_code == 200
body = r.text
# The warning DIV is rendered (not just the CSS class in <style>).
assert '<div class="export-warning">' in body
assert str(EXPORT_MAX_ROWS) in body
# The first EXPORT_MAX_ROWS seeded rows (timestamps 1000..1000+N-1) render;
# anything past the cap must NOT appear. msg_id pattern "cap{i}" lets us
# count rendered bubbles precisely.
last_kept = EXPORT_MAX_ROWS - 1
last_dropped = EXPORT_MAX_ROWS # first row beyond the cap
assert f"msg{last_kept}" in body
assert f"msg{last_dropped}" not in body
async def test_export_no_warning_below_cap(make_client):
"""Sub-cap exports render no warning DIV."""
seed = functools.partial(_seed_many, n=10)
client = await make_client(seed)
r = await client.get("/api/export/chat-html", params={
"channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "wx_friend"})
assert r.status_code == 200
# No warning DIV (the CSS class still lives in <style> — that's fine).
assert '<div class="export-warning">' not in r.text