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
+122
View File
@@ -274,6 +274,128 @@ async def test_run_once_swallows_client_build_failure(engine, session_maker, mon
assert results == []
# ---------------------------------------------------------------------------
# Advisory lock — concurrent-invocation protection (Fix 3)
# ---------------------------------------------------------------------------
def _utcnow_naive() -> dt.datetime:
"""Naive UTC 'now', matching how SQLAlchemy reads back ``SyncRun.started_at``.
The column is a non-tz-aware ``DateTime``; an aware ``datetime`` would
raise TypeError when compared against DB-read values inside ``_safe_run``.
"""
return dt.datetime.now(dt.UTC).replace(tzinfo=None)
async def _make_static_engine():
"""In-memory engine with ``StaticPool`` so cross-session rows are visible.
The default ``:memory:`` pool gives each checkout a fresh DB; the advisory
lock test seeds a row in one session and needs ``_safe_run``'s own session
to see it, so the engine must pin a single connection.
"""
from sqlalchemy.pool import StaticPool
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return engine
async def test_run_once_skips_when_another_run_is_active(monkeypatch):
"""A 'running' SyncRun for the same entity started < TTL ago → skip."""
from app.db.models import SyncRun
engine = await _make_static_engine()
session_maker = async_sessionmaker(engine, expire_on_commit=False)
# Seed a 'running' SyncRun for "friends" started just now.
async with session_maker() as s:
s.add(
SyncRun(
entity_type="friends",
status="running",
started_at=_utcnow_naive(),
)
)
await s.commit()
fake_client = MagicMock()
monkeypatch.setattr(
scheduler_module,
"_build_client_from_env",
AsyncMock(return_value=fake_client),
)
called = {"count": 0}
async def fake_runner(session, client, store):
called["count"] += 1
return [_mk_result("wechat_friend", "success", 1, 1)]
monkeypatch.setitem(ENTITY_FUNCS, "friends", fake_runner)
try:
sched = SyncScheduler(engine=engine, session_maker=session_maker)
results = await sched.run_once("friends")
finally:
await engine.dispose()
# Skipped: the runner must NOT have run.
assert called["count"] == 0
# The skip surfaces as a single 'skipped' SyncResult with a reason.
assert len(results) == 1
assert results[0].status == "skipped"
assert "another sync is running" in (results[0].error or "")
async def test_run_once_proceeds_after_stale_run_expires(monkeypatch):
"""A 'running' SyncRun older than the TTL does NOT block — auto-recover."""
from app.db.models import SyncRun
engine = await _make_static_engine()
session_maker = async_sessionmaker(engine, expire_on_commit=False)
# Seed a 'running' SyncRun for "friends" 2 hours old — past the 60-min TTL.
async with session_maker() as s:
s.add(
SyncRun(
entity_type="friends",
status="running",
started_at=_utcnow_naive() - dt.timedelta(hours=2),
)
)
await s.commit()
fake_client = MagicMock()
monkeypatch.setattr(
scheduler_module,
"_build_client_from_env",
AsyncMock(return_value=fake_client),
)
async def fake_runner(session, client, store):
return [_mk_result("wechat_friend", "success", 3, 3)]
monkeypatch.setitem(ENTITY_FUNCS, "friends", fake_runner)
try:
sched = SyncScheduler(engine=engine, session_maker=session_maker)
results = await sched.run_once("friends")
finally:
await engine.dispose()
# Not skipped: the runner ran and returned its success result.
assert len(results) == 1
assert results[0].status == "success"
assert results[0].fetched == 3
# ---------------------------------------------------------------------------
# SyncScheduler.start — job registration
# ---------------------------------------------------------------------------