diff --git a/lib/shop.cartvalidate.lib.php b/lib/shop.cartvalidate.lib.php new file mode 100644 index 000000000..a988b1c0e --- /dev/null +++ b/lib/shop.cartvalidate.lib.php @@ -0,0 +1,159 @@ + $error); + + $option_map = array(); + $has_selection = false; + foreach ($options as $option) { + if ((string) $option['it_id'] !== (string) $item['it_id']) + return array('error' => $error); + if ((string) $option['io_type'] === '0') + $has_selection = true; + $key = $option['io_type'].':'.$option['io_id']; + if (isset($option_map[$key])) + return array('error' => $error); + $option_map[$key] = $option; + } + + $validated = array(); + $quantities = array(); + $base_qty = 0; + $total = 0; + foreach ($rows as $row) { + if (!isset($row['io_id'], $row['io_type'], $row['ct_qty']) || + !is_string($row['io_id']) || + !(is_string($row['io_type']) || is_int($row['io_type'])) || + !in_array((string) $row['io_type'], array('0', '1'), true) || + !(is_string($row['ct_qty']) || is_int($row['ct_qty'])) || + !preg_match('/^[1-9][0-9]*$/D', (string) $row['ct_qty']) || + (float) $row['ct_qty'] > 2147483647) + return array('error' => $error); + + $type = (int) $row['io_type']; + if (!$validated && $type !== 0) + return array('error' => $error); + $id = $row['io_id']; + $key = $type.':'.$id; + if ($id === '') { + if ($type !== 0 || $has_selection) + return array('error' => $error); + $price = 0; + $stock = (int) $item['it_stock_qty']; + } else { + if (!isset($option_map[$key]) || empty($option_map[$key]['io_use'])) + return array('error' => $error); + $option = $option_map[$key]; + $type = (int) $option['io_type']; + $price = (int) $option['io_price']; + $stock = (int) $option['io_stock_qty']; + } + + $qty = (int) $row['ct_qty']; + $quantities[$key] = isset($quantities[$key]) ? $quantities[$key] + $qty : $qty; + if ($quantities[$key] > $stock) + return array('error' => '상품 또는 옵션의 재고수량이 부족합니다.'); + $unit_price = $type === 1 ? $price : (int) $item['it_price'] + $price; + if ($unit_price < 0) + return array('error' => $error); + if ($check_price && (!isset($row['ct_price'], $row['io_price']) || + (int) $row['ct_price'] !== (int) $item['it_price'] || (int) $row['io_price'] !== $price)) + return array('error' => '상품 또는 옵션 금액이 변경되었습니다. 장바구니를 다시 확인해 주십시오.'); + + if ($type === 0) + $base_qty += $qty; + $row['io_type'] = $type; + $row['io_price'] = $price; + $row['ct_price'] = (int) $item['it_price']; + $row['ct_qty'] = $qty; + $validated[] = $row; + $total += $unit_price * $qty; + } + + if (!$base_qty || (!empty($item['it_buy_min_qty']) && $base_qty < $item['it_buy_min_qty']) || + (!empty($item['it_buy_max_qty']) && $base_qty > $item['it_buy_max_qty'])) + return array('error' => '상품의 선택옵션과 최소/최대 구매수량을 확인해 주십시오.'); + + return array('error' => '', 'rows' => $validated, 'total' => $total); +} + +function shop_cart_option_data($it_id) +{ + global $g5; + $id = sql_escape_string($it_id); + $item = sql_fetch(" select * from {$g5['g5_shop_item_table']} where it_id = '$id' "); + $result = sql_query(" select * from {$g5['g5_shop_item_option_table']} where it_id = '$id' "); + $options = array(); + while ($row = sql_fetch_array($result)) + $options[] = $row; + return array($item, $options); +} + +// 모든 상품을 먼저 검증한다. 바로구매/옵션수정의 선삭제와 다중 상품의 부분 저장을 막는다. +function shop_validate_cart_request($post, $multi = false) +{ + $error = '장바구니 요청 정보가 올바르지 않습니다.'; + $products = array(); + if (empty($post['it_id']) || !is_array($post['it_id']) || + array_keys($post['it_id']) !== range(0, count($post['it_id']) - 1)) + return array('error' => $error); + foreach ($post['it_id'] as $i => $id) { + if ($multi && empty($post['chk_it_id'][$i])) + continue; + if (!is_string($id) || $id === '' || safe_replace_regex($id, 'it_id') !== $id || isset($products[$id])) + return array('error' => $error); + foreach (array('io_id', 'io_type', 'ct_qty') as $field) { + if (!isset($post[$field][$id]) || !is_array($post[$field][$id]) || !$post[$field][$id]) + return array('error' => $error); + } + $keys = range(0, count($post['io_id'][$id]) - 1); + foreach (array('io_id', 'io_type', 'ct_qty') as $field) { + if (array_keys($post[$field][$id]) !== $keys) + return array('error' => $error); + } + $rows = array(); + foreach ($keys as $k) { + $io_id = $post['io_id'][$id][$k]; + if (!is_string($io_id) || preg_replace(G5_OPTION_ID_FILTER, '', $io_id) !== $io_id || + (isset($post['io_value'][$id][$k]) && !is_string($post['io_value'][$id][$k]))) + return array('error' => $error); + $rows[] = array('io_id' => $io_id, 'io_type' => $post['io_type'][$id][$k], 'ct_qty' => $post['ct_qty'][$id][$k]); + } + list($item, $options) = shop_cart_option_data($id); + $validated = shop_validate_cart_rows($item, $options, $rows); + if ($validated['error'] !== '') + return $validated; + $validated['item'] = $item; + $products[$id] = $validated; + } + return array('error' => $products ? '' : $error, 'products' => $products); +} + +// 주문 준비/확정 시에는 선택된 행만 검증하여 보조옵션 단독 선택과 과거 조작 행도 차단한다. +function shop_validate_order_cart($cart_id) +{ + global $g5; + $id = sql_escape_string($cart_id); + $result = sql_query(" select * from {$g5['g5_shop_cart_table']} where od_id = '$id' and ct_select = '1' order by ct_id asc "); + $products = array(); + while ($row = sql_fetch_array($result)) { + if ($row['ct_status'] !== '쇼핑') + return '이미 처리되었거나 올바르지 않은 장바구니입니다.'; + $products[$row['it_id']][] = $row; + } + if (!$products) + return '주문하실 상품을 선택해 주십시오.'; + foreach ($products as $it_id => $rows) { + list($item, $options) = shop_cart_option_data($it_id); + $validated = shop_validate_cart_rows($item, $options, $rows, true); + if ($validated['error'] !== '') + return $validated['error']; + } + return ''; +} diff --git a/lib/shop.lib.php b/lib/shop.lib.php index a969c9d63..a48162752 100644 --- a/lib/shop.lib.php +++ b/lib/shop.lib.php @@ -1,4 +1,5 @@ '장바구니에 담을 상품을 선택하여 주십시오.'))); + $cart_validation = shop_validate_cart_request($_POST); + if ($cart_validation['error'] !== '') + die(json_encode(array('error' => $cart_validation['error']))); + $ct_count = 0; for($i=0; $i<$count; $i++) { $it_id = isset($_POST['it_id'][$i]) ? safe_replace_regex($_POST['it_id'][$i], 'it_id') : ''; $opt_count = (isset($_POST['io_id'][$it_id]) && is_array($_POST['io_id'][$it_id])) ? count($_POST['io_id'][$it_id]) : 0; // 상품정보 - $it = get_shop_item($it_id, false); + $it = $cart_validation['products'][$it_id]['item']; if(!$it['it_id']) die(json_encode(array('error' => '상품정보가 존재하지 않습니다.'))); - // 옵션정보를 얻어서 배열에 저장 - $opt_list = array(); - $sql = " select * from {$g5['g5_shop_item_option_table']} where it_id = '$it_id' order by io_no asc "; - $result = sql_query($sql); - $lst_count = 0; - for($k=0; $row=sql_fetch_array($result); $k++) { - $opt_list[$row['io_type']][$row['io_id']]['id'] = $row['io_id']; - $opt_list[$row['io_type']][$row['io_id']]['use'] = $row['io_use']; - $opt_list[$row['io_type']][$row['io_id']]['price'] = $row['io_price']; - $opt_list[$row['io_type']][$row['io_id']]['stock'] = $row['io_stock_qty']; - - // 선택옵션 개수 - if(!$row['io_type']) - $lst_count++; - } - - if($lst_count > 0 && !trim($_POST['io_id'][$it_id][$i]) && $_POST['io_type'][$it_id][$i] == 0) - die(json_encode(array('error' => '상품의 선택옵션을 선택해 주십시오.'))); - for($k=0; $k<$opt_count; $k++) { $post_ct_qty = isset($_POST['ct_qty'][$it_id][$k]) ? (int) $_POST['ct_qty'][$it_id][$k] : 0; if ($post_ct_qty < 1) @@ -155,25 +140,17 @@ switch ($action) { // 장바구니에 Insert $comma = ''; + $ct_count = 0; $sql = " INSERT INTO {$g5['g5_shop_cart_table']} ( od_id, mb_id, it_id, it_name, it_sc_type, it_sc_method, it_sc_price, it_sc_minimum, it_sc_qty, ct_status, ct_price, ct_point, ct_point_use, ct_stock_use, ct_option, ct_qty, ct_notax, io_id, io_type, io_price, ct_time, ct_ip, ct_send_cost, ct_direct, ct_select, ct_select_time ) VALUES "; for($k=0; $k<$opt_count; $k++) { - $io_id = isset($_POST['io_id'][$it_id][$k]) ? preg_replace(G5_OPTION_ID_FILTER, '', $_POST['io_id'][$it_id][$k]) : ''; - $io_type = isset($_POST['io_type'][$it_id][$k]) ? preg_replace('#[^01]#', '', $_POST['io_type'][$it_id][$k]) : ''; + $io_id = $cart_validation['products'][$it_id]['rows'][$k]['io_id']; + $io_type = $cart_validation['products'][$it_id]['rows'][$k]['io_type']; $io_value = isset($_POST['io_value'][$it_id][$k]) ? $_POST['io_value'][$it_id][$k] : ''; - // 선택옵션정보가 존재하는데 선택된 옵션이 없으면 건너뜀 - if($lst_count && $io_id == '') - continue; - - $opt_list_type_id_use = isset($opt_list[$io_type][$io_id]['use']) ? $opt_list[$io_type][$io_id]['use'] : ''; - // 구매할 수 없는 옵션은 건너뜀 - if($io_id && ! $opt_list_type_id_use) - continue; - - $io_price = isset($opt_list[$io_type][$io_id]['price']) ? $opt_list[$io_type][$io_id]['price'] : 0; + $io_price = $cart_validation['products'][$it_id]['rows'][$k]['io_price']; $ct_qty = isset($_POST['ct_qty'][$it_id][$k]) ? (int) $_POST['ct_qty'][$it_id][$k] : 0; // 구매가격이 음수인지 체크 @@ -186,14 +163,17 @@ switch ($action) { } // 동일옵션의 상품이 있으면 수량 더함 - $sql2 = " select ct_id, io_type, ct_qty + $sql2 = " select ct_id, io_type, ct_qty, ct_price, io_price from {$g5['g5_shop_cart_table']} where od_id = '$tmp_cart_id' and it_id = '$it_id' and io_id = '$io_id' + and io_type = '$io_type' and ct_status = '쇼핑' "; $row2 = sql_fetch($sql2); if(isset($row2['ct_id']) && $row2['ct_id']) { + if ((int) $row2['ct_price'] !== (int) $it['it_price'] || (int) $row2['io_price'] !== $io_price) + die(json_encode(array('error' => '기존 장바구니의 상품 정보가 변경되었습니다. 삭제한 뒤 다시 담아 주십시오.'))); // 재고체크 $tmp_ct_qty = $row2['ct_qty']; if(!$io_id) diff --git a/shop/cartupdate.php b/shop/cartupdate.php index dac248e9e..465bea2ff 100644 --- a/shop/cartupdate.php +++ b/shop/cartupdate.php @@ -146,6 +146,10 @@ else // 장바구니에 담기 if ($count < 1) alert('장바구니에 담을 상품을 선택하여 주십시오.'); + $cart_validation = shop_validate_cart_request($_POST, $act === 'multi'); + if ($cart_validation['error'] !== '') + alert($cart_validation['error']); + $ct_count = 0; $post_chk_it_id = (isset($_POST['chk_it_id']) && is_array($_POST['chk_it_id'])) ? $_POST['chk_it_id'] : array(); $post_io_ids = (isset($_POST['io_id']) && is_array($_POST['io_id'])) ? $_POST['io_id'] : array(); @@ -184,7 +188,7 @@ else // 장바구니에 담기 } // 상품정보 - $it = get_shop_item($it_id, false); + $it = $cart_validation['products'][$it_id]['item']; if(!$it['it_id']) alert('상품정보가 존재하지 않습니다.'); @@ -219,30 +223,14 @@ else // 장바구니에 담기 } } - // 옵션정보를 얻어서 배열에 저장 - $opt_list = array(); - $sql = " select * from {$g5['g5_shop_item_option_table']} where it_id = '$it_id' and io_use = 1 order by io_no asc "; - $result = sql_query($sql); - $lst_count = 0; - for($k=0; $row=sql_fetch_array($result); $k++) { - $opt_list[$row['io_type']][$row['io_id']]['id'] = $row['io_id']; - $opt_list[$row['io_type']][$row['io_id']]['use'] = $row['io_use']; - $opt_list[$row['io_type']][$row['io_id']]['price'] = $row['io_price']; - $opt_list[$row['io_type']][$row['io_id']]['stock'] = $row['io_stock_qty']; - - // 선택옵션 개수 - if(!$row['io_type']) - $lst_count++; - } - //-------------------------------------------------------- // 재고 검사, 바로구매일 때만 체크 //-------------------------------------------------------- // 이미 주문폼에 있는 같은 상품의 수량합계를 구한다. if($sw_direct) { for($k=0; $k<$opt_count; $k++) { - $io_id = isset($_POST['io_id'][$it_id][$k]) ? preg_replace(G5_OPTION_ID_FILTER, '', $_POST['io_id'][$it_id][$k]) : ''; - $io_type = isset($_POST['io_type'][$it_id][$k]) ? preg_replace('#[^01]#', '', $_POST['io_type'][$it_id][$k]) : ''; + $io_id = $cart_validation['products'][$it_id]['rows'][$k]['io_id']; + $io_type = $cart_validation['products'][$it_id]['rows'][$k]['io_type']; $io_value = isset($_POST['io_value'][$it_id][$k]) ? $_POST['io_value'][$it_id][$k] : ''; $sql = " select SUM(ct_qty) as cnt from {$g5['g5_shop_cart_table']} @@ -287,24 +275,17 @@ else // 장바구니에 담기 // 장바구니에 Insert $comma = ''; + $ct_count = 0; $sql = " INSERT INTO {$g5['g5_shop_cart_table']} ( od_id, mb_id, it_id, it_name, it_sc_type, it_sc_method, it_sc_price, it_sc_minimum, it_sc_qty, ct_status, ct_price, ct_point, ct_point_use, ct_stock_use, ct_option, ct_qty, ct_notax, io_id, io_type, io_price, ct_time, ct_ip, ct_send_cost, ct_direct, ct_select, ct_select_time ) VALUES "; for($k=0; $k<$opt_count; $k++) { - $io_id = isset($_POST['io_id'][$it_id][$k]) ? preg_replace(G5_OPTION_ID_FILTER, '', $_POST['io_id'][$it_id][$k]) : ''; - $io_type = isset($_POST['io_type'][$it_id][$k]) ? preg_replace('#[^01]#', '', $_POST['io_type'][$it_id][$k]) : ''; + $io_id = $cart_validation['products'][$it_id]['rows'][$k]['io_id']; + $io_type = $cart_validation['products'][$it_id]['rows'][$k]['io_type']; $io_value = isset($_POST['io_value'][$it_id][$k]) ? $_POST['io_value'][$it_id][$k] : ''; - // 선택옵션정보가 존재하는데 선택된 옵션이 없으면 건너뜀 - if($lst_count && $io_id == '') - continue; - - // 구매할 수 없는 옵션은 건너뜀 - if($io_id && !$opt_list[$io_type][$io_id]['use']) - continue; - - $io_price = isset($opt_list[$io_type][$io_id]['price']) ? $opt_list[$io_type][$io_id]['price'] : 0; + $io_price = $cart_validation['products'][$it_id]['rows'][$k]['io_price']; $ct_qty = isset($_POST['ct_qty'][$it_id][$k]) ? (int) $_POST['ct_qty'][$it_id][$k] : 0; // 구매가격이 음수인지 체크 @@ -317,14 +298,17 @@ else // 장바구니에 담기 } // 동일옵션의 상품이 있으면 수량 더함 - $sql2 = " select ct_id, io_type, ct_qty + $sql2 = " select ct_id, io_type, ct_qty, ct_price, io_price from {$g5['g5_shop_cart_table']} where od_id = '$tmp_cart_id' and it_id = '$it_id' and io_id = '$io_id' + and io_type = '$io_type' and ct_status = '쇼핑' "; $row2 = sql_fetch($sql2); if(isset($row2['ct_id']) && $row2['ct_id']) { + if ((int) $row2['ct_price'] !== (int) $it['it_price'] || (int) $row2['io_price'] !== $io_price) + alert('기존 장바구니의 상품 정보가 변경되었습니다. 삭제한 뒤 다시 담아 주십시오.'); // 재고체크 $tmp_ct_qty = $row2['ct_qty']; if(!$io_id) diff --git a/shop/orderform.php b/shop/orderform.php index e7818a322..5eed7ec94 100644 --- a/shop/orderform.php +++ b/shop/orderform.php @@ -24,6 +24,12 @@ else { if (get_cart_count($tmp_cart_id) == 0) alert('장바구니가 비어 있습니다.', G5_SHOP_URL.'/cart.php'); +// KVE-2026-2345: 과거 장바구니의 옵션 종류와 가격도 주문 전에 다시 검증한다. +$cart_validation_error = shop_validate_order_cart($tmp_cart_id); +if ($cart_validation_error !== '') { + alert($cart_validation_error, G5_SHOP_URL.'/cart.php'); +} + if (function_exists('before_check_cart_price')) { if(! before_check_cart_price($tmp_cart_id) ) alert('장바구니 금액에 변동사항이 있습니다.\n장바구니를 다시 확인해 주세요.', G5_SHOP_URL.'/cart.php'); } diff --git a/shop/orderformupdate.php b/shop/orderformupdate.php index bce7244da..7a71b9274 100644 --- a/shop/orderformupdate.php +++ b/shop/orderformupdate.php @@ -47,6 +47,12 @@ if (get_cart_count($tmp_cart_id) == 0) { // 장바구니에 담기 alert('장바구니가 비어 있습니다.\\n\\n이미 주문하셨거나 장바구니에 담긴 상품이 없는 경우입니다.', G5_SHOP_URL.'/cart.php'); } +// KVE-2026-2345: 과거 장바구니의 옵션 종류와 가격도 주문 전에 다시 검증한다. +$cart_validation_error = shop_validate_order_cart($tmp_cart_id); +if ($cart_validation_error !== '') { + alert($cart_validation_error, G5_SHOP_URL.'/cart.php'); +} + // 변수 초기화 $od_other_pay_type = ''; diff --git a/tests/README.cart-validation.md b/tests/README.cart-validation.md new file mode 100644 index 000000000..de3b073c4 --- /dev/null +++ b/tests/README.cart-validation.md @@ -0,0 +1,31 @@ +# 장바구니 옵션 검증 테스트 + +KVE-2026-2345 수정의 회귀 검증이다. 운영 DB·PG를 사용하지 않는다. + +## 공통 규칙 + +```bash +php tests/shop_cart_validation.php +``` + +DB 연결 없이 기본상품·선택옵션·추가옵션, 잘못된 종류·수량·재고, 정상 0원 상품, 과거 조작 행과 가격 변경을 검증한다. + +## HTTP 및 MySQL + +별도로 초기화한 테스트 전용 MySQL 서버의 Unix 소켓을 전달한다. PHP CLI의 `mysqli`, Python 3, `mysql` 클라이언트와 해당 테스트 서버의 비밀번호 없는 root 계정이 필요하다. + +```bash +python3 tests/shop_cart_http.py /tmp/kve2345-mysql.sock +``` + +실행마다 고유한 `kve2345_test_*` DB를 생성하고 종료 시 삭제한다. 저장소 설치 SQL에서 상품·옵션·장바구니 테이블을 생성하며 MyISAM과 InnoDB에서 같은 검증을 수행한다. DB 엔진을 변환하는 대상은 생성된 테스트 테이블뿐이다. + +공통 AJAX, 기본 테마 AJAX, 일반 장바구니 처리 파일을 임시 디렉터리에 복사하여 로컬 HTTP 서버로 실행한다. 인증·세션·리다이렉트 등 주변 기능은 테스트 부트스트랩으로 대체하고, SQL은 실제 MySQL에서 실행한다. 잘못된 요청이 DB를 바꾸지 않는지와 정상 추가·수량 병합·옵션별 합계를 확인한다. + +PC·모바일 주문 처리 파일은 추가한 장바구니 검증 경계까지만 실행한다. 따라서 이 테스트는 실제 PG 승인·취소, 주문 저장 완료, 쿠폰·포인트·배송비 후속 정산, 실제 로그인·구매 권한, 브라우저 UI 또는 동시 요청 검증을 대신하지 않는다. 이 항목들은 배포 전 별도의 통합 환경에서 확인해야 한다. + +## 구현 범위 + +두 AJAX 경로와 일반 추가 경로는 요청 전체의 상품·옵션 구성을 검증한 후 기존 변경 로직으로 진행한다. 옵션 종류·가격은 검증한 서버 데이터에서 가져온다. 주문 준비 및 PC·모바일 주문 제출은 선택된 기존 장바구니 행도 검증하여 과거 조작 행과 가격 불일치를 거부한다. 정상 무료상품은 허용한다. + +기존 테이블 엔진은 변경하지 않으며, 장바구니·주문 전체의 트랜잭션이나 외부 PG 복구 흐름을 새로 도입하지 않는다. 따라서 사전 검증 이후의 DB 오류·동시 변경까지 모든 처리가 원자적이라고 보장하는 변경은 아니다. diff --git a/tests/shop_cart_http.py b/tests/shop_cart_http.py new file mode 100644 index 000000000..84e3172de --- /dev/null +++ b/tests/shop_cart_http.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""격리 MySQL 전용: python3 tests/shop_cart_http.py /path/to/test-mysql.sock + +고유 테스트 DB를 생성·삭제한다. 실제 장바구니 처리 파일을 HTTP로 실행하며, +주문서는 PG/메일/주문 저장을 실행하기 전 검증 경계까지만 실행한다. +인증·세션·메일·리다이렉트 등 주변 기능은 아래 테스트 부트스트랩으로 대체한다. +""" +import http.cookiejar +import json +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time +import urllib.parse +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +SOCKET = sys.argv[1] +DB = 'kve2345_test_' + str(time.time_ns()) + +def mysql(sql, database=True): + command = ['mysql', '--no-defaults', '--socket=' + SOCKET, '-u', 'root', '-N', '-B'] + if database: + command.append(DB) + return subprocess.check_output(command, input="SET sql_mode='';" + sql, text=True).strip() + +mysql('CREATE DATABASE `' + DB + '`', False) +server = None +try: + import re + schema = (ROOT / 'install/gnuboard5shop.sql').read_text() + for table in ['item', 'item_option', 'cart']: + statement = re.search(r'CREATE TABLE IF NOT EXISTS `g5_shop_' + table + r'` \(.*?;', schema, re.S).group() + mysql("SET sql_mode='';" + statement) + mysql("SET sql_mode=''; INSERT INTO g5_shop_item (it_id,it_name,it_use,it_price,it_stock_qty,it_sc_type) VALUES ('item','상품',1,50000,100,1),('other','다른 상품',1,30000,100,1)") + with tempfile.TemporaryDirectory(prefix='kve2345-http-') as temp: + directory = Path(temp) + bootstrap = r''''g5_shop_item', 'g5_shop_item_option_table'=>'g5_shop_item_option', 'g5_shop_cart_table'=>'g5_shop_cart'); +$member = array('mb_id'=>'', 'mb_level'=>1); +$default = array('de_level_sell'=>1, 'de_pg_service'=>'kcp'); +$config = array('cf_use_point'=>0); +$is_admin = true; $is_member = false; +$sw_direct = !empty($_POST['sw_direct']) ? 1 : 0; +$od_settle_case = '무통장'; +$link = new mysqli('localhost','root','',TEST_DB,0,TEST_SOCKET); +$link->set_charset('utf8'); +$link->query("SET sql_mode=''"); +function sql_query($sql, $error=true) { global $link; return $link->query($sql); } +function sql_fetch_array($result) { return $result->fetch_assoc(); } +function sql_fetch($sql) { return sql_fetch_array(sql_query($sql)) ?: array(); } +function sql_escape_string($s) { global $link; return $link->real_escape_string($s); } +function sql_real_escape_string($s) { return sql_escape_string($s); } +function safe_replace_regex($s,$type) { return preg_replace('/[^a-z0-9_\-]/i','',$s); } +function clean_xss_tags($s) { return strip_tags($s); } +function get_real_client_ip() { return '127.0.0.1'; } +function set_cart_id($direct) { if(empty($_SESSION['ss_cart_id'])) $_SESSION['ss_cart_id'] = (string)random_int(100000000,999999999); $_SESSION['ss_cart_direct'] = $_SESSION['ss_cart_id']; } +function get_session($key) { return isset($_SESSION[$key]) ? $_SESSION[$key] : ''; } +function set_session($key,$value) { $_SESSION[$key]=$value; } +function cart_item_clean() {} +function is_inicis_order_pay($method) { return false; } +function check_request_origin($url) {} +function get_cart_count($id) { $r=sql_fetch("SELECT COUNT(*) n FROM g5_shop_cart WHERE od_id='".sql_escape_string($id)."'"); return $r['n']; } +function get_it_stock_qty($id) { $r=sql_fetch("SELECT it_stock_qty FROM g5_shop_item WHERE it_id='".sql_escape_string($id)."'"); return $r['it_stock_qty']; } +function get_option_stock_qty($id,$option,$type) { $r=sql_fetch("SELECT io_stock_qty FROM g5_shop_item_option WHERE it_id='".sql_escape_string($id)."' AND io_id='".sql_escape_string($option)."' AND io_type='".(int)$type."'"); return $r['io_stock_qty']; } +function alert($message,$url='') { die(json_encode(array('error'=>$message))); } +function goto_url($url) { die(json_encode(array('error'=>'','redirect'=>$url))); } +require TEST_VALIDATOR; +''' + constants = '\n' + (directory / 'bootstrap.php').write_text(constants + bootstrap) + (directory / 'mailer.lib.php').write_text(''','checkpoint'=>'validated')); exit;" + (directory / (route + '.php')).write_text(text) + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + port = sock.getsockname()[1] + with (directory / 'server.log').open('w+') as log: + server = subprocess.Popen(['php', '-S', '127.0.0.1:' + str(port), '-t', temp], stdout=log, stderr=log, cwd=temp) + for attempt in range(100): + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + time.sleep(.05) + client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) + count = 0 + def request(route, fields): + data = urllib.parse.urlencode(fields).encode() + try: + with client.open('http://127.0.0.1:' + str(port) + '/' + route + '.php', data) as response: + return json.loads(response.read()) + except Exception: + print((directory / 'server.log').read_text(), file=sys.stderr) + raise + def payload(types=('0',), ids=('',)): + data = [('action','cart_update'),('it_id[0]','item')] + for i, (kind, identity) in enumerate(zip(types, ids)): + data += [('io_type[item]['+str(i)+']',kind),('io_id[item]['+str(i)+']',identity),('ct_qty[item]['+str(i)+']','1')] + return data + def expect(value, description): + if not value: + raise AssertionError(description) + for engine in ['MyISAM','InnoDB']: + for table in ['item','item_option','cart']: + mysql('ALTER TABLE g5_shop_' + table + ' ENGINE=' + engine) + for route in ['ajax','theme','normal']: + mysql('DELETE FROM g5_shop_cart; DELETE FROM g5_shop_item_option') + for bad in [payload(('1',)), payload(('0','1'),('','')), payload(('01',)), payload(('1x',)), payload(('0',),('missing',))]: + result = request(route,bad) + expect(result['error'] != '' and mysql('SELECT COUNT(*) FROM g5_shop_cart') == '0', route+' 잘못된 입력') + count += 1 + malformed = [ + [('action','cart_update'),('it_id[0]','item'),('io_id[item][0]',''),('io_type[item][0][nested]','0'),('ct_qty[item][0]','1')], + [('action','cart_update'),('it_id[0]','item'),('io_id[item][0][nested]',''),('io_type[item][0]','0'),('ct_qty[item][0]','1')], + [('action','cart_update'),('it_id[0]','item'),('io_id[item][0]',''),('io_type[item][0]','0'),('ct_qty[item][1]','1')], + [('action','cart_update'),('it_id[0]','item'),('io_id','bad'),('io_type[item][0]','0'),('ct_qty[item][0]','1')], + payload()+[('ct_qty[item][0]','1.5')], + payload()+[('io_value[item][0][nested]','bad')], + ] + for bad in malformed: + expect(request(route,bad)['error'] != '' and mysql('SELECT COUNT(*) FROM g5_shop_cart') == '0', '배열 구조 및 수량 검증') + count += 1 + expect(request(route,payload())['error'] == '',route+' 정상 추가') + expect(mysql('SELECT SUM(IF(io_type=1,io_price*ct_qty,(ct_price+io_price)*ct_qty)) FROM g5_shop_cart') == '50000',route+' 정상 가격') + count += 1 + # 옵션 변경/바로구매 실패가 기존 행을 삭제하면 안 된다. + before = mysql('SELECT ct_id,ct_qty FROM g5_shop_cart') + expect(request(route,payload(('1',))+[('sw_direct','1'),('act','optionmod')])['error'] != '', '잘못된 수정 거부') + expect(mysql('SELECT ct_id,ct_qty FROM g5_shop_cart') == before,'기존 장바구니 보존') + count += 1 + bad_second = payload()+[('it_id[1]','other'),('io_type[other][0]','1'),('io_id[other][0]',''),('ct_qty[other][0]','1')] + expect(request(route,bad_second)['error'] != '', '다중 상품 검증') + expect(mysql('SELECT ct_id,ct_qty FROM g5_shop_cart') == before,'다중 요청 부분 변경 방지') + count += 1 + expect(request(route,payload())['error'] == '', '정상 병합') + expect(mysql('SELECT COUNT(*),SUM(ct_qty) FROM g5_shop_cart') == '1\t2','수량 병합 결과') + count += 1 + # 패치 전 변조 행이 정상이 되어 보이도록 병합되지 않는지 확인 + mysql('UPDATE g5_shop_cart SET io_type=1,io_price=0,ct_select=1') + for order_route in ['pc_order','mobile_order']: + expect(request(order_route,[])['error'] != '', '과거 조작 주문 거부') + count += 1 + mysql('UPDATE g5_shop_cart SET io_type=0') + for order_route in ['pc_order','mobile_order']: + expect(request(order_route,[]) .get('checkpoint') == 'validated','정상 주문 검증 진입') + count += 1 + mysql("DELETE FROM g5_shop_cart; INSERT INTO g5_shop_item_option (it_id,io_id,io_type,io_use,io_price,io_stock_qty) VALUES ('item','wrap',1,1,1000,100)") + expect(request(route,payload(('0','1'),('','wrap')))['error'] == '', '정상 보조옵션') + expect(mysql('SELECT SUM(IF(io_type=1,io_price*ct_qty,(ct_price+io_price)*ct_qty)) FROM g5_shop_cart') == '51000','보조옵션 포함 합계') + count += 1 + # 타입은 다르지만 ID가 같은 옵션을 서로 병합하지 않는다. + mysql("DELETE FROM g5_shop_cart; DELETE FROM g5_shop_item_option; INSERT INTO g5_shop_item_option (it_id,io_id,io_type,io_use,io_price,io_stock_qty) VALUES ('item','same',0,1,2000,100),('item','same',1,1,1000,100)") + pair = payload(('0','1'),('same','same')) + expect(request(route,pair)['error'] == '', '동일 ID 다른 종류 추가') + expect(request(route,pair)['error'] == '', '동일 ID 다른 종류 병합') + expect(mysql('SELECT COUNT(*),SUM(IF(io_type=1,io_price*ct_qty,(ct_price+io_price)*ct_qty)) FROM g5_shop_cart') == '2\t106000', '서로 다른 종류 병합 방지') + count += 1 + server.terminate(); server.wait(); server = None + log.seek(0); errors = log.read() + expect('PHP Warning' not in errors and 'PHP Fatal' not in errors,errors) + print(str(count)+'개 HTTP/DB 검증 통과 (MyISAM·InnoDB, 주문은 검증 경계까지)') +finally: + if server: + server.terminate(); server.wait() + mysql('DROP DATABASE `' + DB + '`', False) diff --git a/tests/shop_cart_validation.php b/tests/shop_cart_validation.php new file mode 100644 index 000000000..66fbc9467 --- /dev/null +++ b/tests/shop_cart_validation.php @@ -0,0 +1,75 @@ + $id, 'io_type' => $type, 'ct_qty' => $qty); +} +$item = array('it_id' => 'item', 'it_use' => 1, 'it_tel_inq' => 0, 'it_price' => 50000, 'it_stock_qty' => 100, 'it_buy_min_qty' => 0, 'it_buy_max_qty' => 0); +$base = test_row(); +$addon = array('it_id' => 'item', 'io_id' => 'wrap', 'io_type' => '1', 'io_use' => 1, 'io_price' => 1000, 'io_stock_qty' => 100); +$selection = array('it_id' => 'item', 'io_id' => 'red', 'io_type' => '0', 'io_use' => 1, 'io_price' => 2000, 'io_stock_qty' => 100); +check_cart('기본상품', $item, array(), array($base), 50000); +check_cart('빈 보조옵션', $item, array(), array(test_row('', '1')), false); +check_cart('정상 행 뒤 빈 보조옵션', $item, array(), array($base, test_row('', '1')), false); +foreach (array('', '01', '11', '2', '-1', '1x', '1.0', true, array('1')) as $type) + check_cart('종류 형식', $item, array(), array(test_row('', $type)), false); +foreach (array('0', '-1', '1.5', '1x', '2147483648', array(1), true) as $qty) + check_cart('수량 형식', $item, array(), array(test_row('', '0', $qty)), false); +check_cart('수량 재고 초과', $item, array(), array(test_row('', '0', '101')), false); +check_cart('동일 옵션 누적 재고', $item, array(), array(test_row('', '0', '60'), test_row('', '0', '60')), false); +check_cart('정상 추가옵션', $item, array($addon), array($base, test_row('wrap', '1')), 51000); +check_cart('첫 행 추가옵션', $item, array($addon), array(test_row('wrap', '1'), $base), false); +check_cart('추가옵션 단독', $item, array($addon), array(test_row('wrap', '1')), false); +check_cart('추가옵션 종류 불일치', $item, array($addon), array(test_row('wrap', '0')), false); +check_cart('없는 옵션', $item, array(), array($base, test_row('wrap', '1')), false); +$disabled = $addon; $disabled['io_use'] = 0; +check_cart('비활성 옵션', $item, array($disabled), array($base, test_row('wrap', '1')), false); +$foreign = $addon; $foreign['it_id'] = 'other'; +check_cart('타 상품 옵션', $item, array($foreign), array($base, test_row('wrap', '1')), false); +check_cart('모호한 중복 옵션', $item, array($addon, $addon), array($base, test_row('wrap', '1')), false); +check_cart('필수 선택옵션 누락', $item, array($selection), array($base), false); +check_cart('정상 선택옵션', $item, array($selection), array(test_row('red')), 52000); +$disabled = $selection; $disabled['io_use'] = 0; +check_cart('모든 선택옵션 비활성', $item, array($disabled), array($base), false); +$same_id = $addon; $same_id['io_id'] = 'red'; +check_cart('다른 종류의 동일 ID', $item, array($selection, $same_id), array(test_row('red'), test_row('red', '1')), 53000); +$free = $item; $free['it_price'] = 0; +check_cart('정상 무료상품', $free, array(), array($base), 0); +$discount = $selection; $discount['io_price'] = -50000; +check_cart('정상 0원 선택옵션', $item, array($discount), array(test_row('red')), 0); +$discount['io_price'] = -50001; +check_cart('음수 합계', $item, array($discount), array(test_row('red')), false); +$negative = $addon; $negative['io_price'] = -1; +check_cart('음수 추가옵션', $item, array($negative), array($base, test_row('wrap', '1')), false); +$stored = $base; $stored['ct_price'] = '50000'; $stored['io_price'] = '0'; +check_cart('정상 저장 행', $item, array(), array($stored), 50000, true); +$stored['io_type'] = '1'; +check_cart('과거 조작 행', $item, array(), array($stored), false, true); +$stored['io_type'] = '0'; $stored['ct_price'] = 1; +check_cart('상품가격 변경', $item, array(), array($stored), false, true); +$stored['ct_price'] = 50000; $stored['io_price'] = 1; +check_cart('옵션가격 불일치', $item, array(), array($stored), false, true); +foreach (array('it_use' => 0, 'it_tel_inq' => 1, 'it_id' => '') as $key => $value) { + $invalid = $item; $invalid[$key] = $value; + check_cart('구매불가 상품', $invalid, array(), array($base), false); +} +$limited = $item; $limited['it_buy_min_qty'] = 2; +check_cart('최소 수량', $limited, array(), array($base), false); +$limited = $item; $limited['it_buy_max_qty'] = 1; +check_cart('최대 수량', $limited, array(), array(test_row('', '0', '2')), false); +echo $checks."개 검증 통과\n"; diff --git a/theme/basic/shop/ajax.action.php b/theme/basic/shop/ajax.action.php index 0f5f37ff9..d110308c1 100644 --- a/theme/basic/shop/ajax.action.php +++ b/theme/basic/shop/ajax.action.php @@ -78,35 +78,20 @@ switch ($action) { if ($count < 1) die(json_encode(array('error' => '장바구니에 담을 상품을 선택하여 주십시오.'))); + $cart_validation = shop_validate_cart_request($_POST); + if ($cart_validation['error'] !== '') + die(json_encode(array('error' => $cart_validation['error']))); + $ct_count = 0; for($i=0; $i<$count; $i++) { $it_id = isset($_POST['it_id'][$i]) ? safe_replace_regex($_POST['it_id'][$i], 'it_id') : ''; $opt_count = (isset($_POST['io_id'][$it_id]) && is_array($_POST['io_id'][$it_id])) ? count($_POST['io_id'][$it_id]) : 0; // 상품정보 - $it = get_shop_item($it_id, false); + $it = $cart_validation['products'][$it_id]['item']; if(!$it['it_id']) die(json_encode(array('error' => '상품정보가 존재하지 않습니다.'))); - // 옵션정보를 얻어서 배열에 저장 - $opt_list = array(); - $sql = " select * from {$g5['g5_shop_item_option_table']} where it_id = '$it_id' order by io_no asc "; - $result = sql_query($sql); - $lst_count = 0; - for($k=0; $row=sql_fetch_array($result); $k++) { - $opt_list[$row['io_type']][$row['io_id']]['id'] = $row['io_id']; - $opt_list[$row['io_type']][$row['io_id']]['use'] = $row['io_use']; - $opt_list[$row['io_type']][$row['io_id']]['price'] = $row['io_price']; - $opt_list[$row['io_type']][$row['io_id']]['stock'] = $row['io_stock_qty']; - - // 선택옵션 개수 - if(!$row['io_type']) - $lst_count++; - } - - if($lst_count > 0 && !trim($_POST['io_id'][$it_id][$i]) && $_POST['io_type'][$it_id][$i] == 0) - die(json_encode(array('error' => '상품의 선택옵션을 선택해 주십시오.'))); - for($k=0; $k<$opt_count; $k++) { $post_ct_qty = isset($_POST['ct_qty'][$it_id][$k]) ? (int) $_POST['ct_qty'][$it_id][$k] : 0; if ($post_ct_qty < 1) @@ -155,25 +140,17 @@ switch ($action) { // 장바구니에 Insert $comma = ''; + $ct_count = 0; $sql = " INSERT INTO {$g5['g5_shop_cart_table']} ( od_id, mb_id, it_id, it_name, it_sc_type, it_sc_method, it_sc_price, it_sc_minimum, it_sc_qty, ct_status, ct_price, ct_point, ct_point_use, ct_stock_use, ct_option, ct_qty, ct_notax, io_id, io_type, io_price, ct_time, ct_ip, ct_send_cost, ct_direct, ct_select, ct_select_time ) VALUES "; for($k=0; $k<$opt_count; $k++) { - $io_id = isset($_POST['io_id'][$it_id][$k]) ? preg_replace(G5_OPTION_ID_FILTER, '', $_POST['io_id'][$it_id][$k]) : ''; - $io_type = isset($_POST['io_type'][$it_id][$k]) ? preg_replace('#[^01]#', '', $_POST['io_type'][$it_id][$k]) : ''; + $io_id = $cart_validation['products'][$it_id]['rows'][$k]['io_id']; + $io_type = $cart_validation['products'][$it_id]['rows'][$k]['io_type']; $io_value = isset($_POST['io_value'][$it_id][$k]) ? $_POST['io_value'][$it_id][$k] : ''; - // 선택옵션정보가 존재하는데 선택된 옵션이 없으면 건너뜀 - if($lst_count && $io_id == '') - continue; - - $opt_list_type_id_use = isset($opt_list[$io_type][$io_id]['use']) ? $opt_list[$io_type][$io_id]['use'] : ''; - // 구매할 수 없는 옵션은 건너뜀 - if($io_id && ! $opt_list_type_id_use) - continue; - - $io_price = isset($opt_list[$io_type][$io_id]['price']) ? $opt_list[$io_type][$io_id]['price'] : 0; + $io_price = $cart_validation['products'][$it_id]['rows'][$k]['io_price']; $ct_qty = isset($_POST['ct_qty'][$it_id][$k]) ? (int) $_POST['ct_qty'][$it_id][$k] : 0; // 구매가격이 음수인지 체크 @@ -186,14 +163,17 @@ switch ($action) { } // 동일옵션의 상품이 있으면 수량 더함 - $sql2 = " select ct_id, io_type, ct_qty + $sql2 = " select ct_id, io_type, ct_qty, ct_price, io_price from {$g5['g5_shop_cart_table']} where od_id = '$tmp_cart_id' and it_id = '$it_id' and io_id = '$io_id' + and io_type = '$io_type' and ct_status = '쇼핑' "; $row2 = sql_fetch($sql2); if(isset($row2['ct_id']) && $row2['ct_id']) { + if ((int) $row2['ct_price'] !== (int) $it['it_price'] || (int) $row2['io_price'] !== $io_price) + die(json_encode(array('error' => '기존 장바구니의 상품 정보가 변경되었습니다. 삭제한 뒤 다시 담아 주십시오.'))); // 재고체크 $tmp_ct_qty = $row2['ct_qty']; if(!$io_id)