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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user