테넌트 경계가 이렇게까지 구멍 나 있었나
목차
백엔드부터 건드렸다. 아침에 IDE를 열었더니 보안 이슈 티켓이 쌓여 있었는데, 처음엔 한두 개만 정리하고 모바일로 넘어가려 했다. 그 계획은 점심 전에 완전히 틀어졌다.
시작점은 buildDataFilterParam이었다. 멀티테넌트 구조에서 쿼리 파라미터를 조립할 때 테넌트 스코프를 강제 주입하는 유틸 메서드인데, 몇 가지 진입 경로에서 이걸 거치지 않고 직접 쿼리 매퍼로 떨어지는 흐름이 있었다. 그 자체로는 "데이터 조회 경로 누락"처럼 들리지만, 실상은 다른 테넌트 데이터가 응답에 섞여 나올 수 있다는 얘기다. 가장 조용하고 가장 위험한 종류. 뿌리를 막기 전에 뻗어나간 가지들을 먼저 다 확인해야 했고, 그게 하루를 삼켰다.
크로스테넌트라고 이름 붙일 수 있는 것들이 너무 많았다
suspend 처리, 팝업 관리, 앱 기기 등록, 게시판 게시글 조회. 이 네 곳에서 각각 다른 테넌트 리소스를 ID만 알면 접근할 수 있었다. IDOR 네 건이 한 커밋에 묶인 것이다. 하나씩 열어볼 때마다 "아, 여기도"라는 말이 나왔다. 서비스 레이어가 아닌 컨트롤러 레이어에서 ownership 체크를 안 하고 그냥 ID 받아서 조회하는 구조로 오래전에 짜여진 것들. 리팩터는 오늘 범위가 아니니까, 각 진입점에서 테넌트 소속 검증을 심었다.
첨부파일은 더 직접적이었다. 다운로드 엔드포인트에서 인증은 하는데 인가는 안 했다. 로그인만 되어 있으면 다른 테넌트 파일 URL을 직접 치면 받을 수 있는 상태. 매퍼에 테넌트 조건을 추가하고 컨트롤러에서 소유권을 한 번 더 걸었다. 이건 안 고쳤으면 실제로 사고 날 뻔한 것.
비회원 주문 IDOR도 이쪽 흐름에서 나왔다. 비회원은 계정이 없으니 ownership을 세션 마커 방식으로 증명하게 했다. 주문 완료 시점에 세션에 소유권 토큰을 심어두고, 조회 요청이 들어오면 그걸 게이트로 쓰는 구조. 처음 설계할 때 들어갔어야 하는 건데 언제 빠진 건지 모르겠다.
매퍼 id 오타 세 건도 이 과정에서 발견됐다. 존재하지 않는 id를 참조하고 있어서 실행 시 예외가 났는데, 에러가 단순 NPE처럼 보여서 원인 추적이 안 됐을 법한 것들. 오타 수정과 함께 isSuspend 크로스테넌트 후속 처리도 같이 묶었다. isSuspend 체크가 현재 테넌트 범위 안에서만 동작해야 하는데 전역으로 걸리는 케이스가 있어서, 조회 조건에 tenantId를 명시적으로 바인딩하는 방식으로 수정.
매출전표 링크가 시퀀스였다는 게 문제였다
이건 발견했을 때 잠깐 멍했다. 매출전표 공유 링크가 Base64(receiptNo) 형태였는데, receiptNo가 시퀀셜한 숫자라면 1씩 올리면서 다른 테넌트 전표를 전부 열람할 수 있다. 열거 공격이라고 하기도 거창한, 그냥 숫자 하나 올리는 거다.
해법은 UUID를 발급해서 Redis에 토큰으로 올려두는 것. 링크 생성 시점에 UUID를 키로, receiptNo를 값으로 Redis에 박고 TTL을 설정했다. 공유 링크가 만료되면 무효화되는 건 덤이고, 열거 가능성 자체가 사라진다. 기존에 이미 유통된 Base64 링크들이 있을 텐데 - 서버에서 Base64 방식을 더 이상 해석 안 하면 그냥 404다. 감수해야 하는 부분.
관리자 링크 배선 교정은 별도로 진행했다. FAQ 폼, 상품공급 상세, 공급 모듈 페이아웃 카드 - JSP 파일에서 href가 존재하지 않는 URL 패턴을 가리키고 있었던 게 여러 곳이었다. 예치금 이력에도 테넌트 인가 가드가 빠져 있어서 같이 넣었다.
관리자 권한 우회랑 파트너 쿠폰 탈취 IDOR는 성격이 조금 달랐다. 관리자 진입 조건 검증이 우회되는 경로가 있었고, 파트너가 자기 소속 쿠폰이 아닌 쿠폰을 API로 직접 취득하는 게 가능했다. KYC 링크 오타, 공급 주문 쿼리 오타도 같은 커밋에 묶었다. 오타 수정은 단순한데 KYC 링크가 끊겨 있으면 실제 파트너 온보딩이 멈추는 거라 중요했다.
엑셀 수식 인젝션 방어는 유틸 클래스에서 셀 값 앞에 =, +, -, @가 오면 앞에 작은따옴표를 붙이는 표준 방어 패턴. 매직바이트 검증은 파일 업로드 시 확장자와 실제 파일 헤더 시그니처가 불일치하는 경우를 걸러내게 했다. 메뉴 URL 경계 매칭 버그는 /admin/user가 /admin/user2를 prefix로 인가해버리던 것, 경계 문자 체크를 추가하는 식으로.
돈이 두 번 나가거나 없는 돈이 생기는 것들
오늘 제일 조심스러웠던 작업.
이중환불은 경로가 여러 갈래였다. 관리자 경로로 환불을 실행했을 때 order_status가 REFUNDED로 전이되지 않아서, 파트너 경로로 다시 환불 요청이 들어오면 "이미 처리됨"을 감지 못하고 재실행됐다. 이걸 막으려면 환불 시작 시점에 상태를 원자적으로 선점해야 한다. UPDATE ... SET status = 'REFUNDING' WHERE status = 'PAID' 방식으로 CAS를 걸었고, 업데이트 결과가 0이면 이미 다른 경로가 처리 중이거나 완료됐다는 뜻으로 멱등하게 리턴하게 했다. codex REJECT 대응이라고 커밋에 썼는데 - 처음 올렸을 때 코드 리뷰에서 원자성 부족 지적을 받았던 것. 이번에 파트너 환불 경로까지 같이 묶어서 재제출했다.
zlgoon 이중차감은 포인트/크레딧을 선차감하고 외부 결제가 실패하면 롤백해야 하는 구조에서, 응답 타임아웃 시 롤백과 성공 처리가 둘 다 실행될 수 있는 케이스가 있었다. 멱등 키를 심어서 같은 트랜잭션 ID로 두 번 처리되지 않게 걸었다. 이중환불 가드도 같이 4종 묶음으로.
충전 환불은 다른 종류의 버그였다. 수수료를 제하고 환불하는 구조인데, lot 기준 계산 환불금과 지갑 차감 금액 사이에 불일치가 생기는 케이스가 있었다. 그 차액이 허공에서 생겨나는 것. "수수료분 돈 창조"라고 표현했는데, 재현시켜보니 원래 충전 금액보다 환불금 합산이 커지는 케이스가 실제로 나왔다. lot 잔액과 지갑 잔액을 하나의 트랜잭션으로 묶어서 조정하게 했다.
외부 쿠폰 발급 롤백도 정리했다. vendor 쪽 발급은 성공했는데 우리 DB 저장이 실패한 경우, 롤백 시 vendor API를 호출해서 해당 쿠폰을 취소해야 하는데 취소 대상을 잘못 잡는 케이스가 있었다. 이미 유효한 다른 쿠폰을 취소하거나, 아니면 orphan으로 아무것도 안 하거나. 발급 응답에서 받은 couponId를 컨텍스트로 들고 다니다가 롤백 시 정확히 그걸 넘기는 방식으로.
배치는 지뢰밭이었다
건드리기 싫었는데 어쩔 수 없었다.
출금 배치에서 이중송금 가능성. 배치가 실패 후 재시도할 때, 이미 외부 PG에는 송금이 나갔는데 DB 상태는 실패로 남아 있으면 재시도가 또 나간다. 분산락이 있긴 했는데 락 TTL이 실제 처리 시간보다 짧은 구간이 있어서 락이 풀린 뒤 다른 인스턴스가 같은 건을 집어들 수 있었다. TTL 늘리고, 처리 완료 직후 명시적으로 락을 해제하도록 바꿨다.
재고 배치는 부분 취소 시 과복원 케이스가 있었다. 부분 취소인데 전체 주문 수량이 재고로 돌아오는 코드 경로가 있던 것. 타임존 버그도 하나 - 배치가 UTC 기준으로 돌고 있는데 일자 계산이 서버 로컬 시간 기준으로 짜여 있어서, 자정 전후로 일부 건이 누락되거나 중복 처리될 수 있었다. 이건 오래된 버그인데 지금까지 안 터진 게 신기할 정도.
사일런트스킵은 배치 아이템 처리 중 예외가 나도 로그만 찍고 넘어가는 구조라 실제로 몇 건이 누락됐는지 파악이 안 됐다. 실패 건을 별도 테이블에 적재하고 알람을 발생시키는 방향으로 바꿨다. 이게 없으면 배치는 항상 성공한 척 보인다.
API Key 관리 화면은 이 와중에 완성했다
보안 수정 사이사이에 끼워 넣었다. tb_api_key DDL, 메뉴 SQL에 BASIC 플랜 판별값(paid_yn=Y) 정합, 운영 섹션 레벨 설정. 관리화면은 처음부터 테넌트 IDOR 가드를 넣어서 만들었다. 오늘 다른 화면들 고치면서 생긴 한 가지 교훈이 있다면 새로 만드는 건 처음부터 제대로 만든다는 것.
명세서 이메일 재발송은 원래 "지원안함" 스텁으로 박혀 있었던 거다. 파트너 포털 supplier-settlement-statement 화면에서 재발송 버튼이 있는데 누르면 아무것도 안 됐다. 오늘 백엔드 엔드포인트 구현하고 JSP에서 버튼 연결까지 끝냈다.
오후엔 모바일로 완전히 넘어갔다.
iOS 연령등급 신규 문항이 함대 전체를 걸어잠갔다
Apple이 App Store Connect에 연령등급 관련 신규 문항을 추가했다. 기존에 제출 완료된 앱들도 해당 항목을 업데이트해야 심사 통과나 버전 업데이트 제출이 가능한 구조. 문제는 관리 중인 앱이 하나가 아니라는 것이다. 버전 코드만 봐도 여러 앱이다 - 1.0.2, 1.0.3, 1.0.4, 1.1.4, 1.1.5가 각각 다른 프로덕트.
하나씩 하면 될 줄 알았는데, 각 앱마다 iOS 빌드를 새로 올려야 하는 상황이 됐다. 연령등급 항목을 바꾸는 것뿐 아니라 동시에 서명도 현행화해야 했다. Apple Distribution 인증서와 프로비저닝 프로파일이 바뀐 게 있었고, 그걸 반영하지 않으면 업로드 단계에서 자동으로 거부된다.
서명 함정이 세 가지 정도 있었다. 첫째, Xcode 자동 서명이 만료된 프로파일을 캐시해서 쓰는 것. 둘째, ExportOptions.plist에 명시된 팀 ID나 method가 현재 계정과 맞지 않는 것. 셋째, bundle identifier에 연결된 provisioning profile이 갱신됐는데 Xcode가 아무 에러 없이 빌드만 해주는 것 - 아카이브는 성공하는데 xcodebuild -exportArchive 단계에서 터진다. 이걸 앱마다 하나씩 확인하고 고쳐야 했다. 결국 docs/mobile-app-launch.md에 "연령등급 신규문항·서명 함정 3종·submit_batch" 플레이북으로 정리했다. 다음에 또 이런 상황이 오면 그냥 이걸 읽으면 된다.
submit_batch.py를 짰다. 여러 앱 디렉토리를 순회하면서 각각 버전 범프, 아카이브, 업로드까지 처리해주는 스크립트. store_swap_build.py는 심사 중 빌드를 교체할 때 쓰는 것. 이게 없었으면 오늘 저녁까지 수동으로 하고 있었을 것이다. 반복이 많아지면 자동화를 먼저 고민해야 한다는 걸 또 한 번 느꼈다.
Play 쪽도 묶었다. alpha 트랙에 올라간 버전코드가 iOS 제출 버전과 맞지 않아서 각 앱마다 범프하고 재업로드했다. minify OFF를 명시한 건 난독화된 스택트레이스로 인한 crash report 분석 이슈 때문이다. Play 번들-릴리즈 방치 함정 - draft 상태로 두면 릴리즈가 자동으로 되지 않는다는 것도 문서에 박아뒀다. 한 번 겪고 나서야 아는 것.
7/23 게이트는 오늘 폐기됐다. 원래 7월 23일 이후 프로덕션 제출을 계획했던 건데, 오늘 지시로 지금 바로 제출 가능 상태로 전환했다. 문서 업데이트하고 Android 권한 문구 심사 무관 판정도 같이 정리했다.
Zoothe가 실제로 나가는 앱이 됐다
Zoothe는 동물 진정 사운드 앱이다. 기존에 운영 중인 noiseproof 기술 베이스로 만든 것.
오늘 클래식 CC0 트랙 4개를 추가했다. 골드베르크 변주 13·21·25번과 평균율 1권 프렐류드 1번. 기존 동물 카테고리 사운드가 있었는데 클래식을 독립 카테고리로 분리했다. 6트랙, l10n 8개 언어. 애셋 구조는 원본 mp3를 raw_classical/에 두고 m4a로 변환해서 배포용으로 따로 두는 형태인데, raw 원본을 커밋에 포함시킬지 잠깐 고민했다가 CREDITS.md랑 같이 두는 걸로 결론.
AdMob 실 ID 붙이고 vc4에서 vc5로 범프했다. frame_screenshots.py - 마케팅 프레임이 씌워진 스크린샷을 자동으로 생성해주는 파이프라인을 만들었고 양 스토어에 교체 완료했다. AdMob iOS 스토어 연결은 어제 날짜로 라이브가 됐다.
fix(l10n) - 알림 채널 이름이랑 스트림 에러 메시지가 하드코딩 한글로 박혀 있었다. 영어권 사용자한테 알림이 한국어로 뜨는 거다. 8개국어 키로 뺐다. monitor_service.dart, notifier.dart, event_db.dart에 산재해 있던 것들.
fix(harness) - 스크린샷 하네스에서 SHOT_LOCALE dart-define을 Money 포맷터 초기화와 l10n 초기화가 못 받아서 스크린샷이 기본 언어로만 찍혔던 것. i18n.dart랑 theme.dart에서 define 값을 읽어서 초기화에 반영했다. 이 버그 때문에 스크린샷 파이프라인 돌렸더니 전부 영어로만 나와서 잠깐 당황했다.
다크 설정과 페이월에서 CalmBackground 위에 흰 텍스트가 밝은 배경에서 안 보이는 명암비 이슈도 잡았다. 파트너 포털 대시보드랑 시스템 매출 요약 화면에서도 같은 문제가 있었다. 스냅샷 데이트피커까지 세 파일 같이 정리.
psy 쪽 자동생성 테스트는 크론으로 돌아간 것이다. ghost-phone-type, observation-style 두 개가 오늘치 커버 이미지와 JSON 콘텐츠로 올라왔다.
오늘 커밋을 세보면 40개가 넘는다. 백엔드는 보안 감사 수준으로 훑었다. 크로스테넌트 뿌리 차단부터 시작해서 IDOR 여러 건, 금전 무결성, 배치 방어까지. 모바일은 앱 함대 전체를 연령등급 이슈로 동시에 건드렸고, Zoothe는 스토어에 올라갈 상태가 됐다.
찝찝한 건 하나다. "오늘 발견한 게 전부일까"라는 것. buildDataFilterParam 뿌리는 막았지만 그 위에 쌓인 코드 중에 직접 쿼리를 조합하는 경로가 없으리란 법이 없다. 배치도 오늘 고친 게 주요 케이스들이지 전수 검증은 아니다. 다음 주 안에 매퍼 파일 전체를 한 번 더 훑는 작업이 필요하다.
댓글 0
첫 댓글 달아줘.