플러그인 없이 2회차 · Claude Code · Opus 5.5 · 생각 깊이 중간 · 클로드가 쓴 review.md 원문 · 목록
shop.py 코드 리뷰
- 검토 대상:
shop.py(84줄, sqlite3 기반 주문 처리 모듈) - 검토일: 2026-10-16
- 검증 방법: 코드 정독 + 임시 DB(products/orders/order_items/users)에 대해 재현 스크립트 실행
결론: ❌ 이대로 머지 불가
실제 돈과 개인정보가 오가는 서비스에 올리기에는 보안 취약점 4건과 금전 손실로 직결되는 로직 버그가 있습니다. 특히 아래 1~5번은 서비스 첫날 바로 악용될 수 있는 수준이라 반드시 고친 뒤에 배포해야 합니다.
| # | 심각도 | 위치 | 문제 | 재현 |
|---|---|---|---|---|
| 1 | 🔴 Critical | shop.py:7 |
라이브 관리자 토큰 하드코딩 | 코드 확인 |
| 2 | 🔴 Critical | shop.py:19 |
SQL 인젝션 | ✅ |
| 3 | 🔴 Critical | shop.py:46-66 |
수량·잔액 검증 없음 → 돈/재고 조작 | ✅ |
| 4 | 🔴 Critical | shop.py:77-80 |
경로 조작(Path Traversal)으로 서버 파일 읽기 | ✅ |
| 5 | 🔴 Critical | shop.py:40-43 |
남의 주문 조회 가능 (IDOR) | ✅ |
| 6 | 🟠 High | shop.py:48-55 |
재고 확인과 차감 사이 경쟁 상태 → 초과 판매 | 코드 분석 |
| 7 | 🟠 High | shop.py:69-74 |
쿠폰 오류 시 None 반환, 할인율 범위 검증 없음 |
✅ |
| 8 | 🟡 Medium | shop.py:25 |
페이지네이션 off-by-one, 첫 페이지 누락 | ✅ |
| 9 | 🟡 Medium | shop.py:51 |
없는 상품 주문 시 TypeError 크래시 |
✅ |
| 10 | 🟡 Medium | shop.py:11-14 외 |
DB 연결을 닫지 않음 | 코드 분석 |
| 11 | 🟢 Low | 여러 곳 | ORDER BY 없음, N+1 쿼리, 상수 시간 비교 아님 등 | 코드 분석 |
🔴 Critical
1. 라이브 관리자 토큰이 소스코드에 하드코딩됨 — shop.py:7
ADMIN_TOKEN = "sk_live_9f8e7d6c5b4a3210shopadmin"
- 코드가 저장소에 올라가는 순간 저장소를 볼 수 있는 모든 사람이 관리자 권한을 갖게 됩니다.
sk_live_접두사로 보아 실제 운영 키로 보입니다. - 이미 친구분과 공유된 상태이므로 이 키는 유출된 것으로 보고 반드시 폐기·재발급해야 합니다. 코드에서 지우는 것만으로는 부족합니다. git 히스토리에도 남습니다.
- 수정: ```python import hmac ADMIN_TOKEN = os.environ["SHOP_ADMIN_TOKEN"] # 또는 시크릿 매니저
def is_admin(token): return hmac.compare_digest(token or "", ADMIN_TOKEN) # 타이밍 공격 방지 ```
2. SQL 인젝션 — shop.py:19
sql = f"SELECT id, name, price FROM products WHERE name LIKE '%{keyword}%'"
검색어가 SQL에 그대로 들어갑니다. 상품 검색창 하나로 DB 전체를 읽을 수 있습니다.
재현: search_products("zzz' UNION SELECT id, balance, 0 FROM users --")
→ [{'id': 1, 'name': 0, ...}, {'id': 2, 'name': 0, ...}] 처럼 users 테이블(잔액)이 그대로 노출됨. 같은 방법으로 비밀번호 해시 등 모든 테이블을 읽을 수 있습니다.
수정 (다른 함수들처럼 파라미터 바인딩 사용):
sql = "SELECT id, name, price FROM products WHERE name LIKE ? ESCAPE '\\'"
pattern = "%" + keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
db.execute(sql, (pattern,))
3. 주문 수량·잔액 검증 없음 → 돈과 재고를 마음대로 늘릴 수 있음 — shop.py:46-66
qty에 음수를 넣으면 재고 확인(stock < qty)을 통과하고, 재고는 늘어나며, 잔액은balance - (price × 음수)라서 오히려 증가합니다.- 잔액 부족 여부를 확인하지 않아 잔액 0원인 사용자도 주문 가능(잔액이 마이너스가 됨).
재현:
- place_order(1, 1, -100) → 재고 5 → 104, 사용자1 잔액 0 → 100,000
- 잔액 0인 사용자2가 1,000원 상품 주문 → 성공, 잔액 -1,000
수정:
if not isinstance(qty, int) or qty <= 0:
raise ValueError("잘못된 수량")
# 잔액도 원자적으로 확인하며 차감
cur = db.execute(
"UPDATE users SET balance = balance - ? WHERE id = ? AND balance >= ?",
(total, user_id, total),
)
if cur.rowcount != 1:
raise ValueError("잔액 부족")
4. 경로 조작(Path Traversal) — shop.py:77-80
path = os.path.join(INVOICE_DIR, filename)
filename에 ../나 절대경로를 넣으면 invoices/ 밖의 아무 파일이나 읽힙니다. (os.path.join은 두 번째 인자가 절대경로면 앞부분을 버립니다.)
재현:
- download_invoice("../shop.py") → 소스코드(=관리자 토큰 포함) 반환
- download_invoice("/etc/hosts") → 시스템 파일 반환
- ../shop.db를 넣으면 DB 파일 통째로 다운로드 가능
또 사용자 확인이 없어서 남의 영수증 파일명만 알면 그대로 받을 수 있습니다.
수정:
def download_invoice(order_id, current_user_id):
order = get_order(order_id, current_user_id) # 소유권 확인
if order is None:
raise PermissionError
base = os.path.realpath(INVOICE_DIR)
path = os.path.realpath(os.path.join(base, f"{int(order_id)}.pdf"))
if os.path.commonpath([base, path]) != base:
raise PermissionError
...
사용자 입력 파일명을 받지 말고 주문 ID에서 서버가 파일명을 만드는 방식을 권장합니다.
5. 남의 주문을 조회할 수 있음 (IDOR) — shop.py:40-43
current_user_id 인자를 받기만 하고 전혀 사용하지 않습니다. 주문 번호만 바꿔가며 호출하면 모든 고객의 주문을 볼 수 있고, SELECT * 때문에 주문 테이블에 주소·연락처 등이 있다면 그것까지 전부 노출됩니다.
재현: 사용자2의 주문을 사용자1로 조회 → {'id': 1, 'user_id': 2, ...} 그대로 반환.
수정:
row = db.execute(
"SELECT id, user_id, created_at FROM orders WHERE id = ? AND user_id = ?",
(order_id, current_user_id),
).fetchone()
🟠 High
6. 재고 확인-차감 사이 경쟁 상태(Race Condition) → 초과 판매 — shop.py:48-55
재고를 SELECT로 읽은 뒤(이 시점엔 트랜잭션 밖) 따로 UPDATE합니다. 재고 1개 남은 상품에 두 요청이 동시에 오면 둘 다 확인을 통과하고 재고가 -1이 됩니다. 한정판/이벤트 상품에서 바로 터지는 유형입니다.
수정: 조건부 UPDATE로 확인과 차감을 한 번에 하고, 전체를 하나의 트랜잭션으로 묶습니다.
with db: # 예외 시 자동 rollback, 성공 시 commit
db.execute("BEGIN IMMEDIATE")
cur = db.execute(
"UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?",
(qty, product_id, qty),
)
if cur.rowcount != 1:
raise ValueError("재고 부족 또는 상품 없음")
... # 주문 생성, 잔액 차감(3번 참고)
7. 쿠폰 처리 — shop.py:69-74
except Exception: pass로 모든 오류를 삼키고None을 반환합니다. 호출부에서 결제 금액이None이 되어 다른 곳에서 엉뚱하게 터지거나, 0원 결제로 처리될 위험이 있습니다.- 할인율 범위 검증이 없어
percent: 150→ -5,000원 (가게가 돈을 내주는 셈), 음수 percent는 가격 인상. - 쿠폰 유효기간·사용 여부·사용자 제한 확인도 없습니다(호출부에서 하는지 확인 필요).
- 금액을 float로 계산합니다(
10000 * 0.15등). 원 단위 정수 + 명시적 반올림, 또는Decimal을 권장합니다.
재현: apply_coupon(10000, {}) → None, apply_coupon(10000, {"percent": 150}) → -5000.0
수정:
def apply_coupon(total, coupon):
percent = int(coupon["percent"]) # 잘못된 쿠폰이면 예외가 올라가게 둠
if not 0 <= percent <= 100:
raise ValueError("잘못된 쿠폰")
return total - (total * percent) // 100
🟡 Medium
8. 페이지네이션 off-by-one — shop.py:25
page 기본값이 1인데 offset = page * PAGE_SIZE 라서 기본 호출 시 첫 20건을 건너뜁니다. 주문이 20건 미만인 고객은 주문 목록이 항상 비어 보입니다.
재현: 주문 1건 있는 사용자의 list_orders(2) → []
수정: offset = (page - 1) * PAGE_SIZE (+ page >= 1 검증), 그리고 ORDER BY created_at DESC, id DESC 추가 (정렬이 없으면 페이지 간 중복/누락 가능).
9. 존재하지 않는 상품 주문 시 크래시 — shop.py:51
fetchone()이 None이면 stock["stock"]에서 TypeError: 'NoneType' object is not subscriptable. 사용자에게 500 에러가 납니다. 6번의 조건부 UPDATE(rowcount 확인)로 함께 해결됩니다.
10. DB 연결을 닫지 않음 — get_db() 사용하는 모든 함수
모든 함수가 연결을 열기만 하고 닫지 않습니다. 트래픽이 늘면 파일 핸들이 쌓이고, place_order에서 예외가 나면 쓰기 잠금이 GC 될 때까지 남아 다른 요청이 database is locked로 실패할 수 있습니다. contextlib.closing 이나 요청 단위 연결 관리로 바꾸세요.
🟢 Low / 개선 제안
- N+1 쿼리 (
shop.py:31-35): 주문마다 order_items를 따로 조회.WHERE order_id IN (...)한 번이나 JOIN으로. list_orders에서created_at을 조회해 놓고 결과에 넣지 않음 (shop.py:36).place_order가 한 번에 상품 하나만 주문 가능 — 장바구니 요구사항이 있다면 구조 재검토 필요.DB_PATH,INVOICE_DIR이 상대경로라 실행 위치에 따라 다른 DB/폴더를 씀. 환경변수/절대경로로.- sqlite는 동시 쓰기에 약합니다. 실제 서비스 트래픽이 예상되면 WAL 모드(
PRAGMA journal_mode=WAL) +busy_timeout설정, 또는 PostgreSQL 등 검토. - 테스트 코드가 전혀 없음. 최소한 위 재현 케이스(음수 수량, 재고 부족, 남의 주문 조회, 경로 조작, SQLi)는 회귀 테스트로 추가하길 권장합니다.
배포 전 체크리스트
- [ ] 관리자 토큰 즉시 폐기·재발급, 환경변수로 이동,
hmac.compare_digest사용 - [ ]
search_products파라미터 바인딩으로 변경 - [ ]
place_order: 수량 > 0 검증, 잔액 검증, 조건부 UPDATE + 단일 트랜잭션 - [ ]
download_invoice: 사용자 입력 파일명 제거, 소유권 확인, 경로 검증 - [ ]
get_order:user_id조건 추가,SELECT *제거 - [ ]
apply_coupon: 예외 삼키기 제거, 0~100 범위 검증, 정수/Decimal 금액 계산 - [ ]
list_orders: offset 수정 + ORDER BY - [ ] DB 연결 close 처리
- [ ] 위 항목 회귀 테스트 작성
참고: 이 모듈을 호출하는 웹 계층(라우팅, 로그인 세션, 결제 연동)은 받지 못해 검토하지 못했습니다. 특히 user_id/current_user_id가 클라이언트 요청값이 아니라 서버 세션에서 오는지 반드시 확인하세요. 그렇지 않으면 위 수정을 해도 다른 사람 명의로 주문할 수 있습니다.