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
+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";