From 7a80ae913d8f68216d0a24f89f7eb0681459a19f Mon Sep 17 00:00:00 2001 From: whitedot Date: Thu, 10 Sep 2026 03:14:36 +0000 Subject: [PATCH] =?UTF-8?q?security:=20KVE-2026-2346=20KCP=20=ED=86=B5?= =?UTF-8?q?=EB=B3=B4=20=EA=B2=80=EC=A6=9D=EA=B3=BC=20=EC=9E=85=EA=B8=88?= =?UTF-8?q?=C2=B7=EB=A7=9D=EC=B7=A8=EC=86=8C=20=EC=9E=AC=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트·운영 발신지와 결제 당시 상점·거래·계좌·금액을 대조하여 위조 입금 처리를 차단한다. 개인결제와 연결 주문의 중복 가산을 막고 통보 이력과 목표값으로 MyISAM/InnoDB 중간 실패를 재처리한다. DB 마이그레이션과 기존 거래 적용·복구 안내를 추가했다. 격리 HTTP/MySQL 검사 420개, PHP 문법 검사와 설치 스키마 대조가 통과했다. 실제 KCP 연동 확인과 배포·KISA 회신은 별도 진행이 필요하다. --- docs/security-kve-2026-2346.md | 59 ++++ install/gnuboard5shop.sql | 18 ++ install/install_db.php | 1 + lib/kcp_notification.lib.php | 263 ++++++++++++++++++ lib/migration.lib.php | 2 + migrations/20260910_001_kcp_notification.sql | 23 ++ mobile/shop/orderformupdate.php | 4 + mobile/shop/personalpayformupdate.php | 4 + shop/orderformupdate.php | 4 + shop/personalpayformupdate.php | 4 + shop/settle_kcp_common.php | 271 +------------------ tests/kcp_notification_http.py | 264 ++++++++++++++++++ 12 files changed, 650 insertions(+), 267 deletions(-) create mode 100644 docs/security-kve-2026-2346.md create mode 100644 lib/kcp_notification.lib.php create mode 100644 migrations/20260910_001_kcp_notification.sql create mode 100644 tests/kcp_notification_http.py diff --git a/docs/security-kve-2026-2346.md b/docs/security-kve-2026-2346.md new file mode 100644 index 000000000..bec691151 --- /dev/null +++ b/docs/security-kve-2026-2346.md @@ -0,0 +1,59 @@ +# KVE-2026-2346 KCP 통보 검증 보강 + +KCP 공통 통보의 테스트 모드 발신 검증 누락으로 미결제 주문을 입금 처리할 수 있는 경로를 차단한다. `shop/settle_kcp_common.php`에서 요청을 검증한 뒤 `lib/kcp_notification.lib.php`가 주문·개인결제·장바구니와 처리 이력을 반영한다. + +## 적용 절차 + +1. 소스와 DB를 백업하고 주문·결제 접수를 잠시 중지한다. 변경 파일 반영 후 최고관리자의 **환경설정 → DB업그레이드**에서 `20260910_001_kcp_notification`까지 실행한다. 신규 설치 SQL에도 같은 스키마를 포함했다. 일반 요청에서는 DDL을 실행하지 않는다. 업그레이드 전에는 새 주문 저장 및 KCP 통보가 실패할 수 있다. +2. DB 계정의 `LOCK TABLES`, 관련 테이블의 `SELECT`·`INSERT`·`UPDATE` 권한을 확인한다. 테스트 환경은 운영 주문과 분리한다. +3. 기존 KCP 미결제 가상계좌 주문과 개인결제를 KCP 거래 내역에 대조한다. 새 `od_kcp_site_cd`·`pp_kcp_site_cd`는 기존 행에 빈값으로 추가되며, 빈 상점코드인 거래의 통보는 실패 응답한다. **현재 설정을 전체 과거 거래에 일괄 복사하지 않는다.** 거래번호·주문번호·결제수단·청구액·계좌·테스트 여부·실제 상점코드를 확인한 건만 관리자가 별도 보정하고 KCP 재통보를 요청한다. 이미 입금된 기존 거래에는 새 이력을 임의로 만들지 말고 PG 기록과 대조하여 수동 정산한다. +4. PC·모바일에서 실제 KCP 테스트 가상계좌 발급과 모의 입금을 확인한 뒤 접수를 재개한다. 기존 연동의 통보 발신 IP 및 처리구분이 아래 공식 안내와 다른 경우 KCP에 해당 연동의 값을 확인한다. 검증을 통째로 생략하거나 임의 전달 헤더로 우회하지 않는다. +5. 실패 통보와 `shop_kcp_noti`의 미완료 이력을 점검하고 재통보 제한을 넘긴 건은 KCP 내역과 대조해 복구한다. + +## 입력과 거래 검증 + +- 테스트는 `210.122.176.144`, 운영은 `103.215.144.173`, `103.215.144.174`, `210.122.72.173`의 POST만 받는다. 과거 코드의 다른 IP는 자동 승계하지 않는다. 기준은 [KCP 웹훅 가이드](https://developer.kcp.co.kr/guide/webhook), 확인일은 2026-09-10이다. 이 목록이 구형 계약의 모든 발신지를 포함하는지 KCP에 별도 확인한 상태는 아니다. +- `REMOTE_ADDR`만 사용한다. 리버스 프록시 환경은 신뢰하는 프록시와 원본 IP 복원 설정을 웹 서버에서 제한해야 한다. 요청 헤더·상점코드·관리자 로그인 자체를 발신 인증으로 취급하지 않는다. +- 필수값은 문자열 형식과 길이를 검사하고 배열·빈 거래번호·잘못된 날짜·0원·음수·소수·지수 표기·DB 정수 범위 초과를 거부한다. 가상계좌 입금은 `TX00`, `op_cd=50`, 망취소는 `op_cd=13`으로 구분한다. 다른 처리구분은 실패 응답한다. +- PC·모바일 주문과 개인결제 저장 시 서버의 `$g_conf_site_cd`를 보존한다. 테스트 `T0000`·`T0007`, 운영 `SR` 접두어를 포함한 실제 사용값을 비교하므로 이후 상점 설정 변경으로 과거 거래를 다른 상점에 연결하지 않는다. +- 저장된 PG·상점·거래번호·가상계좌 결제수단·계좌를 모두 대조한다. 연결 주문은 존재해야 하고 테스트 환경도 일치해야 한다. 개인결제와 일반 주문의 번호가 충돌하면 실패한다. +- 일반 입금은 서버 주문 합계에서 쿠폰·포인트 등을 반영한 청구액 전액과 일치하고, 입금액 0·주문 상태여야 한다. 개인결제는 `pp_price`와 대조하며 연결 주문 미수금을 초과할 수 없다. 연결 주문의 원래 PG·결제수단은 개인결제와 다를 수 있으므로 개인결제 자체의 KCP 거래를 검사한다. 연결 주문이 없는 독립 개인결제도 처리한다. +- 기존에 처리하지 않던 `TX01`~`TX07` 이벤트는 상점·거래를 확인하고 수신 확인만 한다. 환불·에스크로 상태 자동화 기능을 새로 추가한 것은 아니다. + +## 중복과 중간 실패 + +이력 키는 상점·거래·주문·통보 ID·입금/망취소 구분을 포함한다. 같은 통보 ID의 입금과 망취소가 각각 기록되며, 이미 완료한 동일 통보는 주문이 완료 상태가 되었어도 성공 응답한다. 같은 통보의 금액·계좌가 바뀌면 거부한다. 활성 입금이 있을 때 다른 통보 ID로 다시 입금할 수 없고, 망취소 후 지연 도착한 입금 통보도 금액을 다시 올리지 않는다. + +MyISAM과 InnoDB 모두 관련 네 테이블의 쓰기 잠금을 잡고 검증·반영한다. 변경 전 값과 목표값을 `kn_plan`에 먼저 기록하고, 개인결제 금액과 연결 주문 입금액을 목표값으로 저장하여 재시도에서 중복 가산하지 않는다. 주문·장바구니·개인결제 반영과 결과 재조회가 끝난 뒤 `kn_done=1`을 기록해야 `result=0000`을 응답한다. 입력 불일치·스키마/권한 누락·SQL 오류·미완료 복구 충돌은 `9999`로 응답한다. + +**MyISAM의 여러 테이블 쓰기가 원자적 트랜잭션이 되는 것은 아니다.** 중간 실패 시 일부 변경이 남을 수 있으며 같은 통보의 재시도로 완료한다. 물리적 서버 장애·디스크 손상에 대한 롤백도 보장하지 않는다. 재시도 시 현재 값이 기록된 변경 전/후 값과 다르면 외부 변경으로 판단하여 덮어쓰지 않는다. 해당 주문의 미완료 통보가 있으면 다른 신규 통보도 막는다. 테이블 전체 잠금을 사용하므로 통보 처리 중 주문·관리자 쿼리가 잠시 대기할 수 있다. + +망취소는 해당 입금액만 차감하고 미수금을 복원한다. 주문/장바구니의 `입금`은 `주문`으로 되돌린다. 이미 `준비`·`배송`·`완료`인 주문은 금액과 미수금을 수정하되 물류 상태를 되돌리지 않는다. 이런 건은 출고 중지·회수·콘텐츠 권한·지급 포인트 등을 운영자가 별도로 대조해야 한다. 이미 취소·반품된 주문 또는 이력 없이 입금액이 남아 있는 기존 거래는 자동 차감하지 않고 실패로 남긴다. + +복구 점검 예시(실제 설치의 테이블 접두어로 바꾼다): + +```sql +SELECT kn_key, kn_trade, kn_noti_id, kn_op_cd, od_id, kn_created_at +FROM g5_shop_kcp_noti WHERE kn_done = 0 ORDER BY kn_created_at; +``` + +미완료 행을 단순 삭제하거나 `kn_done=1`로 바꾸면 중복 가산 또는 미반영 통보 유실이 발생할 수 있다. 주문·개인결제·장바구니의 현재 값과 `kn_plan`, KCP 거래 기록을 대조하고 충돌 원인을 해결한 뒤 동일 통보를 재전송한다. 이력에는 금액·계좌 등 거래 정보가 포함되므로 DB 백업과 접근권한을 기존 주문 데이터와 같이 관리한다. + +## 검증 + +```bash +python3 tests/kcp_notification_http.py 33443 +php bin/check-migration-schema.php +``` + +첫 명령은 **격리 MySQL 전용**이다. 로컬 지정 포트에 root/빈 비밀번호로 접속하여 고유 임시 DB를 만들고 종료 시 삭제한다. PHP CLI·mysqli, MySQL CLI, Python 3이 필요하다. `bin`의 스키마 검사는 로컬 검증 도구로 배포본에 포함되지 않는다. + +테스트는 실제 콜백 파일을 로컬 HTTP로 호출하고 MySQL 8의 MyISAM/InnoDB 테이블을 읽고 쓴다. 공통 초기화와 PG 발신 IP는 테스트 부트스트랩으로 대체한다. 주문·개인결제는 격리 DB에 준비하며 실제 KCP 승인·은행 입금·상점 로그인·전체 주문서 제출을 실행하지 않는다. 각 SQL 쓰기 직전 실패와 DB 반영 후 응답 유실은 테스트 SQL 래퍼에서 주입한다. 정상/거부 응답과 실제 DB 상태, 동시 통보, 중복·망취소·역순, 중간 실패 복구와 외부 수정 충돌, 실제 마이그레이션 적용·재실행을 확인한다. + +2026-09-10 검증 결과: PHP 8.4.22, MySQL 8.0.45에서 HTTP·DB 검사 420개와 PHP 문법 검사, 신규 설치/마이그레이션 스키마 대조가 통과했다. + +## 배포·회신 상태 + +코드 변경 및 격리 회귀 검증 단계다. 신고 대상은 5.6.35이며 검토 기준 5.6.37 개발 코드에도 원인이 있었다. 전체 영향 버전, 수정 배포 버전·배포일은 미확정이다. 실제 KCP 연동 시험, 구형 발신 범위 확인, 운영 반영, KISA 회신 완료를 의미하지 않는다. 원본 PDF는 저장소에 포함하지 않는다. + +규격 참고: [가상계좌 입금·망취소](https://developer.kcp.co.kr/reference/account), [웹훅 응답](https://developer.kcp.co.kr/reference/webhook). 공개 규격에서 확인되지 않은 수신 서명 필드를 임의로 필수화하지 않았다. 인증서·서명을 사용하는 서버 간 거래조회 API의 도입은 사용 중인 계약/연동 지원 확인 후 별도로 검토한다. diff --git a/install/gnuboard5shop.sql b/install/gnuboard5shop.sql index 4fb827d5d..cecaf5579 100644 --- a/install/gnuboard5shop.sql +++ b/install/gnuboard5shop.sql @@ -683,6 +683,7 @@ CREATE TABLE IF NOT EXISTS `g5_shop_order` ( `od_test` tinyint(4) NOT NULL DEFAULT '0', `od_mobile` tinyint(4) NOT NULL DEFAULT '0', `od_pg` varchar(255) NOT NULL DEFAULT '', + `od_kcp_site_cd` varchar(5) NOT NULL DEFAULT '', `od_tno` varchar(255) NOT NULL DEFAULT '', `od_app_no` varchar(20) NOT NULL DEFAULT '', `od_escrow` tinyint(4) NOT NULL DEFAULT '0', @@ -781,6 +782,7 @@ CREATE TABLE IF NOT EXISTS `g5_shop_personalpay` ( `pp_use` TINYINT(4) NOT NULL DEFAULT '0', `pp_price` INT(11) NOT NULL DEFAULT '0', `pp_pg` varchar(255) NOT NULL DEFAULT '', + `pp_kcp_site_cd` varchar(5) NOT NULL DEFAULT '', `pp_tno` VARCHAR(255) NOT NULL DEFAULT '', `pp_app_no` VARCHAR(20) NOT NULL DEFAULT '', `pp_casseqno` VARCHAR(255) NOT NULL DEFAULT '', @@ -1037,3 +1039,19 @@ CREATE TABLE IF NOT EXISTS `g5_shop_inicis_pay_event` ( KEY `pe_status` (`pe_status`), KEY `pe_created_at` (`pe_created_at`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8; + +-- KCP 통보 처리 및 중간 실패 복구 이력 +CREATE TABLE `g5_shop_kcp_noti` ( + `kn_key` char(64) NOT NULL, + `kn_trade` char(64) NOT NULL, + `kn_noti_id` varchar(20) NOT NULL, + `kn_op_cd` char(2) NOT NULL, + `kn_payload` char(64) NOT NULL, + `od_id` bigint(20) unsigned NOT NULL DEFAULT '0', + `kn_plan` mediumtext NOT NULL, + `kn_done` tinyint(4) NOT NULL DEFAULT '0', + `kn_created_at` datetime NOT NULL, + PRIMARY KEY (`kn_key`), + KEY `kn_trade` (`kn_trade`), + KEY `od_id` (`od_id`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8; diff --git a/install/install_db.php b/install/install_db.php index 2bba97a96..5a2ce5058 100644 --- a/install/install_db.php +++ b/install/install_db.php @@ -655,6 +655,7 @@ if($g5_shop_install) { install_file_write($f, "\$g5['g5_shop_coupon_log_table'] = G5_SHOP_TABLE_PREFIX.'coupon_log'; // 쿠폰사용정보 테이블\n"); install_file_write($f, "\$g5['g5_shop_sendcost_table'] = G5_SHOP_TABLE_PREFIX.'sendcost'; // 추가배송비 테이블\n"); install_file_write($f, "\$g5['g5_shop_personalpay_table'] = G5_SHOP_TABLE_PREFIX.'personalpay'; // 개인결제 정보 테이블\n"); + install_file_write($f, "\$g5['g5_shop_kcp_noti_table'] = G5_SHOP_TABLE_PREFIX.'kcp_noti'; // KCP 통보 처리 이력 테이블\n"); install_file_write($f, "\$g5['g5_shop_order_address_table'] = G5_SHOP_TABLE_PREFIX.'order_address'; // 배송지이력 정보 테이블\n"); install_file_write($f, "\$g5['g5_shop_item_stocksms_table'] = G5_SHOP_TABLE_PREFIX.'item_stocksms'; // 재입고SMS 알림 정보 테이블\n"); install_file_write($f, "\$g5['g5_shop_post_log_table'] = G5_SHOP_TABLE_PREFIX.'order_post_log'; // 주문요청 로그 테이블\n"); diff --git a/lib/kcp_notification.lib.php b/lib/kcp_notification.lib.php new file mode 100644 index 000000000..d0a2602cc --- /dev/null +++ b/lib/kcp_notification.lib.php @@ -0,0 +1,263 @@ + '/\A[A-Z0-9]{5}\z/', 'tno' => '/\A[0-9]{14}\z/', + 'order_no' => '/\A[1-9][0-9]{0,19}\z/', 'tx_cd' => '/\ATX0[0-7]\z/', + 'tx_tm' => '/\A[0-9]{14}\z/' + ); + if (isset($post['tx_cd']) && $post['tx_cd'] === 'TX00') { + $patterns += array('ipgm_mnyx' => '/\A[0-9]{1,12}\z/', + 'account' => '/\A[T]?[0-9]{1,19}\z/', 'noti_id' => '/\A[0-9]{20}\z/', + 'op_cd' => '/\A(?:50|13)\z/'); + } + $data = array(); + foreach ($patterns as $name => $pattern) { + if (!isset($post[$name]) || !is_string($post[$name]) || !preg_match($pattern, $post[$name])) return false; + $data[$name] = $post[$name]; + } + $time = $data['tx_tm']; + if ((int)substr($time, 0, 4) < 1000 || !checkdate((int)substr($time, 4, 2), (int)substr($time, 6, 2), (int)substr($time, 0, 4)) + || substr($time, 8, 2) > 23 || substr($time, 10, 2) > 59 || substr($time, 12, 2) > 59) return false; + $test = in_array($data['site_cd'], array('T0000', 'T0007'), true); + if ($test !== !empty($default['de_card_test'])) return false; + if (!$test && substr($data['site_cd'], 0, 2) !== 'SR') return false; + if ($data['tx_cd'] === 'TX00') { + // 주문 금액 컬럼은 signed INT이다. 변환 전에 범위를 검사한다. + if ((float)$data['ipgm_mnyx'] < 1 || (float)$data['ipgm_mnyx'] > 2147483647) return false; + $data['ipgm_mnyx'] = (string)(int)$data['ipgm_mnyx']; + } + return $data; +} + +function kcp_noti_query($sql) +{ + $result = sql_query($sql, false); + if (!$result) throw new Exception('KCP notification database failure'); + return $result; +} + +function kcp_noti_rows($sql) +{ + $result = kcp_noti_query($sql); + $rows = array(); + while ($row = sql_fetch_array($result)) $rows[] = $row; + return $rows; +} + +function kcp_noti_quote($value) +{ + return "'".sql_escape_string((string)$value)."'"; +} + +function kcp_noti_tables() +{ + global $g5; + return array('order' => $g5['g5_shop_order_table'], 'personal' => $g5['g5_shop_personalpay_table'], + 'cart' => $g5['g5_shop_cart_table'], + 'event' => isset($g5['g5_shop_kcp_noti_table']) ? $g5['g5_shop_kcp_noti_table'] : G5_SHOP_TABLE_PREFIX.'kcp_noti'); +} + +function kcp_noti_process($data) +{ + $tables = kcp_noti_tables(); + $locked = false; + try { + // 기본 설치의 MyISAM에서도 다른 통보 및 관리자 UPDATE와 경쟁하지 않게 한다. + // DDL은 관리자 DB 업그레이드에서만 실행하며 스키마/권한이 없으면 실패 응답한다. + $locks = array(); + foreach ($tables as $table) $locks[] = '`'.$table.'` WRITE'; + kcp_noti_query('LOCK TABLES '.implode(', ', $locks)); + $locked = true; + $success = kcp_noti_locked($data, $tables); + } catch (Exception $e) { + error_log('KCP notification: processing failed; reconciliation/retry required'); + $success = false; + } catch (Throwable $e) { + error_log('KCP notification: processing failed; reconciliation/retry required'); + $success = false; + } + if ($locked) { + try { + kcp_noti_query('UNLOCK TABLES'); + } catch (Exception $e) { + $success = false; + } catch (Throwable $e) { + $success = false; + } + } + return $success; +} + +// 변경 전/후 값을 함께 기록한다. 재시도는 가산 SQL 대신 목표값을 대입한다. +function kcp_noti_step($table, $id_field, $row, $changes, $guards) +{ + $before = array(); + foreach (array_unique(array_merge(array($id_field), array_keys($changes), $guards)) as $field) { + if (!array_key_exists($field, $row)) throw new Exception('Missing notification schema'); + $before[$field] = (string)$row[$field]; + } + $after = $before; + foreach ($changes as $field => $value) $after[$field] = (string)$value; + return array('table' => $table, 'id_field' => $id_field, 'id' => (string)$row[$id_field], + 'before' => $before, 'after' => $after); +} + +function kcp_noti_matches($row, $expected) +{ + foreach ($expected as $field => $value) { + if (!array_key_exists($field, $row) || (string)$row[$field] !== $value) return false; + } + return true; +} + +function kcp_noti_apply($plan, $tables) +{ + // 먼저 모든 행을 검사한다. 실패 후 외부에서 수정한 행을 과거 값으로 덮어쓰지 않는다. + foreach ($plan as $step) { + $rows = kcp_noti_rows('SELECT * FROM `'.$tables[$step['table']].'` WHERE `'.$step['id_field'].'` = '.kcp_noti_quote($step['id'])); + if (count($rows) !== 1 || (!kcp_noti_matches($rows[0], $step['before']) && !kcp_noti_matches($rows[0], $step['after']))) return false; + } + foreach ($plan as $step) { + $sets = array(); + foreach ($step['after'] as $field => $value) { + if ($value !== $step['before'][$field]) $sets[] = '`'.$field.'` = '.kcp_noti_quote($value); + } + if ($sets) kcp_noti_query('UPDATE `'.$tables[$step['table']].'` SET '.implode(', ', $sets) + .' WHERE `'.$step['id_field'].'` = '.kcp_noti_quote($step['id'])); + } + foreach ($plan as $step) { + $rows = kcp_noti_rows('SELECT * FROM `'.$tables[$step['table']].'` WHERE `'.$step['id_field'].'` = '.kcp_noti_quote($step['id'])); + if (count($rows) !== 1 || !kcp_noti_matches($rows[0], $step['after'])) return false; + } + return true; +} + +function kcp_noti_locked($data, $tables) +{ + $order_no = kcp_noti_quote($data['order_no']); + $personal = kcp_noti_rows("SELECT * FROM `{$tables['personal']}` WHERE pp_id = $order_no"); + $orders = kcp_noti_rows("SELECT * FROM `{$tables['order']}` WHERE od_id = $order_no"); + // 동일 번호가 두 종류에 존재하면 임의로 한쪽을 선택하지 않는다. + if ($personal && $orders) return false; + $pp = $personal ? $personal[0] : null; + if ($pp) { + $prefix = 'pp'; $payment = $pp; + $orders = $pp['od_id'] ? kcp_noti_rows("SELECT * FROM `{$tables['order']}` WHERE od_id = ".kcp_noti_quote($pp['od_id'])) : array(); + if ($pp['od_id'] && !$orders) return false; + } else { + if (!$orders) return false; + $prefix = 'od'; $payment = $orders[0]; + } + $od = $orders ? $orders[0] : null; + if ($payment[$prefix.'_pg'] !== 'kcp' || $payment[$prefix.'_tno'] !== $data['tno'] + || empty($payment[$prefix.'_kcp_site_cd']) || $payment[$prefix.'_kcp_site_cd'] !== $data['site_cd']) return false; + // 처리하지 않는 기존 에스크로 이벤트는 거래를 확인한 뒤 수신만 확인한다. + if ($data['tx_cd'] !== 'TX00') return true; + if ($payment[$prefix.'_settle_case'] !== '가상계좌') return false; + if ($od && (bool)$od['od_test'] !== in_array($data['site_cd'], array('T0000', 'T0007'), true)) return false; + $account_parts = preg_split('/\s+/', trim($payment[$prefix.'_bank_account'])); + if (end($account_parts) !== $data['account']) return false; + + $trade = hash('sha256', $data['site_cd'].'|'.$data['tno'].'|'.$data['order_no']); + $key = hash('sha256', $trade.'|'.$data['noti_id'].'|'.$data['op_cd']); + $payload = hash('sha256', $data['ipgm_mnyx'].'|'.$data['account']); + $events = kcp_noti_rows("SELECT * FROM `{$tables['event']}` WHERE kn_trade = '$trade'"); + $deposit = null; $cancel = null; $active = false; + $by_id = array(); + foreach ($events as $event) { + if ($event['kn_key'] === $key) { + if ($event['kn_payload'] !== $payload) return false; + if ($event['kn_done']) return true; + $plan = json_decode($event['kn_plan'], true); + if (!is_array($plan) || !kcp_noti_apply($plan, $tables)) return false; + kcp_noti_query("UPDATE `{$tables['event']}` SET kn_done = 1 WHERE kn_key = '$key'"); + return true; + } + if (!$event['kn_done']) return false; + $by_id[$event['kn_noti_id']][$event['kn_op_cd']] = $event; + if ($event['kn_noti_id'] === $data['noti_id']) { + if ($event['kn_payload'] !== $payload) return false; + if ($event['kn_op_cd'] === '50') $deposit = $event; + else $cancel = $event; + } + } + foreach ($by_id as $pair) { + if (isset($pair['50']) && !isset($pair['13'])) $active = true; + } + // 망취소가 먼저 도착했거나 취소 뒤 입금 통보가 재전송되어도 재입금하지 않는다. + if ($data['op_cd'] === '50' && $cancel) return true; + $canceling = $data['op_cd'] === '13'; + if (!$canceling && $active) return false; + if ($canceling && !$deposit && $active) return false; + if ($pp && !$pp['pp_use']) return false; + $amount = (int)$data['ipgm_mnyx']; + $receipt = (int)$payment[$prefix.'_receipt_price']; + if ($pp && ((int)$pp['pp_price'] !== $amount || (int)$pp['pp_price'] <= 0)) return false; + if (!$canceling && $receipt !== 0) return false; + if ($canceling && $deposit && $receipt < $amount) return false; + if ($canceling && !$deposit && $receipt !== 0) return false; + + $od_id = $od ? $od['od_id'] : '0'; + if ($od) { + $pending = kcp_noti_rows("SELECT kn_key FROM `{$tables['event']}` WHERE od_id = ".kcp_noti_quote($od_id)." AND kn_done = 0"); + if ($pending) return false; + $due = (int)$od['od_cart_price'] + (int)$od['od_send_cost'] + (int)$od['od_send_cost2'] + - (int)$od['od_cart_coupon'] - (int)$od['od_coupon'] - (int)$od['od_send_coupon'] + - (int)$od['od_cancel_price'] - (int)$od['od_receipt_point'] + (int)$od['od_refund_price']; + $misu = $due - (int)$od['od_receipt_price']; + if ($misu !== (int)$od['od_misu'] || $due <= 0) return false; + if (!$pp && ($due !== $amount || (int)$od['od_cancel_price'] !== 0 || (int)$od['od_refund_price'] !== 0)) return false; + if (!$canceling && ($od['od_status'] !== '주문' || $misu < $amount)) return false; + if ($canceling && !in_array($od['od_status'], array('주문', '입금', '준비', '배송', '완료'), true)) return false; + if ($canceling && $deposit && (int)$od['od_receipt_price'] < $amount) return false; + } + $time = substr($data['tx_tm'], 0, 4).'-'.substr($data['tx_tm'], 4, 2).'-'.substr($data['tx_tm'], 6, 2) + .' '.substr($data['tx_tm'], 8, 2).':'.substr($data['tx_tm'], 10, 2).':'.substr($data['tx_tm'], 12, 2); + $delta = $canceling ? ($deposit ? -$amount : 0) : $amount; + $plan = array(); + if ($pp && $delta) { + $plan[] = kcp_noti_step('personal', 'pp_id', $pp, + array('pp_receipt_price' => $receipt + $delta, 'pp_receipt_time' => $time), + array('od_id', 'pp_pg', 'pp_tno', 'pp_kcp_site_cd', 'pp_settle_case', 'pp_price', 'pp_use', 'pp_bank_account')); + } + if ($od && $delta) { + $new_receipt = (int)$od['od_receipt_price'] + $delta; + $new_misu = $due - $new_receipt; + if ($new_receipt < 0 || $new_receipt > 2147483647 || $new_misu > 2147483647) return false; + $status = $od['od_status']; + if (!$canceling && $new_misu === 0) $status = '입금'; + if ($canceling && $status === '입금') $status = '주문'; + $cart = kcp_noti_rows("SELECT ct_id, od_id, ct_status FROM `{$tables['cart']}` WHERE od_id = ".kcp_noti_quote($od_id)); + if (!$cart) return false; + foreach ($cart as $item) { + if (!$canceling && $item['ct_status'] !== '주문') return false; + if ($status !== $od['od_status'] && $item['ct_status'] === $od['od_status']) { + $plan[] = kcp_noti_step('cart', 'ct_id', $item, array('ct_status' => $status), array('od_id')); + } + } + // 마지막에 주문 상태를 변경하여 중간 실패 시 출고 판정 노출을 줄인다. + $plan[] = kcp_noti_step('order', 'od_id', $od, + array('od_receipt_price' => $new_receipt, 'od_receipt_time' => $time, 'od_misu' => $new_misu, 'od_status' => $status), + array('od_pg', 'od_tno', 'od_kcp_site_cd', 'od_settle_case', 'od_test', 'od_cart_price', 'od_send_cost', + 'od_send_cost2', 'od_cart_coupon', 'od_coupon', 'od_send_coupon', 'od_cancel_price', 'od_receipt_point', 'od_refund_price')); + } + $json = json_encode($plan); + if ($json === false) return false; + kcp_noti_query("INSERT INTO `{$tables['event']}` SET kn_key = '$key', kn_trade = '$trade', kn_noti_id = ".kcp_noti_quote($data['noti_id']) + .", kn_op_cd = ".kcp_noti_quote($data['op_cd']).", kn_payload = '$payload', od_id = ".kcp_noti_quote($od_id) + .", kn_plan = ".kcp_noti_quote($json).", kn_created_at = ".kcp_noti_quote($time)); + if (!kcp_noti_apply($plan, $tables)) return false; + kcp_noti_query("UPDATE `{$tables['event']}` SET kn_done = 1 WHERE kn_key = '$key'"); + return true; +} diff --git a/lib/migration.lib.php b/lib/migration.lib.php index f0d3994bc..c8f52a1f3 100644 --- a/lib/migration.lib.php +++ b/lib/migration.lib.php @@ -34,6 +34,8 @@ function g5_migration_replace_placeholders($sql) 'g5_shop_cart_table' => $shop_prefix . 'cart', 'g5_shop_item_table' => $shop_prefix . 'item', 'g5_shop_order_table' => $shop_prefix . 'order', + 'g5_shop_personalpay_table' => $shop_prefix . 'personalpay', + 'g5_shop_kcp_noti_table' => $shop_prefix . 'kcp_noti', 'g5_shop_default_table' => $shop_prefix . 'default', 'g5_shop_coupon_table' => $shop_prefix . 'coupon', 'g5_shop_coupon_log_table' => $shop_prefix . 'coupon_log', diff --git a/migrations/20260910_001_kcp_notification.sql b/migrations/20260910_001_kcp_notification.sql new file mode 100644 index 000000000..291167fcf --- /dev/null +++ b/migrations/20260910_001_kcp_notification.sql @@ -0,0 +1,23 @@ +-- @description KCP 통보 거래 바인딩 및 재통보 복구 이력 추가 +-- @if-table-exists {{g5_shop_order_table}} +-- @if-column-missing {{g5_shop_order_table}} od_kcp_site_cd +ALTER TABLE `{{g5_shop_order_table}}` ADD `od_kcp_site_cd` varchar(5) NOT NULL DEFAULT ''; +-- @if-table-exists {{g5_shop_personalpay_table}} +-- @if-column-missing {{g5_shop_personalpay_table}} pp_kcp_site_cd +ALTER TABLE `{{g5_shop_personalpay_table}}` ADD `pp_kcp_site_cd` varchar(5) NOT NULL DEFAULT ''; +-- @if-table-exists {{g5_shop_default_table}} +-- @if-table-missing {{g5_shop_kcp_noti_table}} +CREATE TABLE `{{g5_shop_kcp_noti_table}}` ( + `kn_key` char(64) NOT NULL, + `kn_trade` char(64) NOT NULL, + `kn_noti_id` varchar(20) NOT NULL, + `kn_op_cd` char(2) NOT NULL, + `kn_payload` char(64) NOT NULL, + `od_id` bigint(20) unsigned NOT NULL DEFAULT '0', + `kn_plan` mediumtext NOT NULL, + `kn_done` tinyint(4) NOT NULL DEFAULT '0', + `kn_created_at` datetime NOT NULL, + PRIMARY KEY (`kn_key`), + KEY `kn_trade` (`kn_trade`), + KEY `od_id` (`od_id`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8; diff --git a/mobile/shop/orderformupdate.php b/mobile/shop/orderformupdate.php index 77ebc65c6..8e77c1488 100644 --- a/mobile/shop/orderformupdate.php +++ b/mobile/shop/orderformupdate.php @@ -574,6 +574,9 @@ else } $od_pg = $default['de_pg_service']; +// KCP 통보는 결제 당시 사용한 상점코드와 대조한다. +$od_kcp_site_cd = ($od_pg === 'kcp' && isset($g_conf_site_cd)) + ? sql_escape_string($g_conf_site_cd) : ''; // 주문금액과 결제금액이 일치하는지 체크 if($tno) { @@ -676,6 +679,7 @@ $sql = " insert {$g5['g5_shop_order_table']} od_receipt_time = '$od_receipt_time', od_misu = '$od_misu', od_pg = '$od_pg', + od_kcp_site_cd = '$od_kcp_site_cd', od_tno = '$od_tno', od_app_no = '$od_app_no', od_escrow = '$od_escrow', diff --git a/mobile/shop/personalpayformupdate.php b/mobile/shop/personalpayformupdate.php index 45d39eff5..908dad56e 100644 --- a/mobile/shop/personalpayformupdate.php +++ b/mobile/shop/personalpayformupdate.php @@ -213,10 +213,14 @@ if((int)$pp['pp_price'] !== (int)$pg_price) { } $pp_pg = $default['de_pg_service']; +// KCP 통보는 결제 당시 사용한 상점코드와 대조한다. +$pp_kcp_site_cd = ($pp_pg === 'kcp' && isset($g_conf_site_cd)) + ? sql_escape_string($g_conf_site_cd) : ''; // 결제정보 입력 $sql = " update {$g5['g5_shop_personalpay_table']} set pp_pg = '$pp_pg', + pp_kcp_site_cd = '$pp_kcp_site_cd', pp_tno = '$pp_tno', pp_app_no = '$app_no', pp_receipt_price = '$pp_receipt_price', diff --git a/shop/orderformupdate.php b/shop/orderformupdate.php index 7a71b9274..51c2b8dd1 100644 --- a/shop/orderformupdate.php +++ b/shop/orderformupdate.php @@ -531,6 +531,9 @@ else } $od_pg = $default['de_pg_service']; +// KCP 통보는 결제 당시 사용한 상점코드와 대조한다. +$od_kcp_site_cd = ($od_pg === 'kcp' && isset($g_conf_site_cd)) + ? sql_escape_string($g_conf_site_cd) : ''; $tno = isset($tno) ? $tno : ''; $od_receipt_time = isset($od_receipt_time) ? $od_receipt_time : ''; @@ -630,6 +633,7 @@ $sql = " insert {$g5['g5_shop_order_table']} od_receipt_time = '$od_receipt_time', od_misu = '$od_misu', od_pg = '$od_pg', + od_kcp_site_cd = '$od_kcp_site_cd', od_tno = '$od_tno', od_app_no = '$od_app_no', od_escrow = '$od_escrow', diff --git a/shop/personalpayformupdate.php b/shop/personalpayformupdate.php index fe4183ea5..29a30e178 100644 --- a/shop/personalpayformupdate.php +++ b/shop/personalpayformupdate.php @@ -192,6 +192,9 @@ if((int)$pp['pp_price'] !== (int)$pg_price) { } $pp_pg = $default['de_pg_service']; +// KCP 통보는 결제 당시 사용한 상점코드와 대조한다. +$pp_kcp_site_cd = ($pp_pg === 'kcp' && isset($g_conf_site_cd)) + ? sql_escape_string($g_conf_site_cd) : ''; $pp_email = get_email_address($pp_email); // 결제정보 입력 @@ -199,6 +202,7 @@ $sql = " update {$g5['g5_shop_personalpay_table']} set pp_email = '$pp_email', pp_hp = '$pp_hp', pp_pg = '$pp_pg', + pp_kcp_site_cd = '$pp_kcp_site_cd', pp_tno = '$pp_tno', pp_app_no = '$app_no', pp_receipt_price = '$pp_receipt_price', diff --git a/shop/settle_kcp_common.php b/shop/settle_kcp_common.php index 523a716dd..1763d3e20 100644 --- a/shop/settle_kcp_common.php +++ b/shop/settle_kcp_common.php @@ -1,271 +1,8 @@ - -
\ No newline at end of file +
diff --git a/tests/kcp_notification_http.py b/tests/kcp_notification_http.py new file mode 100644 index 000000000..d9b3328ef --- /dev/null +++ b/tests/kcp_notification_http.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""격리 MySQL에서 KCP 실제 콜백과 MyISAM/InnoDB 재통보를 검증한다. +실행: python3 tests/kcp_notification_http.py 33443 +localhost의 지정 포트에 root/빈 비밀번호로 접속하여 임시 DB만 만들고 삭제한다. +공통 초기화·발신 IP·SQL 실패만 대역이며 SQL 저장과 실제 콜백은 그대로 실행한다. +""" +import concurrent.futures +import json +import os +from pathlib import Path +import re +import signal +import socket +import subprocess +import sys +import tempfile +import time +import urllib.parse +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +PORT = int(sys.argv[1]) +DB = 'kcp43_test_' + str(time.time_ns()) +checks = 0 + +def mysql(sql, database=True): + command = ['mysql', '--no-defaults', '-h127.0.0.1', '-P' + str(PORT), '-uroot', '-N', '-B'] + if database: + command.append(DB) + return subprocess.check_output(command, input="SET sql_mode='NO_ENGINE_SUBSTITUTION';" + sql, text=True).strip() + +def check(value, message): + global checks + if not value: + raise AssertionError(message) + checks += 1 + +mysql('CREATE DATABASE `' + DB + '`', False) +server = None +try: + schema = (ROOT / 'install/gnuboard5shop.sql').read_text() + for table in ['order', 'personalpay', 'cart', 'default', 'kcp_noti']: + statement = re.search(r'CREATE TABLE(?: IF NOT EXISTS)? `g5_shop_' + table + r'` \(.*?;', schema, re.S).group() + mysql(statement) + with tempfile.TemporaryDirectory(prefix='kcp43-http-') as temp: + directory = Path(temp) + fixture = directory / 'fixture.json' + bootstrap = r'''set_charset('utf8mb4'); +$db->query("SET sql_mode='NO_ENGINE_SUBSTITUTION'"); +$fixture = json_decode(file_get_contents(__DIR__.'/fixture.json'), true); +$default = array('de_card_test' => $fixture['test'], 'de_pg_service' => 'kcp'); +if (isset($fixture['ip'])) $_SERVER['REMOTE_ADDR'] = $fixture['ip']; +$g5 = array('g5_shop_order_table' => 'g5_shop_order', 'g5_shop_personalpay_table' => 'g5_shop_personalpay', + 'g5_shop_cart_table' => 'g5_shop_cart', 'g5_shop_default_table' => 'g5_shop_default'); +$writes = 0; +function sql_query($sql, $error = false) { + global $db, $writes, $fixture; + $write = preg_match('/^(UPDATE|INSERT) /', $sql); + if ($write) $writes++; + if ($write && isset($fixture['fail_before']) && $writes === $fixture['fail_before']) return false; + $result = $db->query($sql); + if ($write && isset($fixture['fail_after']) && $writes === $fixture['fail_after']) return false; + return $result; +} +function sql_fetch_array($result) { return $result->fetch_assoc(); } +function sql_num_rows($result) { return $result->num_rows; } +function sql_escape_string($value) { global $db; return $db->real_escape_string($value); } +function sql_real_escape_string($value) { return sql_escape_string($value); } +function sql_error_info() { global $db; return $db->error; } +'''.replace('ROOT_PATH', repr(str(ROOT))).replace('TEST_DB', repr(DB)).replace('TEST_PORT', str(PORT)) + (directory / '_common.php').write_text(bootstrap) + (directory / 'router.php').write_text("