diff --git a/app/api/news.py b/app/api/news.py index f975062..ff33370 100644 --- a/app/api/news.py +++ b/app/api/news.py @@ -195,10 +195,17 @@ async def trigger_collect( if _collect_lock.locked(): raise HTTPException(status_code=429, detail="수집이 이미 진행 중입니다") + # TOCTOU 제거 (R9) — 기존엔 locked() 체크 후 실제 acquire 가 별도 task 안에서 일어나, 그 + # 사이 다른 요청이 끼어들어 이중 수집 task 가 생길 수 있었다. 핸들러에서 동기적으로(uncontended + # Lock.acquire 는 이벤트루프 양보 없이 즉시 완료) acquire 하고 task 의 finally 에서 release. + await _collect_lock.acquire() + async def _run_with_lock(): - async with _collect_lock: + try: from workers.news_collector import run await run() + finally: + _collect_lock.release() asyncio.create_task(_run_with_lock()) return {"message": "뉴스 수집 시작됨"} diff --git a/app/workers/tier_backfill.py b/app/workers/tier_backfill.py index f2f8ec0..cfd60bc 100644 --- a/app/workers/tier_backfill.py +++ b/app/workers/tier_backfill.py @@ -52,6 +52,11 @@ DOMAIN_PRIORITY: list[tuple[str, str]] = [ ("manual", "source_channel = 'manual'"), ] +# R12: filter_clause 는 SQL 에 직접 보간되므로 이 allowlist(DOMAIN_PRIORITY 출처) 통과분만 +# 허용 — 현재 모듈 상수라 injection 경로 0 이나, 외부 입력화 시 즉시 차단하는 final gate +# (retrieval_service 의 _VALID_DOCS_TABLE allowlist 정본 대비 비대칭 해소). +_ALLOWED_FILTER_CLAUSES: frozenset[str] = frozenset(c for _, c in DOMAIN_PRIORITY) + async def _classify_pending(session: AsyncSession) -> int: return int(await session.scalar(text(""" @@ -66,6 +71,9 @@ async def _enqueue_domain(session: AsyncSession, filter_clause: str, limit: int) extracted_text 빈 문자열 (LENGTH=0) 도 제외 — classify_worker 는 not doc.extracted_text truthy 체크라 빈 문자열에서 ValueError raise. 무한 retry 루프 방지. """ + # R12: SQL 직접 보간 전 allowlist final gate. + if filter_clause not in _ALLOWED_FILTER_CLAUSES: + raise ValueError(f"비허용 filter_clause (allowlist 외): {filter_clause!r}") sql = text(f""" INSERT INTO processing_queue (document_id, stage, status, attempts, max_attempts) SELECT id, 'classify', 'pending', 0, 3