플러그인 없이 1회차 · Claude Code · Opus 5.5 · 생각 깊이 중간 · 클로드가 쓴 review.md 원문 · 목록
shop.py 코드 리뷰
결론: 이대로 머지하면 안 됩니다.
실제 결제와 재고를 다루는 코드인데 보안 취약점 4건과 돈이나 재고가 틀어지는 로직 버그 여러 건이 있습니다. 아래의 🔴 Critical 항목은 모두 고친 다음 다시 리뷰하는 것을 권합니다.
| 심각도 | 건수 | 요약 |
|---|---|---|
| 🔴 Critical | 6 | 시크릿 하드코딩, SQL 인젝션, 남의 주문 조회(IDOR), 경로 탈출(Path Traversal), 음수 수량으로 돈 생성, 재고 동시성 |
| 🟠 Major | 4 | 페이지네이션 오류, 쿠폰 예외 삼킴, 잔액 검증 없음, 상품 없음 크래시 |
| 🟡 Minor | 4 | 커넥션 누수, N+1 쿼리, 타이밍 공격, 응답 필드 누락 |
🔴 Critical
1. 운영 관리자 토큰이 소스에 하드코딩되어 있음 (shop.py:7)
ADMIN_TOKEN = "sk_live_9f8e7d6c5b4a3210shopadmin"
sk_live_접두사로 보아 운영용 키입니다. 저장소에 올라가는 순간 코드를 볼 수 있는 모든 사람이 관리자 권한을 갖게 됩니다.- 조치: 이 토큰은 이미 노출된 것으로 보고 즉시 폐기하고 재발급해야 합니다. 새 토큰은 환경변수나 시크릿 매니저에서 읽어 오세요.
python ADMIN_TOKEN = os.environ["SHOP_ADMIN_TOKEN"] - 이미 git 히스토리에 커밋됐다면 코드에서 지우는 것만으로는 부족합니다. 반드시 재발급해야 합니다.
2. SQL 인젝션 (shop.py:19)
sql = f"SELECT ... WHERE name LIKE '%{keyword}%'"
- 검색어로
' UNION SELECT id, email, password FROM users --를 넣으면 회원 테이블이 그대로 빠져나갑니다. 검색창은 누구나 쓸 수 있는 입력란이라 가장 먼저 공격받는 곳입니다. - 조치: 파라미터 바인딩을 쓰세요.
python db.execute("SELECT id, name, price FROM products WHERE name LIKE ?", (f"%{keyword}%",))
3. 남의 주문을 조회할 수 있음, IDOR (shop.py:40-43)
def get_order(order_id, current_user_id):
row = db.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone()
current_user_id를 받기만 하고 어디에도 쓰지 않습니다. 그래서 주문 ID를 1, 2, 3… 순서로 바꿔 가며 남의 주문(주소, 결제 정보 등)을 볼 수 있습니다.- 조치:
WHERE id = ? AND user_id = ?조건을 쓰세요.SELECT *도 필요한 컬럼만 고르도록 바꾸는 것이 좋습니다.
4. 경로 탈출로 서버의 임의 파일을 읽을 수 있음, Path Traversal (shop.py:77-80)
path = os.path.join(INVOICE_DIR, filename)
filename="../shop.db"를 넣으면 DB 파일 전체를,"/etc/passwd"처럼 절대경로를 넣으면join이 앞부분을 버리기 때문에 서버의 아무 파일이나 내려받을 수 있습니다.- 소유자 확인도 없어서, 파일명만 알면 남의 영수증도 받을 수 있습니다.
- 조치: 파일명을 직접 받지 말고 주문 ID를 받으세요. 그 주문의 소유자를 확인한 다음 서버가 파일명을 만들어야 합니다. 꼭 파일명을 받아야 한다면 아래처럼 막으세요.
python base = os.path.realpath(INVOICE_DIR) path = os.path.realpath(os.path.join(base, filename)) if os.path.commonpath([base, path]) != base: raise PermissionError
5. 음수 수량으로 주문하면 돈과 재고가 생겨남 (shop.py:46-66)
qty값을 검증하지 않습니다.qty=-10으로 주문하면 다음 일이 일어납니다.stock < -10이 거짓이라 재고 검사를 통과합니다.stock - (-10)이 계산되어 재고가 10개 늘어납니다.balance - (price × -10)이 계산되어 잔액이 늘어납니다. 즉 무한으로 돈을 찍어낼 수 있습니다.- 조치: 함수 맨 앞에
if not isinstance(qty, int) or qty <= 0: raise ValueError검사를 넣으세요.
6. 재고 확인과 차감 사이의 경쟁 상태로 초과 판매가 일어남 (shop.py:48-55)
- SELECT로 재고를 확인한 뒤 UPDATE로 차감하는 구조라서, 동시에 들어온 두 요청이 모두 재고 검사를 통과하면 재고가 음수가 됩니다.
- 조치: 조건부 UPDATE로 재고 확인과 차감을 한 번에 처리하세요.
python cur = db.execute( "UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?", (qty, product_id, qty), ) if cur.rowcount == 0: raise ValueError("재고 부족")주문 처리 전체는with db:블록이나BEGIN IMMEDIATE로 하나의 트랜잭션으로 묶고, 예외가 나면 롤백되도록 하세요.
🟠 Major
7. 페이지네이션이 첫 페이지를 건너뜀 (shop.py:25)
offset = page * PAGE_SIZE이고 기본값이page=1이라서 첫 호출의 offset이 20이 됩니다. 결과적으로 처음 20건의 주문은 영영 보이지 않습니다.ORDER BY도 없어서 페이지마다 주문 순서가 달라질 수 있고, 같은 주문이 중복되거나 빠질 수 있습니다.- 조치:
offset = (page - 1) * PAGE_SIZE로 바꾸고ORDER BY created_at DESC, id DESC를 추가하세요.page < 1입력도 막아야 합니다.
8. 쿠폰 처리에서 예외를 삼키고 None을 반환함 (shop.py:69-74)
- 쿠폰 형식이 잘못되면
except Exception: pass를 거쳐None이 반환됩니다. 호출하는 쪽에서는 결제 금액이None이 되어 엉뚱한 곳에서 터지거나, 0원 결제로 이어질 수 있습니다. percent범위도 검증하지 않습니다. 150을 넣으면 음수 결제 금액이 나오고, 음수를 넣으면 금액이 오히려 늘어납니다.- 조치: 예외를 삼키지 말고 명시적으로 검증하세요(
0 < percent <= 100). 금액은Decimal이나 정수 원 단위로 다루세요. float를 쓰면 반올림 오차가 생깁니다.
9. 잔액 검증이 없음 (shop.py:61-64)
- 잔액이 부족해도 주문이 그대로 진행되고 잔액이 음수가 됩니다.
- 조치:
UPDATE users SET balance = balance - ? WHERE id = ? AND balance >= ?로 바꾸고rowcount를 확인하세요. 실패하면 트랜잭션 전체를 롤백해야 합니다.
10. 없는 상품으로 주문하면 500 에러가 남 (shop.py:51)
fetchone()이None을 반환하면stock["stock"]에서TypeError가 납니다. 사용자에게는 서버 오류로 보이고 원인도 알 수 없습니다.- 조치:
if stock is None: raise ValueError("존재하지 않는 상품")을 추가하세요. 변수명도stock보다product가 더 정확합니다.
🟡 Minor
- DB 커넥션 누수 (
shop.py:11-14, 모든 함수): 함수를 호출할 때마다 커넥션을 열고 닫지 않습니다. 트래픽이 몰리면 파일 핸들이 고갈되거나database is locked오류가 납니다. 컨텍스트 매니저를 쓰거나 요청 단위로 커넥션을 관리하세요. - N+1 쿼리 (
shop.py:31-36): 주문 20건을 조회하면 쿼리가 21번 나갑니다.WHERE order_id IN (...)이나 JOIN으로 한 번에 가져오세요. - 토큰 비교 시 타이밍 공격 가능 (
shop.py:84):==대신hmac.compare_digest(token, ADMIN_TOKEN)을 쓰세요. created_at을 조회만 하고 버림 (shop.py:27, 36): 쿼리로 가져오지만 응답에는 넣지 않습니다. 의도한 것인지 확인해 보세요.
그 밖에 확인이 필요한 점
- 테스트가 없습니다. 최소한 다음 경우는 단위 테스트로 막아 두는 것을 권합니다: 음수/0 수량, 재고 부족, 잔액 부족, 다른 사용자의 주문 조회,
../파일명, 페이지 1의 결과. place_order가 주문 금액을 잔액에서 바로 차감합니다. 실제 PG 결제와는 어떻게 연동하는지, 쿠폰은 어느 단계에서 적용되는지 설계를 확인해야 합니다. 지금은apply_coupon을 호출하는 곳이 없습니다.- 운영 환경에서 SQLite를 쓰면 동시 쓰기 성능에 한계가 있습니다. 트래픽 규모에 따라 PostgreSQL 같은 DB를 검토해 보세요.
머지 전 체크리스트
- [ ]
sk_live_토큰 폐기·재발급, 환경변수로 이동 - [ ]
search_products에 파라미터 바인딩 적용 - [ ]
get_order에 소유자 조건 추가 - [ ]
download_invoice에 경로 검증과 소유자 확인 추가 - [ ]
place_order: 수량·상품·잔액 검증, 원자적 UPDATE, 트랜잭션과 롤백 - [ ] 페이지네이션 offset 수정, ORDER BY 추가
- [ ]
apply_coupon: 예외 삼킴 제거, 범위 검증, Decimal 사용 - [ ] 위 시나리오에 대한 테스트 추가