fix(security): 보안 위생 5건 — library admin 게이트·edit_url SSRF·보안헤더·8080 바인드·하드코딩 비번 제거
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) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,13 @@ http://document.hyungi.net {
|
|||||||
# 명시 Content-Type match — 기본 match 의 text/* 는 text/event-stream 까지 포함해
|
# 명시 Content-Type match — 기본 match 의 text/* 는 text/event-stream 까지 포함해
|
||||||
# SSE(/api/eid/chat)의 첫 ~512B 를 gzip 버퍼링함. SSE 제외, 기존 압축 대상은 보존.
|
# SSE(/api/eid/chat)의 첫 ~512B 를 gzip 버퍼링함. SSE 제외, 기존 압축 대상은 보존.
|
||||||
# (응답 매처는 header <필드> <값> 한 쌍씩 — 여러 줄 = OR. 한 줄 다중 값은 파싱 에러)
|
# (응답 매처는 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 {
|
encode {
|
||||||
gzip
|
gzip
|
||||||
match {
|
match {
|
||||||
|
|||||||
@@ -1277,6 +1277,14 @@ async def update_document(
|
|||||||
if val is not None and val not in ("business", "knowledge"):
|
if val is not None and val not in ("business", "knowledge"):
|
||||||
raise HTTPException(status_code=400, detail="doc_purpose는 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():
|
for field, value in update_data.items():
|
||||||
setattr(doc, field, value)
|
setattr(doc, field, value)
|
||||||
doc.updated_at = datetime.now(timezone.utc)
|
doc.updated_at = datetime.now(timezone.utc)
|
||||||
|
|||||||
+5
-5
@@ -9,7 +9,7 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy import text as sql_text
|
from sqlalchemy import text as sql_text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_session
|
||||||
from core.library import LIBRARY_PREFIX, MAX_DEPTH, normalize_library_path
|
from core.library import LIBRARY_PREFIX, MAX_DEPTH, normalize_library_path
|
||||||
from models.category import LibraryCategory
|
from models.category import LibraryCategory
|
||||||
@@ -78,7 +78,7 @@ async def list_categories(
|
|||||||
@router.post("/categories", response_model=CategoryResponse, status_code=201)
|
@router.post("/categories", response_model=CategoryResponse, status_code=201)
|
||||||
async def create_category(
|
async def create_category(
|
||||||
body: CategoryCreate,
|
body: CategoryCreate,
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(require_admin)],
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
):
|
):
|
||||||
"""카테고리 생성 (조상 자동 생성 포함)"""
|
"""카테고리 생성 (조상 자동 생성 포함)"""
|
||||||
@@ -133,7 +133,7 @@ async def create_category(
|
|||||||
@router.patch("/categories", response_model=CategoryResponse)
|
@router.patch("/categories", response_model=CategoryResponse)
|
||||||
async def rename_category(
|
async def rename_category(
|
||||||
body: CategoryRename,
|
body: CategoryRename,
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(require_admin)],
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
):
|
):
|
||||||
"""카테고리 이름 변경 (leaf only, path 기반 식별)"""
|
"""카테고리 이름 변경 (leaf only, path 기반 식별)"""
|
||||||
@@ -214,7 +214,7 @@ async def rename_category(
|
|||||||
@router.delete("/categories", status_code=204)
|
@router.delete("/categories", status_code=204)
|
||||||
async def delete_category(
|
async def delete_category(
|
||||||
path: str = Query(..., description="삭제할 카테고리 경로"),
|
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,
|
session: Annotated[AsyncSession, Depends(get_session)] = None,
|
||||||
):
|
):
|
||||||
"""카테고리 삭제 (leaf only, 문서 없는 경우만)"""
|
"""카테고리 삭제 (leaf only, 문서 없는 경우만)"""
|
||||||
@@ -410,7 +410,7 @@ async def get_facet_values(
|
|||||||
@router.post("/facets", response_model=FacetValueResponse, status_code=201)
|
@router.post("/facets", response_model=FacetValueResponse, status_code=201)
|
||||||
async def add_facet_value(
|
async def add_facet_value(
|
||||||
body: FacetValueResponse,
|
body: FacetValueResponse,
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(require_admin)],
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
):
|
):
|
||||||
"""facet 사전에 새 값 추가"""
|
"""facet 사전에 새 값 추가"""
|
||||||
|
|||||||
+1
-1
@@ -263,7 +263,7 @@ services:
|
|||||||
caddy:
|
caddy:
|
||||||
image: caddy:2
|
image: caddy:2
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "127.0.0.1:8080:80" # 2026-06-20: LAN 우회 차단 (실 ingress=home-caddy→caddy:80 도커망)
|
||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ async def run(topic_id: int, exam_round: str, apply: bool, abort_threshold: int)
|
|||||||
host="postgres",
|
host="postgres",
|
||||||
port=5432,
|
port=5432,
|
||||||
user="pkm",
|
user="pkm",
|
||||||
password="uW38friypljVS0X2ULoMnw",
|
password=os.environ["POSTGRES_PASSWORD"],
|
||||||
database="pkm",
|
database="pkm",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ dry-run 먼저 출력 (각 필드 N건). 그 다음 --apply 옵션으로 UPDATE.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ async def main() -> None:
|
|||||||
host="postgres",
|
host="postgres",
|
||||||
port=5432,
|
port=5432,
|
||||||
user="pkm",
|
user="pkm",
|
||||||
password="uW38friypljVS0X2ULoMnw",
|
password=os.environ["POSTGRES_PASSWORD"],
|
||||||
database="pkm",
|
database="pkm",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user