security: KVE-2026-2345 장바구니 옵션 검증으로 상품대금 누락 차단

공통·기본 테마 AJAX와 일반 장바구니 요청에 동일한 상품·옵션 검증을 적용한다. 주문 준비 및 PC·모바일 주문 제출에서도 기존 행의 종류·가격·구성을 확인한다.

공통 규칙 46개와 격리 MySQL의 MyISAM·InnoDB HTTP/DB 검증 126개를 통과했다. 주문 검증은 PG·주문 저장 이전 경계까지이며 전체 결제 및 동시성 검증은 별도로 필요하다.
This commit is contained in:
whitedot
2026-09-10 05:17:59 +00:00
parent 77e0cda39b
commit 6d4e75e1c9
11 changed files with 507 additions and 98 deletions
+159
View File
@@ -0,0 +1,159 @@
<?php
if (!defined('_GNUBOARD_')) exit;
// KVE-2026-2345: 요청의 옵션 종류는 가격 계산의 근거가 될 수 없다.
// DB 조회와 분리하여 신규 요청과 기존 장바구니에 같은 규칙을 적용한다.
function shop_validate_cart_rows($item, $options, $rows, $check_price = false)
{
$error = '상품 또는 옵션 정보가 올바르지 않습니다. 장바구니에서 삭제한 뒤 다시 담아 주십시오.';
if (empty($item['it_id']) || empty($item['it_use']) || !empty($item['it_tel_inq']) || !$rows)
return array('error' => $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 '';
}
+2 -1
View File
@@ -1,4 +1,5 @@
<?php
include_once(dirname(__FILE__).'/shop.cartvalidate.lib.php');
//==============================================================================
// 쇼핑몰 라이브러리 모음 시작
//==============================================================================
@@ -2694,7 +2695,7 @@ function before_check_cart_price($s_cart_id, $is_ct_select_condition=false, $is_
}
if( $row['io_id'] ){
$io_sql = " select * from {$g5['g5_shop_item_option_table']} where it_id = '{$it['it_id']}' and io_id = '{$row['io_id']}' ";
$io_sql = " select * from {$g5['g5_shop_item_option_table']} where it_id = '{$it['it_id']}' and io_id = '{$row['io_id']}' and io_type = '{$row['io_type']}' and io_use = '1' ";
$io_infos = sql_fetch( $io_sql );
if( $io_infos['io_type'] ){
+6
View File
@@ -71,6 +71,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 = '';
+13 -33
View File
@@ -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)
+15 -31
View File
@@ -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)
+6
View File
@@ -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');
}
+6
View File
@@ -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 = '';
+31
View File
@@ -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 오류·동시 변경까지 모든 처리가 원자적이라고 보장하는 변경은 아니다.
+181
View File
@@ -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'''<?php
error_reporting(E_ALL);
ini_set('display_errors', '0');
session_start();
define('_GNUBOARD_', true);
define('G5_SHOP_URL', '/shop');
define('G5_BBS_URL', '/bbs');
define('G5_TIME_YMDHIS', date('Y-m-d H:i:s'));
define('G5_OPTION_ID_FILTER', '/[\'"\\\\]/');
define('G5_LIB_PATH', __DIR__);
$g5 = array('g5_shop_item_table'=>'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 = '<?php\ndefine("TEST_DB",' + json.dumps(DB) + ');\ndefine("TEST_SOCKET",' + json.dumps(SOCKET) + ');\ndefine("TEST_VALIDATOR",' + json.dumps(str(ROOT / 'lib/shop.cartvalidate.lib.php')) + ');\n?>\n'
(directory / 'bootstrap.php').write_text(constants + bootstrap)
(directory / 'mailer.lib.php').write_text('<?php')
routes = {'ajax': 'shop/ajax.action.php', 'theme': 'theme/basic/shop/ajax.action.php', 'normal': 'shop/cartupdate.php', 'pc_order': 'shop/orderformupdate.php', 'mobile_order': 'mobile/shop/orderformupdate.php'}
for route, source in routes.items():
text = (ROOT / source).read_text().replace("include_once('./_common.php');", "include_once('./bootstrap.php');", 1)
if route.endswith('_order'):
text = text.split('// 변수 초기화', 1)[0] + "echo json_encode(array('error'=>'','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)
+75
View File
@@ -0,0 +1,75 @@
<?php
// 실행: php tests/shop_cart_validation.php (DB 연결 없이 공통 규칙 검증)
if (PHP_SAPI !== 'cli') exit;
define('_GNUBOARD_', true);
require dirname(__FILE__).'/../lib/shop.cartvalidate.lib.php';
$checks = 0;
function check_cart($name, $item, $options, $rows, $expected, $stored = false)
{
global $checks;
$result = shop_validate_cart_rows($item, $options, $rows, $stored);
$actual = $result['error'] === '' ? $result['total'] : false;
if ($actual !== $expected) {
fwrite(STDERR, $name.': '.json_encode($result)."\n");
exit(1);
}
$checks++;
}
function test_row($id = '', $type = '0', $qty = '1')
{
return array('io_id' => $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";
+13 -33
View File
@@ -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)