From 8a625bfb27789ffa924de350e89e7314afd19c15 Mon Sep 17 00:00:00 2001 From: hyungi Date: Tue, 16 Jun 2026 13:45:33 +0900 Subject: [PATCH] =?UTF-8?q?fix(security):=20soft-delete=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=EC=A1=B0=ED=99=94=20=E2=80=94=20get=5Fliv?= =?UTF-8?q?e=5Fdocument=20=ED=97=AC=ED=8D=BC=20+=20paper-holder=20(R7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조회/수정 경로는 deleted_at 을 일관 가드하나 파일/콘텐츠 서빙 5엔드포인트 (get_document_file·image_raw·save_content·preview·content)가 'if not doc' 만 검사 → 삭제 문서 원본/preview/전문/마커이미지가 doc_id(+토큰)만으로 노출·삭제 문서 NAS 재기록. get_live_document(session, doc_id) 헬퍼(없거나 deleted_at 이면 404)로 통일 — '경로마다 deleted_at 기억' 대신 구조 강제(추가될 서빙 경로 자동 보호). save_content 는 삭제 문서 쓰기 차단까지. find_paper_holder 도 deleted_at IS NULL 필터 추가(dedup.find_canonical 대칭). 검증: py_compile 통과. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/documents.py | 36 +++++++++++++++++++---------------- app/services/papers/holder.py | 3 ++- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/app/api/documents.py b/app/api/documents.py index 31b4722..55dda6c 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -69,6 +69,19 @@ def _upload_error(status_code: int, error_code: str, message: str) -> HTTPExcept ) +async def get_live_document(session: AsyncSession, doc_id: int) -> Document: + """soft-delete(deleted_at) 가드 포함 문서 조회 — 없거나 삭제됐으면 404 (R7). + + 조회/수정 경로는 deleted_at 을 일관 가드하나 파일/콘텐츠 서빙 엔드포인트가 누락 → + 삭제 문서의 원본/preview/전문이 doc_id(+유효 토큰)만으로 노출되던 비대칭. '경로마다 + deleted_at 기억'에 의존하지 않게 헬퍼로 구조 강제(추가될 서빙 경로도 자동 보호). + """ + doc = await session.get(Document, doc_id) + if not doc or doc.deleted_at is not None: + raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + return doc + + async def _near_dup_scan_bg(doc_id: int) -> None: """B-3: post-upload near_duplicate 스캔 (BackgroundTask). 자체 세션, best-effort. @@ -838,9 +851,7 @@ async def get_document_file( # 일반 Bearer 헤더 인증 시도 raise HTTPException(status_code=401, detail="토큰이 필요합니다") - doc = await session.get(Document, doc_id) - if not doc: - raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + doc = await get_live_document(session, doc_id) # note(메모)는 물리 파일이 없음 if not doc.file_path: @@ -943,10 +954,8 @@ async def get_document_image_raw( if not payload or payload.get("type") != "access": raise HTTPException(status_code=401, detail="유효하지 않은 토큰") - # 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단) - doc = await session.get(Document, doc_id) - if doc is None: - raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + # 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단 + soft-delete 가드) + doc = await get_live_document(session, doc_id) img = await session.scalar( select(DocumentImage).where( @@ -1357,9 +1366,8 @@ async def save_document_content( body: dict = None, ): """Markdown 원본 파일 저장 + extracted_text 갱신""" - doc = await session.get(Document, doc_id) - if not doc: - raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + # soft-delete 문서엔 쓰기 차단 (R7 — 삭제 문서 resurrect / NAS 재기록 방지) + doc = await get_live_document(session, doc_id) if doc.file_format not in ("md", "txt"): raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)") @@ -1399,9 +1407,7 @@ async def get_document_preview( else: raise HTTPException(status_code=401, detail="토큰이 필요합니다") - doc = await session.get(Document, doc_id) - if not doc: - raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + doc = await get_live_document(session, doc_id) preview_path = Path(settings.nas_mount_path) / "PKM" / ".preview" / f"{doc_id}.pdf" if not preview_path.exists(): @@ -1448,9 +1454,7 @@ async def get_document_content( session: Annotated[AsyncSession, Depends(get_session)], ): """문서 전문 텍스트 반환 (서비스 호출용).""" - doc = await session.get(Document, doc_id) - if not doc: - raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + doc = await get_live_document(session, doc_id) raw_text = doc.extracted_text or "" content = raw_text[:15000] diff --git a/app/services/papers/holder.py b/app/services/papers/holder.py index 2455dc5..1bc601a 100644 --- a/app/services/papers/holder.py +++ b/app/services/papers/holder.py @@ -32,7 +32,8 @@ async def find_paper_holder(session, raw_or_normalized_doi): return None result = await session.execute( select(Document) - .where(Document.material_type == "paper", _DOI_EXPR == doi) + .where(Document.material_type == "paper", _DOI_EXPR == doi, + Document.deleted_at.is_(None)) .limit(1) ) return result.scalars().first()