security: KVE-2026-2345 장바구니 옵션 검증으로 상품대금 누락 차단
공통·기본 테마 AJAX와 일반 장바구니 요청에 동일한 상품·옵션 검증을 적용한다. 주문 준비 및 PC·모바일 주문 제출에서도 기존 행의 종류·가격·구성을 확인한다. 공통 규칙 46개와 격리 MySQL의 MyISAM·InnoDB HTTP/DB 검증 126개를 통과했다. 주문 검증은 PG·주문 저장 이전 경계까지이며 전체 결제 및 동시성 검증은 별도로 필요하다.
This commit is contained in:
@@ -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 오류·동시 변경까지 모든 처리가 원자적이라고 보장하는 변경은 아니다.
|
||||
@@ -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)
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user