2026-06-02 (화) 개발일지
오늘의 트러블슈팅: 사주 소개팅 앱 보안 허점 세 가지 — 결제 검증, NSFW 사진 잔류, 가격 불일치
오늘은 오연(五緣) 사주 소개팅 앱의 보안 감사 결과에서 뽑힌 Critical/High 이슈 세 건을 수정했다. 처음엔 단순 코드 오류라 생각했는데, 파고들수록 결제 흐름의 구조적 빈틈, 파일 정책과 DB 스키마 간 불일치, 그리고 UI 전반에 퍼진 가격 불일치가 드러났다.
결제 검증 우회 — iap.py stub 이 운영에서 살아 있었다
증상
결제를 거치지 않고 프리미엄 기능(프로필 잠금 해제, 호감 받은 목록)을 사용할 수 있는 경로가 존재했다. iap.py:60-64의 _verify_receipt_stub가 항상 True를 반환하게 되어 있었고, 운영 환경에서도 이 stub가 호출되고 있었다.
단서 + 첫 가설
처음엔 DEBUG=True일 때만 stub가 활성화되는 if DEBUG: 게이트가 제대로 동작하지 않는 줄 알았다. .env를 확인해보니 DEBUG=False였다. 그런데도 결제 우회가 가능했다. 로그를 까보니 iap.py 내부에 _verify_receipt_stub를 호출하는 분기가 VERIFY_URL 환경변수 유무로만 결정되고 있었다. VERIFY_URL이 비어 있으면 자동으로 stub로 fallback — 운영에서도 VERIFY_URL이 설정되지 않아 stub가 그대로 살아 있었다.
전환점: stub 자체가 문제가 아니라, stub로 빠지는 조건이
DEBUG가 아닌VERIFY_URL공백이었다.
원인 추적
verify_receipt() 함수를 따라가며 분기문을 하나하나 읽었다.
# 기존 코드 (요약)
def verify_receipt(receipt_data):
if not VERIFY_URL:
return _verify_receipt_stub(receipt_data) # 운영에서도 True
# 실제 검증...
즉, 운영 배포 시 VERIFY_URL을 주입하지 않으면 stub가 기본 경로였다. 여기에 더해 영수증 중복 체크(purchase 테이블 unique 제약)도 없어서, 같은 영수증으로 여러 번 프리미엄을 활성화할 수도 있었다.
해결
세 가지 수정을 동시에 적용했다.
| 항목 | before | after |
|---|---|---|
| stub 호출 조건 | VERIFY_URL 공백 → 자동 stub |
DEBUG=True 일 때만 stub, 운영은 VERIFY_URL 필수 |
| 영수증 중복 | unique 제약 없음 | purchase 테이블에 receipt_data unique + matching 전역 unique |
| 운영 검증 | stub return True | Apple verifyReceipt / Google Play Billing 실검증 |
iap.py 전면 개편: _verify_receipt_stub은 dev 전용으로 게이팅하고, 운영에서는 실검증 실패 시 반드시 에러를 던지도록 바꿨다. matching.py의 영수증 바인딩도 함께 점검했다.
검증
- flutter analyze: error 0 (기존 103건 info/warning 변화 없음)
- dev 환경: stub 활성, 결제 테스트 통과
- staging:
VERIFY_URL주입 후 stub 호출 차단 확인 purchase테이블 unique insert 실패로 중복 영수증 거절 확인
NSFW 거절 사진 — 파일은 지웠지만 photo_url이 남아 있었다
증상
NSFW로 판정되어 거절된 사진의 파일을 서버에서 삭제했지만, DB의 photo_url 필드는 여전히 빈 문자열이 아닌 상태로 남아 있었다. 클라이언트가 photo_url을 그대로 참조하면 404가 아닌 빈 이미지가 뜨거나, 기존 URL이 캐시된 경우 이전 사진이 노출될 가능성이 있었다.
단서 + 첫 가설
처음엔 “거절 시 파일 삭제 로직만 추가하면 끝”이라고 생각했다. photos.py의 reject_photo() 엔드포인트를 보니 실제로 os.remove()로 파일을 지우고 있었다. 그런데 DB 컬럼은 건드리지 않았다. 가설: photo_url을 NULL로 업데이트하면 되겠다.
원인 추적
photo_url 컬럼 DDL을 확인했다 — nullable=False. 즉 None이 허용되지 않았다. NULL을 넣으려 하면 IntegrityError가 난다. 빈 문자열("")은 허용되지만, 기존 코드 어디에서도 reject 시 photo_url을 ""로 바꾸는 처리가 없었다.
여기에 더해 정적 파일 마운트( GET /uploads/photos/<filename> ) 자체에 인증 게이팅이 없어서, 파일이 남아 있는 한 URL만 알면 아무나 접근할 수 있는 구조였다. 하지만 이걸 바꾸려면 프론트 전체의 이미지 URL 패턴을 깨야 하므로 운영 아키텍처 변경으로 분리했다. 당장은 파일 삭제 + DB 업데이트로 막기로 했다.
해결
reject_photo()에 두 줄을 추가했다.
# before
os.remove(filepath)
# after
os.remove(filepath)
user.photo_url = "" # 빈 문자열로 변경
db.commit()
photo_url이 빈 문자열이면 프론트에서 기본 프로필 이미지로 fallback 처리하도록 이미 설계되어 있었다. 따라서 None이 아닌 ""으로 충분했다.
검증
- reject API 호출 후 DB 조회:
photo_url=""✅ - 파일시스템에서 해당 파일 사라짐 ✅
flutter analyze: error 0, 새 경고 없음
결제 가격 불일치 — ₩1,900 vs ₩900이 UI 곳곳에 혼재
증상
프리미엄 구독 화면에서 CTA 버튼에는 ₩1,900, 상단 배너에는 ₩900, 다이얼로그에는 ₩1,500이 표시되는 등 가격이 통일되지 않았다. 수익 직결 허점인 데다 사용자 신뢰를 직접 깨는 P0 이슈였다.
단서 + 첫 가설
처음엔 단순 하드코딩 문자열 오타라 생각했다. grep -r "₩" lib/로 전체 소스를 훑었다. 7개 파일에서 12개소가 서로 다른 가격을 하드코딩하고 있었다. 가설: 모든 하드코딩을 하나의 상수로 통일하면 해결된다.
원인 추적
그런데 이 중 3개는 백엔드에서 내려오는 product.price 값을 표시하는 위젯이었다. 백엔드 iap.py의 상품 정의에도 ₩1,900으로 박혀 있었다. 즉, 하드코딩 수정만으로는 부족하고, 백엔드 상품 가격도 검증해야 했다.
| 위치 | 표시 가격 | 출처 |
|---|---|---|
premium_compat_screen.dart:659 CTA 버튼 |
₩1,900 | 하드코딩 |
chat_room_screen.dart:349 배너 |
₩900 | 하드코딩 |
| 다이얼로그 (별도 위젯) | ₩1,500 | 하드코딩 |
백엔드 IAP_PRODUCT_PRICE |
₩1,900 | 환경변수 |
실제 의도된 가격은 ₩1,900이었다. 백엔드 가격과 일치하는 쪽으로 통일해야 했다.
해결
- 백엔드에서
IAP_PRODUCT_PRICE = 1900고정 확인 (달러 변환 로직 없음) - 프론트 하드코딩 12개소를 모두
AppPrices.premiumCompat상수로 대체lib/constants/app_prices.dart신규 생성,static const premiumCompat = "₩1,900";
- 백엔드에서 내려오는 가격은 신뢰할 수 없어서(운영 환경에서는 Apple/Google 가격 사용),
product.price표시는 유지하되 DB 시드 데이터와 일치하는지 추가 검증
검증
- 모든 화면에서
₩1,900일관 표시 확인 (수동 스모크 + 스크린샷 7장 비교) - 백엔드 스토어 상품 가격과 불일치 시 경고 로그 추가
오늘의 한줄
파일을 지우는 것보다 DB 레코드를 정리하는 게 더 늦어지는 경우가 많다 — 컬럼 제약은 항상 먼저 확인할 것.