플러그인 없이 2회차 · Claude Code · Opus 5.5 · 생각 깊이 중간 · 클로드가 쓴 review.md 원문 · 목록

shop.py 코드 리뷰

결론: ❌ 이대로 머지 불가

실제 돈과 개인정보가 오가는 서비스에 올리기에는 보안 취약점 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"

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

재현: - 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

재현: 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 / 개선 제안


배포 전 체크리스트

참고: 이 모듈을 호출하는 웹 계층(라우팅, 로그인 세션, 결제 연동)은 받지 못해 검토하지 못했습니다. 특히 user_id/current_user_id가 클라이언트 요청값이 아니라 서버 세션에서 오는지 반드시 확인하세요. 그렇지 않으면 위 수정을 해도 다른 사람 명의로 주문할 수 있습니다.