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); } }