Files
Gnuboard7/tests/Unit/Installer/ComposerPathValidationTest.php
T
2026-05-14 11:13:08 +09:00

165 lines
6.4 KiB
PHP

<?php
namespace Tests\Unit\Installer;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ValidationApi;
/**
* 인스톨러 Composer/PHP 경로 검증 보안 회귀 테스트
*
* 사용자 입력으로 들어온 Composer/PHP 바이너리 경로가 shell 명령으로 그대로
* 실행되던 결함(공백 분기 + escape 부재) 의 패치를 보장한다.
*
* 검증 축:
* - shell 메타문자(공백, 세미콜론, 백틱 등) 포함 입력 → exec 도달 전 차단
* - 존재하지 않는 경로 → exec 도달 전 차단
* - 빈 입력 (시스템 기본 'composer' / 'php') → 통과 (실행은 환경 의존)
* - 정상 .phar 경로 + 잘못된 phpPath → exec 도달 전 차단
*/
class ComposerPathValidationTest extends TestCase
{
private static bool $loaded = false;
public static function setUpBeforeClass(): void
{
if (self::$loaded) {
return;
}
// ValidationApi 는 글로벌 네임스페이스이므로 \lang() 으로 조회됨 →
// 글로벌 스코프에 정의된 스텁이 필요.
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 가 다른 테스트 인프라에 의해 정의되지 않은 경우, 프로젝트 루트로
// 정의해 인접 테스트(DeleteDirectoryTest/InstallerWindowsCommandsTest)의
// `require_once BASE_PATH . '/public/install/...'` 패턴이 깨지지 않도록 함.
$projectRoot = dirname(__DIR__, 3);
if (! defined('BASE_PATH')) {
define('BASE_PATH', $projectRoot);
}
if (! defined('STATE_PATH')) {
define('STATE_PATH', BASE_PATH . '/storage/installer-state.json');
}
require_once $projectRoot . '/public/install/api/check-configuration.php';
// applyInstallerComposerEnvVars() 는 functions.php 에 정의. stat 가드 완화 회복
// 이후 시스템 기본 'composer' 또는 정상 절대경로 입력이 exec 단계까지 도달하면서
// 이 함수를 호출하므로 사전 로드 필요.
require_once $projectRoot . '/public/install/includes/functions.php';
self::$loaded = true;
}
/**
* private 메서드 호출 헬퍼.
*/
private function invoke(string $method, array $args): array
{
$api = new ValidationApi();
$ref = new ReflectionClass($api);
$m = $ref->getMethod($method);
// PHP 8.1+ 부터 ReflectionMethod 가 자동으로 private 접근 가능 — setAccessible 미호출.
return $m->invokeArgs($api, $args);
}
public function test_composer_path_with_shell_injection_is_rejected_before_exec(): void
{
$marker = sys_get_temp_dir() . '/g7_kve_test_' . bin2hex(random_bytes(4));
$payload = 'sh -c "id > ' . $marker . '"';
$result = $this->invoke('validateComposerPath', [$payload, 'php']);
$this->assertFalse($result['valid'], '공백 포함 임의 명령 입력은 valid=false 여야 함');
$this->assertNull($result['version']);
$this->assertFileDoesNotExist($marker, '검증 단계에서 exec 가 실행되지 않아야 함 (RCE 차단)');
}
public function test_composer_path_with_semicolon_injection_is_rejected(): void
{
$marker = sys_get_temp_dir() . '/g7_kve_test_' . bin2hex(random_bytes(4));
$payload = '/bin/sh; touch ' . $marker;
$result = $this->invoke('validateComposerPath', [$payload, 'php']);
$this->assertFalse($result['valid']);
$this->assertFileDoesNotExist($marker);
}
public function test_composer_path_pointing_to_nonexistent_file_is_rejected(): void
{
$result = $this->invoke('validateComposerPath', ['/nonexistent/path/to/composer', 'php']);
$this->assertFalse($result['valid']);
$this->assertNull($result['version']);
}
public function test_composer_path_pointing_to_directory_is_rejected(): void
{
$result = $this->invoke('validateComposerPath', [sys_get_temp_dir(), 'php']);
$this->assertFalse($result['valid']);
}
public function test_phar_path_with_invalid_php_path_is_rejected_before_exec(): void
{
$marker = sys_get_temp_dir() . '/g7_kve_test_' . bin2hex(random_bytes(4));
$tmpPhar = tempnam(sys_get_temp_dir(), 'g7-fake-composer-') . '.phar';
file_put_contents($tmpPhar, '');
chmod($tmpPhar, 0755);
try {
$payload = 'sh -c "id > ' . $marker . '"';
$result = $this->invoke('validateComposerPath', [$tmpPhar, $payload]);
$this->assertFalse($result['valid']);
$this->assertFileDoesNotExist($marker);
} finally {
@unlink($tmpPhar);
}
}
public function test_php_path_with_shell_injection_is_rejected_before_exec(): void
{
$marker = sys_get_temp_dir() . '/g7_kve_test_' . bin2hex(random_bytes(4));
$payload = 'sh -c "id > ' . $marker . '"';
$result = $this->invoke('validatePhpPath', [$payload]);
$this->assertFalse($result['valid']);
$this->assertFileDoesNotExist($marker);
}
public function test_php_path_pointing_to_nonexistent_file_is_rejected(): void
{
$result = $this->invoke('validatePhpPath', ['/nonexistent/path/to/php']);
$this->assertFalse($result['valid']);
}
public function test_empty_composer_path_falls_back_to_system_composer(): void
{
// 빈 입력은 'composer' 시스템 기본으로 시도 (실행 자체는 환경 의존)
// 핵심은 validation 단계에서 거부되지 않고 exec 까지 도달한다는 것.
$result = $this->invoke('validateComposerPath', ['', 'php']);
// composer 미설치 환경: exec 실패로 valid=false + error_composer_exec_failed 메시지
// composer 설치 환경: valid=true
// 두 경우 모두 "사전 거부" 와는 다른 메시지를 반환해야 한다.
$this->assertContains($result['valid'], [true, false]);
// 사전 거부 메시지("error_composer_exec_failed") 가 path='composer' 로 매핑되거나 성공.
// shell injection 메시지 패턴이 들어가지 않는지 확인.
$this->assertIsString($result['message']);
}
}