From 63be005c6faa8236e8a2f35f0ae597d635a76920 Mon Sep 17 00:00:00 2001 From: hyungi Date: Sat, 20 Jun 2026 05:48:02 +0000 Subject: [PATCH] =?UTF-8?q?fix(security):=20=EB=B3=B4=EC=95=88=20=EC=9C=84?= =?UTF-8?q?=EC=83=9D=205=EA=B1=B4=20=E2=80=94=20library=20admin=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8=C2=B7edit=5Furl=20SSRF=C2=B7?= =?UTF-8?q?=EB=B3=B4=EC=95=88=ED=97=A4=EB=8D=94=C2=B78080=20=EB=B0=94?= =?UTF-8?q?=EC=9D=B8=EB=93=9C=C2=B7=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20?= =?UTF-8?q?=EB=B9=84=EB=B2=88=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3 library.py: categories POST/PATCH/DELETE + facets POST 를 get_current_user→require_admin (공유 분류 CRUD 를 17주체→admin 한정, news/digest 패턴 정합). M1 documents.py: update_document PATCH 에 edit_url validate_feed_url 가드 — 내부/메타데이터 주소 후속 fetch(fulltext_worker) latent SSRF 차단(API 레이어 무방비 해소, news.py 동형). Caddyfile: 보안 헤더(nosniff·X-Frame SAMEORIGIN·Referrer-Policy·-Server). HSTS 는 edge 소관. compose: caddy 8080:80 0.0.0.0→127.0.0.1 (LAN 우회 차단, 실 ingress=home-caddy→caddy:80 도커망). scripts: 하드코딩 죽은 DB 비번 → os.environ (1차 감사 누락분, .env 한정 점검이 놓침). 별도(DB): test-% 계정 12개 비활성화 (공유풀 주체 17→5, 랜덤해시라 비번노출 아님·위생). Co-Authored-By: Claude Opus 4.8 (1M context) --- Caddyfile | 7 +++++++ app/api/documents.py | 8 ++++++++ app/api/library.py | 10 +++++----- docker-compose.yml | 2 +- scripts/audit_study_question_markdown.py | 2 +- scripts/strip_outer_fences.py | 3 ++- 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Caddyfile b/Caddyfile index 06dbb83..45cc567 100644 --- a/Caddyfile +++ b/Caddyfile @@ -12,6 +12,13 @@ http://document.hyungi.net { # 명시 Content-Type match — 기본 match 의 text/* 는 text/event-stream 까지 포함해 # SSE(/api/eid/chat)의 첫 ~512B 를 gzip 버퍼링함. SSE 제외, 기존 압축 대상은 보존. # (응답 매처는 header <필드> <값> 한 쌍씩 — 여러 줄 = OR. 한 줄 다중 값은 파싱 에러) + # 2026-06-20 보안 헤더 (M: 클릭재킹·MIME 스니핑 방어). HSTS 는 TLS 종단 edge(home-caddy) 소관. + header { + X-Content-Type-Options nosniff + X-Frame-Options SAMEORIGIN + Referrer-Policy strict-origin-when-cross-origin + -Server + } encode { gzip match { diff --git a/app/api/documents.py b/app/api/documents.py index 9732489..d3af888 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -1277,6 +1277,14 @@ async def update_document( if val is not None and val not in ("business", "knowledge"): raise HTTPException(status_code=400, detail="doc_purpose는 business 또는 knowledge만 가능") + # edit_url SSRF 가드 (2026-06-20 M1): 내부/메타데이터 주소 후속 fetch 차단 (news.py 동형 검증) + if update_data.get("edit_url"): + from core.url_validator import validate_feed_url + try: + await asyncio.to_thread(validate_feed_url, update_data["edit_url"]) + except Exception as e: + raise HTTPException(status_code=400, detail=f"edit_url 검증 실패: {e}") + for field, value in update_data.items(): setattr(doc, field, value) doc.updated_at = datetime.now(timezone.utc) diff --git a/app/api/library.py b/app/api/library.py index a0137d7..26207ab 100644 --- a/app/api/library.py +++ b/app/api/library.py @@ -9,7 +9,7 @@ from sqlalchemy import func, select from sqlalchemy import text as sql_text from sqlalchemy.ext.asyncio import AsyncSession -from core.auth import get_current_user +from core.auth import get_current_user, require_admin from core.database import get_session from core.library import LIBRARY_PREFIX, MAX_DEPTH, normalize_library_path from models.category import LibraryCategory @@ -78,7 +78,7 @@ async def list_categories( @router.post("/categories", response_model=CategoryResponse, status_code=201) async def create_category( body: CategoryCreate, - user: Annotated[User, Depends(get_current_user)], + user: Annotated[User, Depends(require_admin)], session: Annotated[AsyncSession, Depends(get_session)], ): """카테고리 생성 (조상 자동 생성 포함)""" @@ -133,7 +133,7 @@ async def create_category( @router.patch("/categories", response_model=CategoryResponse) async def rename_category( body: CategoryRename, - user: Annotated[User, Depends(get_current_user)], + user: Annotated[User, Depends(require_admin)], session: Annotated[AsyncSession, Depends(get_session)], ): """카테고리 이름 변경 (leaf only, path 기반 식별)""" @@ -214,7 +214,7 @@ async def rename_category( @router.delete("/categories", status_code=204) async def delete_category( path: str = Query(..., description="삭제할 카테고리 경로"), - user: Annotated[User, Depends(get_current_user)] = None, + user: Annotated[User, Depends(require_admin)] = None, session: Annotated[AsyncSession, Depends(get_session)] = None, ): """카테고리 삭제 (leaf only, 문서 없는 경우만)""" @@ -410,7 +410,7 @@ async def get_facet_values( @router.post("/facets", response_model=FacetValueResponse, status_code=201) async def add_facet_value( body: FacetValueResponse, - user: Annotated[User, Depends(get_current_user)], + user: Annotated[User, Depends(require_admin)], session: Annotated[AsyncSession, Depends(get_session)], ): """facet 사전에 새 값 추가""" diff --git a/docker-compose.yml b/docker-compose.yml index 4e692db..dccdf1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -263,7 +263,7 @@ services: caddy: image: caddy:2 ports: - - "8080:80" + - "127.0.0.1:8080:80" # 2026-06-20: LAN 우회 차단 (실 ingress=home-caddy→caddy:80 도커망) volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data diff --git a/scripts/audit_study_question_markdown.py b/scripts/audit_study_question_markdown.py index c4ffe04..58810ef 100644 --- a/scripts/audit_study_question_markdown.py +++ b/scripts/audit_study_question_markdown.py @@ -289,7 +289,7 @@ async def run(topic_id: int, exam_round: str, apply: bool, abort_threshold: int) host="postgres", port=5432, user="pkm", - password="uW38friypljVS0X2ULoMnw", + password=os.environ["POSTGRES_PASSWORD"], database="pkm", ) try: diff --git a/scripts/strip_outer_fences.py b/scripts/strip_outer_fences.py index 9d3da1a..12bf614 100644 --- a/scripts/strip_outer_fences.py +++ b/scripts/strip_outer_fences.py @@ -16,6 +16,7 @@ dry-run 먼저 출력 (각 필드 N건). 그 다음 --apply 옵션으로 UPDATE. from __future__ import annotations import asyncio +import os import re import sys @@ -99,7 +100,7 @@ async def main() -> None: host="postgres", port=5432, user="pkm", - password="uW38friypljVS0X2ULoMnw", + password=os.environ["POSTGRES_PASSWORD"], database="pkm", ) try: