From 690b22fe58352c28ff3548e0c339e51fcc7adf68 Mon Sep 17 00:00:00 2001 From: hyungi Date: Tue, 16 Jun 2026 14:07:07 +0900 Subject: [PATCH] =?UTF-8?q?fix(hardening):=20collect-lock=20TOCTOU=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(R9)=20+=20tier=5Fbackfill=20fstring=20all?= =?UTF-8?q?owlist=20(R12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - news.collect: locked() 체크 후 실제 acquire 가 별도 task 안에서 일어나 그 사이 다른 요청이 끼어들어 이중 수집 task 가 생기던 TOCTOU. 핸들러에서 동기 acquire + task finally release 로 원자화. - tier_backfill._enqueue_domain: filter_clause 가 SQL 에 직접 보간되나 allowlist 가드 부재 (retrieval_service _VALID_DOCS_TABLE 정본 대비 비대칭). DOMAIN_PRIORITY 출처 allowlist final gate 추가 — 현재 모듈 상수라 injection 0 이나 외부 입력화 시 즉시 차단. 검증: py_compile 통과. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/news.py | 9 ++++++++- app/workers/tier_backfill.py | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) 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