Files
Gnuboard5/tests/shop_cart_http.py
whitedot 6d4e75e1c9 security: KVE-2026-2345 장바구니 옵션 검증으로 상품대금 누락 차단
공통·기본 테마 AJAX와 일반 장바구니 요청에 동일한 상품·옵션 검증을 적용한다. 주문 준비 및 PC·모바일 주문 제출에서도 기존 행의 종류·가격·구성을 확인한다.

공통 규칙 46개와 격리 MySQL의 MyISAM·InnoDB HTTP/DB 검증 126개를 통과했다. 주문 검증은 PG·주문 저장 이전 경계까지이며 전체 결제 및 동시성 검증은 별도로 필요하다.
2026-09-10 05:17:59 +00:00

182 lines
12 KiB
Python

#!/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)