security: KVE-2026-2344 관리자 날짜 필터 검증과 속성 인코딩으로 XSS 차단

보관함과 상품판매순위에서 날짜 전체 형식·실제 달력 날짜·기간 순서를 검증하고 잘못된 요청을 조회 전에 HTTP 400으로 거부한다. 날짜 출력과 조회 링크를 각각 인코딩하고 최초 조회·분류·정렬·페이징을 유지한다.

검증: 변경 PHP 문법 검사, 100개 잘못된 날짜 입력과 정상 조회·윤년·페이징 회귀 검사, Chromium 속성 주입 및 이벤트 차단 검사 통과. 실제 관리자 세션과 운영 DB 검증 및 배포는 미실행.
This commit is contained in:
whitedot
2026-09-09 07:58:16 +00:00
parent ded3349bfc
commit ff8e8741b3
6 changed files with 277 additions and 14 deletions
+26
View File
@@ -0,0 +1,26 @@
<?php
if (!defined('_GNUBOARD_')) exit;
// 날짜 없는 최초 조회와 잘못된 날짜 조회를 구분한다. 오류는 false로 반환한다.
function shop_admin_date_range($params, $default_to = '')
{
if (!array_key_exists('fr_date', $params) && !array_key_exists('to_date', $params)) {
return array('', $default_to);
}
foreach (array('fr_date', 'to_date') as $key) {
if (!isset($params[$key]) || !is_string($params[$key]) || !preg_match('/\A[0-9]{8}\z/', $params[$key])) {
return false;
}
$date = $params[$key];
if (!checkdate((int) substr($date, 4, 2), (int) substr($date, 6, 2), (int) substr($date, 0, 4))) {
return false;
}
}
if ($params['fr_date'] > $params['to_date']) {
return false;
}
return array($params['fr_date'], $params['to_date']);
}
+19 -7
View File
@@ -4,13 +4,18 @@ include_once('./_common.php');
auth_check_menu($auth, $sub_menu, "r"); auth_check_menu($auth, $sub_menu, "r");
include_once('./date_filter.lib.php');
$date_range = shop_admin_date_range($_GET, date('Ymd'));
if ($date_range === false) {
http_response_code(400);
alert('조회 기간은 올바른 시작일과 종료일을 YYYYMMDD 형식으로 입력해 주십시오. 시작일은 종료일보다 늦을 수 없습니다.');
}
list($fr_date, $to_date) = $date_range;
$g5['title'] = '상품판매순위'; $g5['title'] = '상품판매순위';
include_once (G5_ADMIN_PATH.'/admin.head.php'); include_once (G5_ADMIN_PATH.'/admin.head.php');
include_once(G5_PLUGIN_PATH.'/jquery-ui/datepicker.php'); include_once(G5_PLUGIN_PATH.'/jquery-ui/datepicker.php');
$fr_date = (isset($_GET['fr_date']) && preg_match("/[0-9]/", $_GET['fr_date'])) ? $_GET['fr_date'] : '';
$to_date = (isset($_GET['to_date']) && preg_match("/[0-9]/", $_GET['to_date'])) ? $_GET['to_date'] : date("Ymd", time());
$doc = isset($_GET['doc']) ? clean_xss_tags($_GET['doc'], 1, 1) : ''; $doc = isset($_GET['doc']) ? clean_xss_tags($_GET['doc'], 1, 1) : '';
$sort1 = (isset($_GET['sort1']) && in_array($_GET['sort1'], array('ct_status_1', 'ct_status_2', 'ct_status_3', 'ct_status_4', 'ct_status_5', 'ct_status_6', 'ct_status_7', 'ct_status_8', 'ct_status_9', 'ct_status_sum'))) ? $_GET['sort1'] : 'ct_status_sum'; $sort1 = (isset($_GET['sort1']) && in_array($_GET['sort1'], array('ct_status_1', 'ct_status_2', 'ct_status_3', 'ct_status_4', 'ct_status_5', 'ct_status_6', 'ct_status_7', 'ct_status_8', 'ct_status_9', 'ct_status_sum'))) ? $_GET['sort1'] : 'ct_status_sum';
$sort2 = (isset($_GET['sort2']) && in_array($_GET['sort2'], array('desc', 'asc'))) ? $_GET['sort2'] : 'desc'; $sort2 = (isset($_GET['sort2']) && in_array($_GET['sort2'], array('desc', 'asc'))) ? $_GET['sort2'] : 'desc';
@@ -57,7 +62,14 @@ $sql = $sql . " limit $from_record, $rows ";
$result = sql_query($sql); $result = sql_query($sql);
//$qstr = 'page='.$page.'&amp;sort1='.$sort1.'&amp;sort2='.$sort2; //$qstr = 'page='.$page.'&amp;sort1='.$sort1.'&amp;sort2='.$sort2;
$qstr1 = $qstr.'&amp;fr_date='.$fr_date.'&amp;to_date='.$to_date.'&amp;sel_ca_id='.$sel_ca_id; // 검증된 날짜만 URL에 전달한다. 최초 조회의 빈 날짜는 링크에서 생략한다.
$filter_query = array('sel_ca_id' => $sel_ca_id);
if ($fr_date !== '' && $to_date !== '') {
$filter_query['fr_date'] = $fr_date;
$filter_query['to_date'] = $to_date;
}
$qstr1 = htmlspecialchars(http_build_query($filter_query, '', '&'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$paging_query = htmlspecialchars(http_build_query(array_merge($filter_query, array('sort1' => $sort1, 'sort2' => $sort2)), '', '&'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$listall = '<a href="'.$_SERVER['SCRIPT_NAME'].'" class="ov_listall">전체목록</a>'; $listall = '<a href="'.$_SERVER['SCRIPT_NAME'].'" class="ov_listall">전체목록</a>';
?> ?>
@@ -92,9 +104,9 @@ $listall = '<a href="'.$_SERVER['SCRIPT_NAME'].'" class="ov_listall">전체목
기간설정 기간설정
<label for="fr_date" class="sound_only">시작일</label> <label for="fr_date" class="sound_only">시작일</label>
<input type="text" name="fr_date" value="<?php echo $fr_date; ?>" id="fr_date" required class="required frm_input" size="8" maxlength="8"> 에서 <input type="text" name="fr_date" value="<?php echo htmlspecialchars($fr_date, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>" id="fr_date" required class="required frm_input" size="8" maxlength="8"> 에서
<label for="to_date" class="sound_only">종료일</label> <label for="to_date" class="sound_only">종료일</label>
<input type="text" name="to_date" value="<?php echo $to_date; ?>" id="to_date" required class="required frm_input" size="8" maxlength="8"> 까지 <input type="text" name="to_date" value="<?php echo htmlspecialchars($to_date, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>" id="to_date" required class="required frm_input" size="8" maxlength="8"> 까지
<input type="submit" value="검색" class="btn_submit"> <input type="submit" value="검색" class="btn_submit">
</form> </form>
@@ -162,7 +174,7 @@ $listall = '<a href="'.$_SERVER['SCRIPT_NAME'].'" class="ov_listall">전체목
</table> </table>
</div> </div>
<?php echo get_paging(G5_IS_MOBILE ? $config['cf_mobile_pages'] : $config['cf_write_pages'], $page, $total_page, "{$_SERVER['SCRIPT_NAME']}?$qstr1&amp;page="); ?> <?php echo get_paging(G5_IS_MOBILE ? $config['cf_mobile_pages'] : $config['cf_write_pages'], $page, $total_page, "{$_SERVER['SCRIPT_NAME']}?$paging_query&amp;page="); ?>
<script> <script>
$(function() { $(function() {
+19 -7
View File
@@ -4,13 +4,18 @@ include_once('./_common.php');
auth_check_menu($auth, $sub_menu, "r"); auth_check_menu($auth, $sub_menu, "r");
include_once('./date_filter.lib.php');
$date_range = shop_admin_date_range($_GET);
if ($date_range === false) {
http_response_code(400);
alert('조회 기간은 올바른 시작일과 종료일을 YYYYMMDD 형식으로 입력해 주십시오. 시작일은 종료일보다 늦을 수 없습니다.');
}
list($fr_date, $to_date) = $date_range;
$g5['title'] = '보관함현황'; $g5['title'] = '보관함현황';
include_once (G5_ADMIN_PATH.'/admin.head.php'); include_once (G5_ADMIN_PATH.'/admin.head.php');
include_once(G5_PLUGIN_PATH.'/jquery-ui/datepicker.php'); include_once(G5_PLUGIN_PATH.'/jquery-ui/datepicker.php');
$fr_date = (isset($_GET['fr_date']) && preg_match("/[0-9]/", $_GET['fr_date'])) ? $_GET['fr_date'] : '';
$to_date = (isset($_GET['to_date']) && preg_match("/[0-9]/", $_GET['to_date'])) ? $_GET['to_date'] : '';
$doc = isset($_GET['doc']) ? clean_xss_tags($_GET['doc'], 1, 1) : ''; $doc = isset($_GET['doc']) ? clean_xss_tags($_GET['doc'], 1, 1) : '';
$sort1 = (isset($_GET['sort1']) && in_array($_GET['sort1'], array('mb_id', 'it_id', 'wi_time', 'wi_ip'))) ? $_GET['sort1'] : 'it_id_cnt'; $sort1 = (isset($_GET['sort1']) && in_array($_GET['sort1'], array('mb_id', 'it_id', 'wi_time', 'wi_ip'))) ? $_GET['sort1'] : 'it_id_cnt';
$sort2 = (isset($_GET['sort2']) && in_array($_GET['sort2'], array('desc', 'asc'))) ? $_GET['sort2'] : 'desc'; $sort2 = (isset($_GET['sort2']) && in_array($_GET['sort2'], array('desc', 'asc'))) ? $_GET['sort2'] : 'desc';
@@ -47,7 +52,14 @@ $rank = ($page - 1) * $rows;
$sql = $sql . " limit $from_record, $rows "; $sql = $sql . " limit $from_record, $rows ";
$result = sql_query($sql); $result = sql_query($sql);
$qstr1 = $qstr.'&amp;fr_date='.$fr_date.'&amp;to_date='.$to_date.'&amp;sel_ca_id='.$sel_ca_id; // 검증된 날짜만 URL에 전달한다. 최초 조회의 빈 날짜는 링크에서 생략한다.
$filter_query = array('sel_ca_id' => $sel_ca_id);
if ($fr_date !== '' && $to_date !== '') {
$filter_query['fr_date'] = $fr_date;
$filter_query['to_date'] = $to_date;
}
$qstr1 = htmlspecialchars(http_build_query($filter_query, '', '&'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$paging_query = htmlspecialchars(http_build_query(array_merge($filter_query, array('sort1' => $sort1, 'sort2' => $sort2)), '', '&'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$listall = '<a href="'.$_SERVER['SCRIPT_NAME'].'" class="ov_listall">전체목록</a>'; $listall = '<a href="'.$_SERVER['SCRIPT_NAME'].'" class="ov_listall">전체목록</a>';
?> ?>
@@ -79,10 +91,10 @@ $listall = '<a href="'.$_SERVER['SCRIPT_NAME'].'" class="ov_listall">전체목
</select> </select>
<label for="fr_date" class="sound_only">시작일</label> <label for="fr_date" class="sound_only">시작일</label>
<input type="text" name="fr_date" value="<?php echo $fr_date; ?>" id="fr_date" required class="required frm_input" size="8" maxlength="8"> <input type="text" name="fr_date" value="<?php echo htmlspecialchars($fr_date, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>" id="fr_date" required class="required frm_input" size="8" maxlength="8">
~ ~
<label for="to_date" class="sound_only">종료일</label> <label for="to_date" class="sound_only">종료일</label>
<input type="text" name="to_date" value="<?php echo $to_date; ?>" id="to_date" required class="required frm_input" size="8" maxlength="8"> <input type="text" name="to_date" value="<?php echo htmlspecialchars($to_date, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>" id="to_date" required class="required frm_input" size="8" maxlength="8">
<input type="submit" value="검색" class="btn_submit"> <input type="submit" value="검색" class="btn_submit">
</form> </form>
@@ -127,7 +139,7 @@ $listall = '<a href="'.$_SERVER['SCRIPT_NAME'].'" class="ov_listall">전체목
</table> </table>
</div> </div>
<?php echo get_paging(G5_IS_MOBILE ? $config['cf_mobile_pages'] : $config['cf_write_pages'], $page, $total_page, "{$_SERVER['SCRIPT_NAME']}?$qstr1&amp;page="); ?> <?php echo get_paging(G5_IS_MOBILE ? $config['cf_mobile_pages'] : $config['cf_write_pages'], $page, $total_page, "{$_SERVER['SCRIPT_NAME']}?$paging_query&amp;page="); ?>
<div class="local_desc01 local_desc"> <div class="local_desc01 local_desc">
<p>고객님들이 보관함에 가장 많이 넣은 순으로 순위를 출력합니다.</p> <p>고객님들이 보관함에 가장 많이 넣은 순으로 순위를 출력합니다.</p>
+41
View File
@@ -0,0 +1,41 @@
# KVE-2026-2344 관리자 날짜 필터 보완
보관함(`wishlist.php`, 메뉴 `500140`)과 상품판매순위(`itemsellrank.php`, 메뉴 `500100`)의 날짜 필터를 보완했다. 기존에는 입력에 숫자가 하나라도 있으면 원문을 허용하고 HTML 속성에 직접 출력하여 반사 XSS가 발생할 수 있었다.
## 변경 동작
- 두 화면은 공통 `date_filter.lib.php`로 날짜를 검사한다. 관리자 권한 확인 뒤, 화면 헤더 출력과 조회 SQL 실행 전에 검증한다.
- `fr_date`와 `to_date`는 모두 문자열이며 8자리 ASCII 숫자여야 한다. `checkdate()`로 실제 날짜인지 확인하고 시작일이 종료일보다 늦으면 거부한다.
- 날짜 파라미터가 하나라도 전달되면 두 값 모두 유효해야 한다. 배열·빈 문자열·한쪽 누락·개행·잘못된 달력 날짜·기간 역전은 HTTP 400과 고정 안내 문구로 거부한다. 공격 문자열은 안내에 반영하지 않는다.
- 날짜 파라미터가 모두 없는 최초 조회는 전체 기간을 조회한다. 보관함은 빈 날짜, 판매순위는 기존과 같이 종료일에 오늘 날짜를 표시한다.
- 날짜 input은 `htmlspecialchars`의 `ENT_QUOTES | ENT_SUBSTITUTE`와 UTF-8로 인코딩한다.
- 페이지 이동·정렬 링크의 필터는 `http_build_query`로 URL 인코딩하고 HTML 속성에 맞게 인코딩한다. 필터를 사용하지 않은 조회에서는 빈 날짜를 링크에 넣지 않는다. 페이지 이동 중 분류·날짜·정렬 조건을 유지한다.
일반 관리자 권한 검사와 상품·회원·주문 데이터 저장 동작은 변경하지 않는다. 스키마 변경이나 데이터 마이그레이션은 필요하지 않다.
## 검증
```bash
php tests/admin_date_filter.php
node tests/admin_date_filter.js
```
브라우저 검증에는 Playwright와 Chromium이 필요하다. 별도 설치 경로를 사용하는 경우 `PLAYWRIGHT_MODULE`과 `CHROME_EXECUTABLE` 환경변수로 지정할 수 있다.
PHP 검증은 실제 두 페이지를 실행하며 인증·DB·공통 헤더는 대역을 사용한다. 실제 입력·출력·페이징 및 정렬 함수로 다음을 확인한다.
- 두 페이지와 두 날짜 필드에 대한 100개 잘못된 입력의 HTTP 400 거부 및 조회 SQL 미실행
- 한쪽 날짜 누락, 명시적 빈값, 기간 역전 거부
- 최초 전체 조회와 페이지 이동, 윤년 정상 조회, 분류 필터와 정렬 조건 유지
- Chromium에서 추가 이벤트 속성과 스크립트 실행 표식이 생성되지 않음
- 검증 단계와 별도로, 실제 input 출력문에 속성 주입 문자열을 넣어도 출력 인코딩으로 값 안에만 남고 포커스 이벤트가 실행되지 않음
운영 DB·실제 관리자 세션을 사용하거나 회원 권한을 변경하는 검증은 수행하지 않는다. 배포 전 운영과 동등한 격리 환경에서 관리자 로그인·권한별 접근과 정상 조회를 추가 확인한다.
## 배포 및 보고 상태
현재 상태는 코드 수정 및 격리 회귀 검증 완료다. 운영 반영과 수정 버전 배포 완료를 의미하지 않는다. 배포 버전·배포일은 확정 후 별도로 기록한다.
인터넷진흥원 중간 회신 문구:
> KVE-2026-2344와 관련하여 관리자 보관함 및 동일 원인이 확인된 상품판매순위의 날짜 입력 검증과 출력 인코딩을 보완하였습니다. 잘못된 날짜 입력은 조회 전에 거부하도록 수정하였으며, 정상 조회·정렬·페이지 이동과 브라우저의 이벤트 실행 차단을 격리 환경에서 검증하였습니다. 현재 코드 수정 및 회귀 검증을 완료한 상태이며, 운영 반영 및 배포 버전·배포일은 확정 후 조치 결과와 함께 별도 통보하겠습니다.
+48
View File
@@ -0,0 +1,48 @@
// 실행: node tests/admin_date_filter.js (playwright 필요, 실제 서버·DB 연결 없음)
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const { execFileSync } = require('node:child_process');
const path = require('node:path');
const assert = require('node:assert/strict');
(async () => {
const results = JSON.parse(execFileSync('php', [path.join(__dirname, 'admin_date_filter.php'), '--json'], {encoding: 'utf8'}));
const browser = await chromium.launch({headless: true, ...(process.env.CHROME_EXECUTABLE ? {executablePath: process.env.CHROME_EXECUTABLE} : {})});
try {
for (const [name, result] of Object.entries(results)) {
const page = await browser.newPage();
await page.route('**/*', route => route.abort());
await page.setContent('<!doctype html><html><body>' + result.html + '</body></html>');
assert.equal(await page.locator('[autofocus], [onfocus]').count(), 0, name);
assert.equal(await page.evaluate(() => document.documentElement.dataset.g5xss), undefined, name);
if (name.endsWith('-blocked')) {
assert.equal(result.status, 400);
assert.equal(await page.locator('input').count(), 0);
} else {
assert.equal(await page.locator('#fr_date').count(), 1);
assert.equal(await page.locator('#to_date').count(), 1);
if (name.endsWith('-valid')) {
assert.equal(await page.locator('#fr_date').inputValue(), '20240229');
assert.equal(await page.locator('#to_date').inputValue(), '20240301');
}
if (name.endsWith('-encoded-output')) {
assert.equal(await page.locator('#fr_date').inputValue(), '1" autofocus onfocus=document.documentElement.dataset.g5xss=1 x="');
await page.locator('#fr_date').focus();
await page.locator('#to_date').focus();
assert.equal(await page.evaluate(() => document.documentElement.dataset.g5xss), undefined);
}
for (const url of await page.locator('a[href*="page="]').evaluateAll(links => links.map(a => a.getAttribute('href')))) {
const query = new URL(url, 'https://example.invalid').searchParams;
assert.equal(query.has('amp;fr_date'), false);
if (name.endsWith('-initial')) assert.equal(query.has('fr_date') || query.has('to_date'), false);
if (name.endsWith('-valid')) {
assert.equal(query.get('fr_date'), '20240229');
assert.equal(query.get('to_date'), '20240301');
}
}
}
await page.close();
}
console.log('보관함·판매순위 Chromium 속성 주입 차단·날짜 출력·링크 검증 통과');
} finally {
await browser.close();
}
})().catch(error => {console.error(error); process.exitCode = 1;});
+124
View File
@@ -0,0 +1,124 @@
<?php
if (PHP_SAPI !== 'cli') exit;
// 실행: php tests/admin_date_filter.php [--json] (실제 DB·관리자 세션 사용 안 함)
define('_GNUBOARD_', true);
$root = dirname(__DIR__);
require $root.'/adm/shop_admin/date_filter.lib.php';
function expect_date($condition, $message) {
if (!$condition) throw new RuntimeException($message);
}
// 의존 함수는 실제 소스에서 읽고, DB·인증·화면 헤더만 대역으로 교체한다.
function load_date_test_function($file, $name) {
$source = file_get_contents($file);
$start = strpos($source, 'function '.$name.'(');
if ($start === false) throw new RuntimeException('함수 없음: '.$name);
$code = ''; $depth = 0; $opened = false;
foreach (token_get_all('<?php '.substr($source, $start)) as $token) {
if (is_array($token) && $token[0] === T_OPEN_TAG) continue;
if ($token === '{' || (is_array($token) && in_array($token[0], array(T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES), true))) { $depth++; $opened = true; }
if ($token === '}') $depth--;
$code .= is_array($token) ? $token[1] : $token;
if ($opened && $depth === 0) { eval($code); return; }
}
throw new RuntimeException('함수 추출 실패: '.$name);
}
foreach (array('get_paging', 'clean_xss_tags', 'get_search_string') as $name) load_date_test_function($root.'/lib/common.lib.php', $name);
load_date_test_function($root.'/lib/shop.lib.php', 'title_sort');
load_date_test_function($root.'/adm/admin.lib.php', 'get_sanitize_input');
load_date_test_function($root.'/common.php', 'sql_escape_string');
function sql_query($sql) { $GLOBALS['date_test_queries'][] = $sql; return true; }
function sql_num_rows($result) { return 30; }
function sql_fetch_array($result) { return false; }
function auth_check_menu($auth, $menu, $permission) { $GLOBALS['date_test_auth'] = array($menu, $permission); }
function alert($message) { throw new InvalidArgumentException($message); }
$fixture = sys_get_temp_dir().'/g5-date-filter-'.bin2hex(random_bytes(6));
mkdir($fixture); mkdir($fixture.'/jquery-ui');
file_put_contents($fixture.'/_common.php', '<?php');
file_put_contents($fixture.'/admin.head.php', '<?php echo "<!-- report-header -->";');
file_put_contents($fixture.'/admin.tail.php', '<?php');
file_put_contents($fixture.'/jquery-ui/datepicker.php', '<?php');
file_put_contents($fixture.'/date_filter.lib.php', '<?php'); // 실제 함수는 위에서 로드됨
register_shutdown_function(function () use ($fixture) {
foreach (array('_common.php', 'admin.head.php', 'admin.tail.php', 'date_filter.lib.php', 'jquery-ui/datepicker.php') as $file) unlink($fixture.'/'.$file);
rmdir($fixture.'/jquery-ui'); rmdir($fixture);
});
define('G5_ADMIN_PATH', $fixture);
define('G5_PLUGIN_PATH', $fixture);
define('G5_IS_MOBILE', false);
function render_date_report($name, $params) {
global $root, $fixture, $sort1, $sort2, $page, $doc, $fr_date, $to_date;
$g5 = array('g5_shop_wish_table' => 'wish', 'g5_shop_item_table' => 'item', 'g5_shop_category_table' => 'category', 'g5_shop_cart_table' => 'cart');
$config = array('cf_page_rows' => 10, 'cf_mobile_pages' => 5, 'cf_write_pages' => 5);
$auth = array(); $page = isset($params['page']) ? (int) $params['page'] : 1;
$_GET = array();
foreach ($params as $key => $value) $_GET[$key] = is_string($value) ? sql_escape_string($value) : $value;
$_SERVER['SCRIPT_NAME'] = '/adm/shop_admin/'.$name.'.php';
$GLOBALS['date_test_queries'] = array();
$GLOBALS['date_test_auth'] = null;
http_response_code(200);
$cwd = getcwd(); chdir($fixture); ob_start();
try { include $root.'/adm/shop_admin/'.$name.'.php'; }
catch (InvalidArgumentException $e) { echo '조회 기간 오류'; }
finally { $html = ob_get_clean(); chdir($cwd); }
return array('html' => $html, 'status' => http_response_code(), 'queries' => $GLOBALS['date_test_queries'], 'auth' => $GLOBALS['date_test_auth']);
}
$bad_dates = array(
'1" autofocus onfocus=document.documentElement.dataset.g5xss=1 x="',
"1' onfocus=alert(1)", '20260909" onfocus="alert(1)', '20260909\\', '<svg onload=alert(1)>1',
'', '20260229', '20260431', '20261301', '20260001', '20260900', '00000101', '2026099', '202609090',
"20260909\n", "20260909\r\n", ' 20260909', '20260909 ', '20260909', '2026-09-09',
array('20260909'), array(array('x')), null, 20260909, true
);
$results = array();
foreach (array('wishlist' => '500140', 'itemsellrank' => '500100') as $name => $menu) {
foreach (array('fr_date', 'to_date') as $field) {
foreach ($bad_dates as $i => $bad) {
$params = array('fr_date' => '20260101', 'to_date' => '20261231'); $params[$field] = $bad;
$result = render_date_report($name, $params);
expect_date($result['status'] === 400 && !$result['queries'], $name.' '.$field.' 잘못된 입력 '.$i);
expect_date(strpos($result['html'], '<input') === false && strpos($result['html'], 'onfocus') === false, '거부 응답에 입력 반영 금지');
expect_date($result['auth'] === array($menu, 'r'), '메뉴 읽기 권한 검사 유지');
if ($i === 0) $results[$name.'-'.$field.'-blocked'] = $result;
}
}
foreach (array(array('fr_date' => '20260909'), array('to_date' => '20260909'), array('fr_date' => '', 'to_date' => ''), array('fr_date' => '20260910', 'to_date' => '20260909')) as $params) {
$result = render_date_report($name, $params);
expect_date($result['status'] === 400 && !$result['queries'], '누락·빈값·역전 범위 거부');
}
$initial = render_date_report($name, array());
expect_date($initial['status'] === 200 && count($initial['queries']) === 3, '최초 전체 조회 유지');
expect_date(strpos(implode('', $initial['queries']), 'between') === false, '최초 조회 기간 조건 없음');
$results[$name.'-initial'] = $initial;
$sort = $name === 'wishlist' ? 'wi_time' : 'ct_status_1';
$valid = render_date_report($name, array('fr_date' => '20240229', 'to_date' => '20240301', 'sort1' => $sort, 'sort2' => 'asc', 'sel_ca_id' => '10'));
expect_date($valid['status'] === 200, '윤년 정상 범위');
expect_date(strpos($valid['queries'][0], "between '2024-02-29 00:00:00' and '2024-03-01 23:59:59'") !== false, '정상 SQL 기간');
foreach (array('initial' => $initial, 'valid' => $valid) as $kind => $result) {
preg_match_all('/href="([^"]+)"/', $result['html'], $links);
$paging_count = 0;
foreach ($links[1] as $link) {
$url = html_entity_decode($link, ENT_QUOTES, 'UTF-8');
parse_str((string) parse_url($url, PHP_URL_QUERY), $params);
if (!isset($params['page'])) continue;
$paging_count++;
expect_date(!isset($params['amp;fr_date']), 'URL 중복 인코딩 없음');
if ($kind === 'initial') expect_date(!isset($params['fr_date']) && !isset($params['to_date']), '최초 조회 링크에 빈 날짜 없음');
else expect_date($params['fr_date'] === '20240229' && $params['to_date'] === '20240301' && $params['sel_ca_id'] === '10', '검색·분류 유지');
$next = render_date_report($name, $params);
expect_date($next['status'] === 200, '정렬·페이지 이동 재조회 성공');
}
expect_date($paging_count >= 2, '실제 페이지 링크 검증');
}
$results[$name.'-valid'] = $valid;
// 검증 단계가 우회되더라도 실제 input 출력의 속성 인코딩이 동작하는지 확인한다.
$fr_date = $to_date = $bad_dates[0];
preg_match_all('/<input type="text" name="(?:fr_date|to_date)"[^\r\n]+/', file_get_contents($root.'/adm/shop_admin/'.$name.'.php'), $inputs);
ob_start(); eval('?>'.implode("\n", $inputs[0]));
$results[$name.'-encoded-output'] = array('html' => ob_get_clean(), 'status' => 200, 'queries' => array());
}
if (in_array('--json', $argv, true)) echo json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
else echo "날짜 필터 100개 잘못된 입력 및 누락·역전·최초 조회·윤년·정렬·페이징 회귀 검증 통과\n";