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:
@@ -21,6 +21,13 @@ router = APIRouter(prefix="/api/export", tags=["export"])
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
# Hard cap on rows an HTML export will load + render. An account-wide export
|
||||
# with no date range can match millions of messages; rendering all of them in
|
||||
# memory risks OOM. The cap is a safety valve, not a tight budget — at 5k rows
|
||||
# the HTML is already unwieldy in any browser. When hit, the response carries
|
||||
# a visible warning banner so the operator knows to narrow the date range.
|
||||
EXPORT_MAX_ROWS = 5000
|
||||
|
||||
|
||||
def _ts_text(ts: int | None) -> str:
|
||||
if not ts:
|
||||
@@ -73,7 +80,14 @@ def _render_media(m) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _render_chat_html(rows: list[Message], channel: str, owner: str, peer: str) -> str:
|
||||
def _render_chat_html(
|
||||
rows: list[Message],
|
||||
channel: str,
|
||||
owner: str,
|
||||
peer: str,
|
||||
*,
|
||||
warning: str = "",
|
||||
) -> str:
|
||||
bubbles = []
|
||||
for m in rows:
|
||||
side = "right" if m.is_send else "left"
|
||||
@@ -90,6 +104,7 @@ def _render_chat_html(rows: list[Message], channel: str, owner: str, peer: str)
|
||||
<title>{title}</title><style>
|
||||
body{{font-family:-apple-system,Segoe UI,sans-serif;background:#f5f5f5;margin:0;padding:16px}}
|
||||
h1{{font-size:16px;margin:0 0 12px}}
|
||||
.export-warning{{background:#fff3cd;border:1px solid #ffeeba;color:#856404;padding:8px 12px;border-radius:4px;margin:0 0 12px;font-size:13px}}
|
||||
.bubble{{max-width:70%;margin:6px 0;padding:8px 12px;border-radius:8px;clear:both}}
|
||||
.bubble.right{{float:right;background:#95ec69;text-align:right}}
|
||||
.bubble.left{{float:left;background:#fff}}
|
||||
@@ -98,7 +113,7 @@ h1{{font-size:16px;margin:0 0 12px}}
|
||||
.bubble .media{{margin-top:4px;font-size:13px}}
|
||||
.bubble .media audio,.bubble .media video,.bubble .media img{{display:block;max-width:100%}}
|
||||
.bubble .media .dur{{font-size:12px;color:#666;margin-left:4px}}
|
||||
</style></head><body><h1>{title}</h1>
|
||||
</style></head><body>{warning}<h1>{title}</h1>
|
||||
{''.join(bubbles) or '<p>暂无消息</p>'}
|
||||
</body></html>"""
|
||||
|
||||
@@ -132,9 +147,21 @@ async def export_chat_html(
|
||||
q = q.where(Message.timestamp >= start)
|
||||
if end is not None:
|
||||
q = q.where(Message.timestamp <= end)
|
||||
q = q.order_by(sa.nullslast(Message.timestamp.asc()), Message.msg_id.asc())
|
||||
q = q.order_by(sa.nullslast(Message.timestamp.asc()), Message.msg_id.asc()).limit(
|
||||
EXPORT_MAX_ROWS
|
||||
)
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
html = _render_chat_html(list(rows), channel, owner_account_id, peer)
|
||||
# If we filled the cap there may be more rows beyond it — surface a visible
|
||||
# warning so the operator knows the export was truncated and can re-run
|
||||
# with a tighter date range.
|
||||
capped = len(rows) >= EXPORT_MAX_ROWS
|
||||
warning = (
|
||||
f'<div class="export-warning">⚠️ 结果超过 {EXPORT_MAX_ROWS} 条,'
|
||||
f'仅导出前 {EXPORT_MAX_ROWS} 条。请缩小日期范围。</div>'
|
||||
if capped
|
||||
else ""
|
||||
)
|
||||
html = _render_chat_html(list(rows), channel, owner_account_id, peer, warning=warning)
|
||||
safe = peer or "all"
|
||||
filename = f"chat-{channel}-{owner_account_id}-{safe}.html".replace("@", "_").replace("/", "_")
|
||||
return Response(
|
||||
|
||||
Reference in New Issue
Block a user