각각은 합리적인 두 프레임워크 기본값(ORM의 expire-on-commit 동작과 진위값처럼 보이는 불리언 반환값)이, 호출 코드가 조용히 세운 가정과 결합했을 때만 버그가 됐다 — '이 기본값이 여기서 실제로 뭘 하는지' 물었다면 더 일찍 잡을 수 있었다.
배경
Compass(1편에서 다룬 그 워크플로우 검증 SaaS)는 가입 절차에 일회용 인증코드(OTP) 검증 흐름이 있고, 비동기 SQLAlchemy 세션을 쓴다. 또한 사용자가 워크플로우 추론 기능에 자신의 API 엔드포인트를 직접 설정할 수 있는 "AI executor"(BYOK — bring your own key) 기능도 있어서, 사용자가 지정한 URL로 외부 HTTP 요청을 보내야 한다.
이 둘 다 실제로 악용 가능한 진짜 버그를 갖고 있었다 — 뭔가 눈에 띄게 고장 나서가 아니라 출시 전 정기 보안 점검 중에 발견됐다.
버그 1 — 한 번 더 틀리기도 전에 잠기는 사용자들
OTP 흐름은 계정을 잠그기 전에 제한된 횟수의 오답을 허용한다. 설정된 한도가 5회인데 사용자들이 4회 오답만에 잠긴다는 보고가 들어왔다.
# 수정 전
record.fail_count += 1
await db.commit()
if record.fail_count >= max_attempts:
lock_account()
겉보기엔 맞아 보인다. 버그는 await db.commit() 이후에 record.fail_count가 무엇을 의미하는지에 있다. 비동기 세션의 기본 동작(expire_on_commit=True)은 커밋 후 모든 ORM 속성을 stale로 표시한다 — 다음에 record.fail_count를 건드리는 순간 SQLAlchemy가 조용히 DB에서 현재 값을 다시 조회한다. 증가값이 이미 커밋됐기 때문에, 커밋 후 record.fail_count를 다시 읽으면 임계값과 비교하려던 값이 아니라 이미 증가된 새 값이 돌아온다. 네 번째 오답 시점에, 코드는 실제로는 다섯 번째 값을 한도와 비교하게 된다.
# 수정 후 — 커밋 전에 고정된 로컬 변수로 계산 및 비교
new_fail_count = record.fail_count + 1
record.fail_count = new_fail_count
await db.commit()
if new_fail_count >= max_attempts:
lock_account()
버그 2 — 오답 경로가 실제로는 아무것도 막지 않고 있었음
잠금 버그를 추적하던 중, OTP 검증을 호출하는 코드에서 훨씬 심각한 두 번째 문제가 드러났다.
# 수정 전
verify_otp(code, submitted)
# verify_otp가 True를 반환했든 False를 반환했든 실행이 계속됨
proceed_with_signup()
verify_otp()는 틀린 코드일 때 예외를 던지지 않고 False를 반환한다. 호출부는 반환값을 전혀 확인하지 않고 있었다. 잠금 임계값에 도달하기 전까지는, 사용자가 틀린 OTP 코드를 제출해도 가입 흐름이 그대로 진행됐다 — 단순 UX 불편이 아니라 인증 우회였다. 반환값을 명시적으로 확인하고 불일치 시 제대로 된 400을 던지도록 수정했다.
이 두 버그 모두, 빠르게 훑어보면 완전히 합리적으로 보이는 코드 안에 숨어 있었다 — 특정 ORM 설정 옵션이 커밋 후 실제로 무엇을 하는지 추적해야만, 그리고 별개로 "이 호출의 성공 경로만이 아니라 실패 경로에서는 무슨 일이 일어나는가"를 구체적으로 물어봐야만 드러나는 종류의 버그였다.
버그 3 — 제품 자체의 설계를 반영해야 했던 SSRF 방어
Compass의 AI executor는 설계상 사용자가 지정한 엔드포인트 URL을 받는다 — BYOK의 핵심은 사용자의 추론 호출이 원하는 어디로든 갈 수 있다는 것이다. 단순한 해결책은 도메인 화이트리스트겠지만, 이 제품의 설정 자체가 기본값을 빈 화이트리스트로 명시적으로 제공한다. 고정된 도메인 집합을 강제하면 대부분의 실제 사용자에게 BYOK 사용 사례 자체가 깨지기 때문이다.
그래서 방어는 도메인 기반이 아니라 IP 기반이어야 했다: 루프백 주소, 링크-로컬 주소(클라우드 메타데이터 엔드포인트를 포함하는 — SSRF에서 인증정보 탈취로 이어지는 전형적인 벡터), 미지정 주소, 멀티캐스트를 차단한다 — 어떤 도메인 이름으로 그 IP가 resolve됐는지와 무관하게.
def _reject_if_private_target(url: str) -> None:
resolved_ip = resolve_hostname(url)
if is_loopback(resolved_ip) or is_link_local(resolved_ip) \
or is_unspecified(resolved_ip) or is_multicast(resolved_ip):
raise SecurityError("target address not allowed")
이 체크의 첫 버전은 한 발 더 나가서 모든 사설 IP 대역(10.x, 172.16.x, 192.168.x)을 일괄 차단했다 — 안전한 기본값처럼 보였다. 그런데 실제 정상 트래픽을 깨뜨렸다: 이 배포 환경이 쓰던 내부 AI 게이트웨이가 설계상 사설 IP로 resolve되기 때문이다(사내망 내부 서비스라서). "이 허용된 URL은 여전히 동작해야 한다"를 구체적으로 검증하는 테스트가 출시 전에 이 과도한 차단을 잡아냈다. 수정은 이 사용 사례에서 절대 정상적인 목적지가 될 수 없는 카테고리(루프백, 링크-로컬, 멀티캐스트, 미지정)로 규칙을 좁히고, RFC1918 사설 대역은 명시적으로 허용했다 — 이 배포 환경 자체의 정상 인프라가 거기 살고 있었기 때문이다.
검증
- 백엔드 전체 테스트 스위트: 실패 25건 → 0건(위 수정을 잡아내는 테스트와, 이전 리팩터링에서 남은 무관한 낡은 테스트들을 같은 작업에서 함께 정리).
- 배포 후 재검증: 실제로 운영 중인 서비스 프로세스가 정말로 코드 변경을 반영했는지 재확인했다 — 프로세스 관리자가 코드를 핫리로드하지 않기 때문에, 서비스 재기동 없이 디스크에만 있는 수정은 프로덕션에서는 수정이 아니다. 이건 직접 짚고 넘어갈 가치가 있는 실수다: 처음엔 테스트 스위트 통과와 코드 푸시 완료를 근거로 수정이 끝났다고 보고했는데, 서비스의 시작 시각을 커밋 시각과 명시적으로 대조해서야 낡은 프로세스가 여전히 돌고 있다는 걸 잡아냈다.
이 세 버그 중 둘은 "이 프레임워크 기본값이 이 특정 설정에서 실제로 뭘 하는가"를 묻지 않은 데서 나왔다 — SQLAlchemy의 expire-on-commit 동작과 OTP 관련 함수의 불리언 반환값은 각각 개별적으로는 합리적인 기본값이지만, 호출부가 조용히 세운 가정과 결합하는 순간에만 버그가 된다. 세 번째 버그는 보안 수정이 "사설 대역은 무조건 다 막자"는 일반적인 직관이 아니라 제품의 실제 설계 의도에 비추어 평가되어야 한다는 걸 일깨워준다 — 올바른 허용/차단 경계는 이 기능이 애초에 왜 존재하는지를 이해해야만 찾을 수 있었다. 그리고 마지막의 배포 실수는 그 자체로 교훈이다: 테스트 통과와 코드 푸시는 "운영 중인 서비스가 이 코드를 실행하고 있다"는 것과 같은 주장이 아니다 — 그 마지막 연결고리는 매번 명시적으로 확인해야 한다.
개별적으로는 안전한 두 프레임워크 기본값이 결합해 보안 버그가 될 수 있는가?
그렇다 — ORM의 expire-on-commit 동작과 프레임워크의 OTP 관련 불리언 반환값은 각각 독자적으로는 합리적인 기본값이었지만, 호출 코드의 조용한 가정과 결합해 거의 인증 우회에 가까운 상황을 만들어냈다.
첫 댓글을 남겨보세요