KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과 그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다. globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를 직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다. 서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이 그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고, 같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다. 아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩 걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
664 lines
27 KiB
PHP
664 lines
27 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Installer;
|
|
|
|
use InvalidArgumentException;
|
|
use PHPUnit\Framework\TestCase;
|
|
use ReflectionClass;
|
|
use ValidationApi;
|
|
|
|
/**
|
|
* 인스톨러 추가 보안 강화 회귀 테스트
|
|
*
|
|
* 후속 감사로 추가 발견된 5개 이슈에 대한 회귀 가드:
|
|
* - getComposerCommand/Display 의 공백 분기 RCE
|
|
* - save-extensions 식별자 셸 주입
|
|
* - checkCorePendingPath 경로 traversal/정보 노출
|
|
* - escapeEnvValue 개행 라인 주입
|
|
* - PDO DSN 파라미터 주입
|
|
*/
|
|
class InstallerSecurityHardeningTest extends TestCase
|
|
{
|
|
private static bool $loaded = false;
|
|
|
|
private static string $tempBase = '';
|
|
|
|
private static string $skipReason = '';
|
|
|
|
public static function setUpBeforeClass(): void
|
|
{
|
|
if (self::$loaded) {
|
|
return;
|
|
}
|
|
|
|
require_once __DIR__.'/stubs/lang_stub.php';
|
|
|
|
if (! defined('MIN_PHP_VERSION')) {
|
|
define('MIN_PHP_VERSION', '8.2.0');
|
|
}
|
|
if (! defined('CHECK_CONFIGURATION_LIBRARY')) {
|
|
define('CHECK_CONFIGURATION_LIBRARY', true);
|
|
}
|
|
|
|
// BASE_PATH 미정의 시 프로젝트 루트로 정의 — 같은 PHPUnit 프로세스에서 뒤에
|
|
// 실행되는 인접 테스트(DeleteDirectoryTest, InstallerWindowsCommandsTest 등)가
|
|
// `require_once BASE_PATH . '/public/install/...'` 로 로드하는 패턴이 깨지지 않도록.
|
|
$projectRoot = dirname(__DIR__, 3);
|
|
if (! defined('BASE_PATH')) {
|
|
define('BASE_PATH', $projectRoot);
|
|
}
|
|
// STATE_PATH 는 테스트 격리용 임시 경로로 분리 — 운영 storage/installer-state.json 미오염.
|
|
self::$tempBase = sys_get_temp_dir().'/g7-installer-hardening-'.bin2hex(random_bytes(4));
|
|
@mkdir(self::$tempBase.'/storage/app', 0755, true);
|
|
|
|
if (! defined('STATE_PATH')) {
|
|
define('STATE_PATH', self::$tempBase.'/storage/installer-state.json');
|
|
}
|
|
|
|
// 안전 가드: STATE_PATH 는 PHP 상수라 한 프로세스에서 단 한 번만 정의된다.
|
|
// 다른 Installer 테스트(ComposerPathValidationTest 등)가 먼저 BASE_PATH 기반
|
|
// 루트 경로로 STATE_PATH 를 박으면 본 테스트의 writeState/clearState 가 실제 운영
|
|
// installer-state.json 을 파괴하게 된다. STATE_PATH 가 시스템 temp 하위가 아니면 skip.
|
|
$tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
|
|
$resolvedState = realpath(dirname((string) STATE_PATH)) ?: dirname((string) STATE_PATH);
|
|
if (strpos($resolvedState, $tempPrefix) !== 0) {
|
|
self::$skipReason = 'STATE_PATH ('.STATE_PATH.') 가 시스템 temp 하위가 아님 — '
|
|
.'다른 Installer 테스트의 상수 정의가 선행됨. 실제 installer-state.json 파괴 방지를 위해 skip. '
|
|
.'격리 실행: php vendor/bin/phpunit --filter=InstallerSecurityHardeningTest';
|
|
self::$loaded = true;
|
|
|
|
return;
|
|
}
|
|
|
|
require_once $projectRoot.'/public/install/api/check-configuration.php';
|
|
require_once $projectRoot.'/public/install/includes/installer-state.php';
|
|
require_once $projectRoot.'/public/install/includes/functions.php';
|
|
require_once $projectRoot.'/public/install/includes/task-runner.php';
|
|
|
|
self::$loaded = true;
|
|
}
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
if (self::$skipReason !== '') {
|
|
$this->markTestSkipped(self::$skipReason);
|
|
}
|
|
}
|
|
|
|
private function invokePrivate(object $obj, string $method, array $args)
|
|
{
|
|
$ref = new ReflectionClass($obj);
|
|
$m = $ref->getMethod($method);
|
|
|
|
return $m->invokeArgs($obj, $args);
|
|
}
|
|
|
|
/**
|
|
* 테스트가 도중 종료되어도 임시 디렉토리에 state.json 잔여물이 남지 않도록 정리.
|
|
* STATE_PATH 는 setUpBeforeClass 의 안전 가드로 시스템 temp 하위임이 보장된 경우에만
|
|
* 이 지점에 도달하므로, 운영 storage/installer-state.json 은 절대 건드리지 않는다.
|
|
* (과거 루트 state 를 무조건 @unlink 하던 코드는 실제 운영 설치 상태를 파괴할 수 있어 제거)
|
|
*/
|
|
protected function tearDown(): void
|
|
{
|
|
if (self::$skipReason === '' && is_file(STATE_PATH)) {
|
|
@unlink(STATE_PATH);
|
|
}
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* 테스트별 임시 state.json 작성/삭제 도우미.
|
|
*/
|
|
private function writeState(array $config): void
|
|
{
|
|
$state = ['config' => $config];
|
|
@file_put_contents(STATE_PATH, json_encode($state));
|
|
}
|
|
|
|
private function clearState(): void
|
|
{
|
|
@unlink(STATE_PATH);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Critical-1 — getComposerCommand 공백 분기 RCE 차단
|
|
// ========================================================================
|
|
|
|
public function test_get_composer_command_rejects_space_containing_input_falls_back_to_composer(): void
|
|
{
|
|
$this->writeState(['composer_binary' => 'sh -c "id > /tmp/g7_should_not_run"']);
|
|
|
|
$cmd = getComposerCommand();
|
|
|
|
$this->assertSame('composer', $cmd, '공백 포함 입력은 시스템 기본 composer 로 폴백');
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_get_composer_command_rejects_shell_metachars_falls_back_to_composer(): void
|
|
{
|
|
foreach ([
|
|
'/bin/sh; touch /tmp/x',
|
|
'/bin/composer && id',
|
|
'$(id)',
|
|
'`id`',
|
|
"/bin/composer\nrm -rf /",
|
|
] as $payload) {
|
|
$this->writeState(['composer_binary' => $payload]);
|
|
$cmd = getComposerCommand();
|
|
$this->assertSame('composer', $cmd, "셸 메타문자 포함 입력 거부 (입력: {$payload})");
|
|
}
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_get_composer_command_passes_through_safe_absolute_path(): void
|
|
{
|
|
// open_basedir 같은 PHP 런타임 제약 환경의 false negative 를 피하기 위해
|
|
// stat 의존 가드가 제거됨. 메타문자 없는 단일 절대경로는 escape 후 그대로 사용.
|
|
// 실제 실행 가능 여부는 exec 결과로 최종 판정 (silent fallback 안 함).
|
|
$this->writeState(['composer_binary' => '/usr/local/bin/composer']);
|
|
|
|
$cmd = getComposerCommand();
|
|
|
|
$this->assertSame(escapeshellarg('/usr/local/bin/composer'), $cmd);
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_get_composer_command_empty_returns_default(): void
|
|
{
|
|
$this->writeState(['composer_binary' => '']);
|
|
|
|
$this->assertSame('composer', getComposerCommand());
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_get_composer_command_for_display_does_not_leak_shell_payload(): void
|
|
{
|
|
$this->writeState(['composer_binary' => 'rm -rf /']);
|
|
|
|
$display = getComposerCommandForDisplay();
|
|
|
|
$this->assertSame('composer', $display, '셸 페이로드는 디스플레이에도 노출되지 않음');
|
|
$this->assertStringNotContainsString('rm', $display);
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_is_installer_executable_path_rejects_metachars(): void
|
|
{
|
|
// 백슬래시는 Windows 경로 구분자이므로 차단 대상 아님 (escapeshellarg 가 셸 인젝션 차단)
|
|
foreach (['foo bar', 'a;b', 'a`b', 'a$b', 'a|b', "a\nb", 'a"b', "a'b", "a\0b", "a\x01b", "a\rb"] as $bad) {
|
|
$this->assertFalse(isInstallerExecutablePath($bad), '메타문자 거부: '.bin2hex($bad));
|
|
}
|
|
}
|
|
|
|
public function test_is_installer_executable_path_accepts_windows_paths(): void
|
|
{
|
|
// Windows 절대경로는 백슬래시 포함이지만 공백·메타문자 없으면 통과.
|
|
// 공백 포함 디렉토리(예: 'C:\\Program Files\\...') 는 공백 분리 입력 형식의 토큰
|
|
// 분리 휴리스틱과 충돌하므로 본 회복 범위 외 — 알려진 한계.
|
|
foreach ([
|
|
'C:\\laragon\\bin\\php\\php-8.3.26-Win32-vs16-x64\\php.exe',
|
|
'C:\\php\\php.exe',
|
|
'D:\\xampp\\php\\php.exe',
|
|
'C:\\php\\composer.phar',
|
|
] as $path) {
|
|
$this->assertTrue(isInstallerExecutablePath($path), "Windows 경로 허용: {$path}");
|
|
}
|
|
}
|
|
|
|
public function test_is_installer_executable_path_rejects_windows_path_with_space_known_limitation(): void
|
|
{
|
|
// 'C:\\Program Files\\...' 같은 공백 포함 Windows 경로는 본 회복 범위 외.
|
|
// 공백 분리 입력(PHP + Composer 합성) 형식 휴리스틱과 충돌 — escapeshellarg 단일 토큰
|
|
// wrap 만으로는 공백 의도(토큰 구분 vs 디렉토리명) 를 자동 구분할 수 없음.
|
|
// 회피: 8.3 short path 형식(C:\\PROGRA~1\\...) 사용 또는 공백 없는 경로 사용.
|
|
$this->assertFalse(isInstallerExecutablePath('C:\\Program Files\\PHP\\php.exe'));
|
|
}
|
|
|
|
// ========================================================================
|
|
// open_basedir 회귀 회복 — stat 의존 가드 완화
|
|
// ========================================================================
|
|
|
|
/**
|
|
* stat 가드가 제거되어 시놀로지 DSM 등 open_basedir 환경에서도
|
|
* 정상 절대경로 입력이 통과해야 함. 실제 실행 가능 여부는 exec 결과로 판정.
|
|
*/
|
|
public function test_is_installer_executable_path_accepts_safe_absolute_path_without_stat(): void
|
|
{
|
|
// 실제로 존재하지 않는 경로지만 메타문자 없음 — stat 가드 제거로 true
|
|
$this->assertTrue(isInstallerExecutablePath('/usr/local/bin/php83'));
|
|
$this->assertTrue(isInstallerExecutablePath('/opt/plesk/php/8.3/bin/php'));
|
|
$this->assertTrue(isInstallerExecutablePath('/nonexistent/path/to/binary'));
|
|
}
|
|
|
|
// ========================================================================
|
|
// Composer 공백 분리 입력 안전 복원 — 멀티 PHP 환경 호환성
|
|
// ========================================================================
|
|
|
|
public function test_get_composer_command_accepts_php_composer_space_separated_input(): void
|
|
{
|
|
// 시놀로지/cPanel/Plesk 멀티 PHP 환경의 운영 패턴
|
|
$this->writeState(['composer_binary' => '/usr/local/bin/php83 /usr/local/bin/composer']);
|
|
|
|
$cmd = getComposerCommand();
|
|
|
|
// 두 토큰 각각 escapeshellarg 적용 후 공백으로 합성
|
|
$expected = escapeshellarg('/usr/local/bin/php83').' '.escapeshellarg('/usr/local/bin/composer');
|
|
$this->assertSame($expected, $cmd);
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_get_composer_command_rejects_space_separated_with_metachar_in_first_token(): void
|
|
{
|
|
// 첫 토큰에 메타문자 → 거부, composer 폴백
|
|
$this->writeState(['composer_binary' => '/usr/local/bin/php$(id) /usr/local/bin/composer']);
|
|
|
|
$cmd = getComposerCommand();
|
|
|
|
$this->assertSame('composer', $cmd);
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_get_composer_command_rejects_space_separated_with_metachar_in_second_token(): void
|
|
{
|
|
// 두 번째 토큰에 메타문자 → 거부, composer 폴백
|
|
$this->writeState(['composer_binary' => '/usr/local/bin/php83 /usr/local/bin/composer;id']);
|
|
|
|
$cmd = getComposerCommand();
|
|
|
|
$this->assertSame('composer', $cmd);
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_get_composer_command_rejects_three_or_more_tokens(): void
|
|
{
|
|
// 3 토큰 이상은 두 번째 토큰에 공백이 남음 → 두 번째 토큰의 메타문자(공백) 로 거부
|
|
$this->writeState(['composer_binary' => '/bin/php /bin/composer extra_arg']);
|
|
|
|
$cmd = getComposerCommand();
|
|
|
|
$this->assertSame('composer', $cmd);
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_get_composer_command_for_display_shows_human_friendly_space_separated_form(): void
|
|
{
|
|
$this->writeState(['composer_binary' => '/usr/local/bin/php83 /usr/local/bin/composer']);
|
|
|
|
$display = getComposerCommandForDisplay();
|
|
|
|
// 사람에게 보이는 표기는 escape 없는 원본 형식 유지
|
|
$this->assertSame('/usr/local/bin/php83 /usr/local/bin/composer', $display);
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_get_composer_command_for_display_does_not_leak_shell_payload_in_space_separated(): void
|
|
{
|
|
// 두 토큰 중 하나라도 메타문자 포함이면 display 도 composer 폴백
|
|
$this->writeState(['composer_binary' => '/bin/php `id`']);
|
|
|
|
$display = getComposerCommandForDisplay();
|
|
|
|
$this->assertSame('composer', $display);
|
|
$this->assertStringNotContainsString('`', $display);
|
|
$this->clearState();
|
|
}
|
|
|
|
// ========================================================================
|
|
// validatePhpPath / validateComposerPath stat 가드 완화 (인스톨러 검증 API)
|
|
// ========================================================================
|
|
|
|
public function test_validate_php_path_rejects_shell_metachars(): void
|
|
{
|
|
$api = new ValidationApi;
|
|
foreach ([
|
|
'/usr/local/bin/php; id',
|
|
'/usr/local/bin/php$(id)',
|
|
'`/usr/local/bin/php`',
|
|
'/usr/local/bin/php|nc evil',
|
|
"/usr/local/bin/php\nrm -rf /",
|
|
] as $payload) {
|
|
$result = $this->invokePrivate($api, 'validatePhpPath', [$payload]);
|
|
$this->assertFalse($result['valid'], "메타문자 거부: {$payload}");
|
|
}
|
|
}
|
|
|
|
public function test_validate_php_path_rejects_empty(): void
|
|
{
|
|
$api = new ValidationApi;
|
|
$result = $this->invokePrivate($api, 'validatePhpPath', ['']);
|
|
$this->assertFalse($result['valid']);
|
|
}
|
|
|
|
public function test_validate_php_path_safe_path_reaches_exec_phase(): void
|
|
{
|
|
// 메타문자 없는 절대경로는 stat 가드 없이 exec 단계까지 도달.
|
|
// 존재하지 않으면 exec 실패 (return code != 0) 로 거부 — 단, 거부 메시지는
|
|
// 'error_php_exec_failed' 로 stat 거부와 동일. 핵심: stat 가드가 사라졌다는 점.
|
|
$api = new ValidationApi;
|
|
$result = $this->invokePrivate($api, 'validatePhpPath', ['/nonexistent/path/to/php']);
|
|
|
|
// 실제 실행 실패로 invalid 이지만, 그 판정이 exec 결과에 기반함을 의미적으로 검증.
|
|
$this->assertFalse($result['valid']);
|
|
}
|
|
|
|
public function test_validate_composer_path_rejects_shell_metachars(): void
|
|
{
|
|
$api = new ValidationApi;
|
|
foreach ([
|
|
'/usr/local/bin/composer; id',
|
|
'/usr/local/bin/composer$(id)',
|
|
'`/usr/local/bin/composer`',
|
|
"/usr/local/bin/composer\nrm",
|
|
] as $payload) {
|
|
$result = $this->invokePrivate($api, 'validateComposerPath', [$payload]);
|
|
$this->assertFalse($result['valid'], "메타문자 거부: {$payload}");
|
|
}
|
|
}
|
|
|
|
public function test_validate_composer_path_rejects_space_separated_with_metachar(): void
|
|
{
|
|
// 공백 분리 입력의 토큰별 메타문자 검증
|
|
$api = new ValidationApi;
|
|
foreach ([
|
|
'/bin/php$(id) /bin/composer',
|
|
'/bin/php /bin/composer;id',
|
|
'/bin/php `id`',
|
|
'/bin/php /bin/composer extra_token',
|
|
] as $payload) {
|
|
$result = $this->invokePrivate($api, 'validateComposerPath', [$payload]);
|
|
$this->assertFalse($result['valid'], "공백 분리 + 메타문자 거부: {$payload}");
|
|
}
|
|
}
|
|
|
|
public function test_split_php_composer_tokens_helper_splits_into_two_tokens(): void
|
|
{
|
|
$api = new ValidationApi;
|
|
$result = $this->invokePrivate($api, 'splitPhpComposerTokens', ['/usr/local/bin/php83 /usr/local/bin/composer']);
|
|
$this->assertSame(['php' => '/usr/local/bin/php83', 'composer' => '/usr/local/bin/composer'], $result);
|
|
}
|
|
|
|
public function test_split_php_composer_tokens_helper_returns_null_for_single_token(): void
|
|
{
|
|
$api = new ValidationApi;
|
|
$result = $this->invokePrivate($api, 'splitPhpComposerTokens', ['/usr/local/bin/composer']);
|
|
$this->assertNull($result);
|
|
}
|
|
|
|
public function test_is_installer_safe_path_arg_helper_accepts_safe_paths(): void
|
|
{
|
|
$api = new ValidationApi;
|
|
|
|
foreach ([
|
|
'/usr/local/bin/php83',
|
|
'/opt/php/bin/php',
|
|
'C:/php/php.exe',
|
|
'/nonexistent/path',
|
|
// Windows 절대경로 (백슬래시 포함) — 회귀 가드: Windows 빌트인 서버 환경
|
|
'C:\\laragon\\bin\\php\\php-8.3.26-Win32-vs16-x64\\php.exe',
|
|
'C:\\php\\php.exe',
|
|
'D:\\xampp\\php\\php.exe',
|
|
] as $safe) {
|
|
$this->assertTrue($this->invokePrivate($api, 'isInstallerSafePathArg', [$safe]), "허용: {$safe}");
|
|
}
|
|
}
|
|
|
|
public function test_is_installer_safe_path_arg_helper_rejects_metachars_and_empty(): void
|
|
{
|
|
$api = new ValidationApi;
|
|
|
|
$this->assertFalse($this->invokePrivate($api, 'isInstallerSafePathArg', ['']));
|
|
// 백슬래시는 Windows 경로 구분자이므로 차단 대상이 아님 — 셸 인젝션 차단은 escapeshellarg 가 담당
|
|
foreach (['foo bar', 'a;b', 'a`b', 'a$b', 'a|b', "a\nb", 'a"b', "a'b", "a\0b", "a\x01b", "a\rb"] as $bad) {
|
|
$this->assertFalse($this->invokePrivate($api, 'isInstallerSafePathArg', [$bad]), '메타문자 거부: '.bin2hex($bad));
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// 워커 정합 — 검증 통과한 입력이 워커에서도 silent fallback 없이 전달
|
|
// ========================================================================
|
|
|
|
public function test_get_composer_command_passes_through_single_absolute_path_without_silent_fallback(): void
|
|
{
|
|
// 검증 단계에서 통과한 단일 절대경로가 워커에서도 그대로 사용됨을 보장.
|
|
// stat 가드 제거 후 회귀 — 정상 입력이 silent fallback 으로 사라지지 않음.
|
|
$this->writeState(['composer_binary' => '/usr/local/bin/composer']);
|
|
|
|
$cmd = getComposerCommand();
|
|
|
|
$this->assertNotSame('composer', $cmd, '검증 통과한 입력은 silent fallback 안 됨');
|
|
$this->assertSame(escapeshellarg('/usr/local/bin/composer'), $cmd);
|
|
$this->clearState();
|
|
}
|
|
|
|
// ========================================================================
|
|
// High-2 — checkCorePendingPath traversal 차단
|
|
// ========================================================================
|
|
|
|
public function test_check_core_pending_path_rejects_parent_traversal(): void
|
|
{
|
|
$api = new ValidationApi;
|
|
|
|
foreach (['../../../etc', '..', 'foo/../bar', 'foo/..\\bar'] as $bad) {
|
|
$_GET = ['path' => $bad];
|
|
ob_start();
|
|
$this->invokePrivate($api, 'checkCorePendingPath', []);
|
|
$body = ob_get_clean();
|
|
$decoded = json_decode($body, true);
|
|
|
|
$this->assertIsArray($decoded);
|
|
$this->assertFalse($decoded['success'], "traversal 거부: {$bad}");
|
|
// 단일 통일 메시지로 enumeration 신호 차단
|
|
$this->assertSame('error_core_pending_path_invalid', $decoded['message']);
|
|
}
|
|
$_GET = [];
|
|
}
|
|
|
|
public function test_check_core_pending_path_rejects_null_byte(): void
|
|
{
|
|
$api = new ValidationApi;
|
|
$_GET = ['path' => "valid\0../etc"];
|
|
ob_start();
|
|
$this->invokePrivate($api, 'checkCorePendingPath', []);
|
|
$body = ob_get_clean();
|
|
$decoded = json_decode($body, true);
|
|
|
|
$this->assertFalse($decoded['success']);
|
|
$_GET = [];
|
|
}
|
|
|
|
public function test_check_core_pending_path_returns_uniform_message_for_nonexistent(): void
|
|
{
|
|
$api = new ValidationApi;
|
|
$_GET = ['path' => '/nonexistent/g7-test-'.bin2hex(random_bytes(4))];
|
|
ob_start();
|
|
$this->invokePrivate($api, 'checkCorePendingPath', []);
|
|
$body = ob_get_clean();
|
|
$decoded = json_decode($body, true);
|
|
|
|
$this->assertFalse($decoded['success']);
|
|
// 존재 여부/타입 차이를 응답에 노출하지 않음
|
|
$this->assertSame('error_core_pending_path_invalid', $decoded['message']);
|
|
$_GET = [];
|
|
}
|
|
|
|
// ========================================================================
|
|
// Medium-1 — escapeEnvValue 개행 거부
|
|
// ========================================================================
|
|
//
|
|
// 종전에는 개행을 조용히 지웠다(Medium-1). KVE-2026-2042 정정으로 직렬화기는 개행을
|
|
// 만나면 예외를 던진다 — 지우면 운영자가 입력한 값과 저장된 값이 달라지는데 그 사실이
|
|
// 화면에 나타나지 않기 때문이다. 하위호환 별칭 escapeEnvValue 도 같은 관문에 위임한다.
|
|
|
|
public function test_escape_env_value_rejects_newlines(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
escapeEnvValue("secret\nINJECTED=true");
|
|
}
|
|
|
|
public function test_escape_env_value_rejects_crlf(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
escapeEnvValue("a\r\nb");
|
|
}
|
|
|
|
public function test_escape_env_value_preserves_normal_password(): void
|
|
{
|
|
$result = escapeEnvValue('p@ssw0rd!#$%');
|
|
|
|
$this->assertSame('"p@ssw0rd!#$%"', $result);
|
|
}
|
|
|
|
public function test_escape_env_value_escapes_quotes_and_backslashes(): void
|
|
{
|
|
$result = escapeEnvValue('a"b\\c');
|
|
|
|
$this->assertSame('"a\\"b\\\\c"', $result);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Medium-2 — PDO DSN 파라미터 sanitize
|
|
// ========================================================================
|
|
|
|
public function test_get_database_connection_rejects_semicolon_in_host(): void
|
|
{
|
|
$this->expectException(\PDOException::class);
|
|
|
|
getDatabaseConnection([
|
|
'db_write_host' => 'localhost;injected=evil',
|
|
'db_write_port' => '3306',
|
|
'db_write_database' => 'testdb',
|
|
'db_write_username' => 'root',
|
|
'db_write_password' => '',
|
|
], false);
|
|
}
|
|
|
|
public function test_get_database_connection_rejects_equals_in_database(): void
|
|
{
|
|
$this->expectException(\PDOException::class);
|
|
|
|
getDatabaseConnection([
|
|
'db_write_host' => 'localhost',
|
|
'db_write_port' => '3306',
|
|
'db_write_database' => 'test=evil',
|
|
'db_write_username' => 'root',
|
|
'db_write_password' => '',
|
|
], false);
|
|
}
|
|
|
|
public function test_get_database_connection_rejects_newline_in_port(): void
|
|
{
|
|
$this->expectException(\PDOException::class);
|
|
|
|
getDatabaseConnection([
|
|
'db_write_host' => 'localhost',
|
|
'db_write_port' => "3306\nfoo=bar",
|
|
'db_write_database' => 'testdb',
|
|
'db_write_username' => 'root',
|
|
'db_write_password' => '',
|
|
], false);
|
|
}
|
|
|
|
// ========================================================================
|
|
// KVE-2026-1677 — 설치 워커의 실행 바이너리 경로
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 저장된 php_binary 가 형태 규칙을 위반하면 시스템 기본값으로 폴백한다.
|
|
*
|
|
* 이 값은 설치 워커에서 실제 명령의 실행 바이너리가 된다(`--version` 이 붙지 않는
|
|
* 호출 지점). 이전에는 아무 검증 없이 그대로 반환해, 저장만 되면 인자 자리가 열렸다.
|
|
*
|
|
* @scenario vector=getPhpBinary, payload_class=argument_injection_dash_r
|
|
*
|
|
* @effects getPhpBinary_falls_back_to_default_for_disallowed_shape
|
|
*/
|
|
public function test_get_php_binary_falls_back_to_default_for_disallowed_shape(): void
|
|
{
|
|
foreach ([
|
|
'-rphpinfo();' => '코드 실행 플래그',
|
|
'-dauto_prepend_file=/tmp/x' => 'ini 주입',
|
|
'bin/php' => '상대경로',
|
|
'/usr/local/../../tmp/php' => '.. 세그먼트',
|
|
'/usr/bin/php$(id)' => '셸 메타문자',
|
|
] as $payload => $reason) {
|
|
$this->writeState(['php_binary' => $payload]);
|
|
|
|
$this->assertSame(
|
|
'php',
|
|
getPhpBinary(),
|
|
"형태 위반 값이 그대로 실행 바이너리가 됨 ({$reason}): {$payload}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 정상 절대경로는 그대로 사용된다 (stat 미사용 — 존재하지 않아도 통과).
|
|
*
|
|
* @scenario vector=getPhpBinary, payload_class=wrapper_script_name
|
|
*
|
|
* @effects getPhpBinary_passes_through_legitimate_paths
|
|
*/
|
|
public function test_get_php_binary_passes_through_legitimate_paths(): void
|
|
{
|
|
foreach ([
|
|
'/usr/local/php84/bin/php',
|
|
'/usr/local/bin/php-8.2-wrapper.sh',
|
|
'C:\\php\\php.exe',
|
|
'/usr/local/php99/bin/php',
|
|
] as $path) {
|
|
$this->writeState(['php_binary' => $path]);
|
|
|
|
$this->assertSame($path, getPhpBinary(), "정상 경로가 폴백됨: {$path}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Composer 자리에 인터프리터 + 임의 스크립트를 넣으면 기본값으로 폴백한다.
|
|
*
|
|
* 하이픈 선두만 막으면 `PHP경로 /tmp/올려둔파일` 형태가 남는다.
|
|
*
|
|
* @scenario vector=getComposerCommand, payload_class=interpreter_plus_script
|
|
*
|
|
* @effects getComposerCommand_rejects_interpreter_plus_arbitrary_script
|
|
*/
|
|
public function test_get_composer_command_rejects_interpreter_plus_arbitrary_script(): void
|
|
{
|
|
foreach ([
|
|
'/usr/bin/php /tmp/evil.php',
|
|
'/usr/bin/python3 /tmp/evil.py',
|
|
'/usr/bin/php -rphpinfo();',
|
|
'/usr/bin/php /tmp/composer-evil.sh',
|
|
] as $payload) {
|
|
$this->writeState(['composer_binary' => $payload]);
|
|
|
|
$this->assertSame(
|
|
'composer',
|
|
getComposerCommand(),
|
|
"인자 자리에 임의 스크립트/옵션이 통과함: {$payload}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 정상 멀티 PHP 입력은 계속 두 토큰으로 실행된다 (비파괴).
|
|
*
|
|
* @scenario vector=getComposerCommand, payload_class=wrapper_script_name
|
|
*
|
|
* @effects getComposerCommand_keeps_multi_php_pair_support
|
|
*/
|
|
public function test_get_composer_command_keeps_multi_php_pair_support(): void
|
|
{
|
|
$this->writeState(['composer_binary' => '/usr/local/bin/php-8.2-wrapper.sh /usr/local/bin/composer.phar']);
|
|
|
|
$cmd = getComposerCommand();
|
|
|
|
$this->assertStringContainsString('php-8.2-wrapper.sh', $cmd);
|
|
$this->assertStringContainsString('composer.phar', $cmd);
|
|
}
|
|
}
|