295 lines
10 KiB
PHP
295 lines
10 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Installer;
|
|
|
|
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 = '';
|
|
|
|
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);
|
|
}
|
|
|
|
self::$tempBase = sys_get_temp_dir() . '/g7-installer-hardening-' . bin2hex(random_bytes(4));
|
|
@mkdir(self::$tempBase . '/storage/app', 0755, true);
|
|
|
|
if (! defined('BASE_PATH')) {
|
|
define('BASE_PATH', self::$tempBase);
|
|
} else {
|
|
self::$tempBase = BASE_PATH;
|
|
@mkdir(BASE_PATH . '/storage/app', 0755, true);
|
|
}
|
|
|
|
if (! defined('STATE_PATH')) {
|
|
define('STATE_PATH', BASE_PATH . '/storage/installer-state.json');
|
|
}
|
|
|
|
require_once dirname(__DIR__, 3) . '/public/install/api/check-configuration.php';
|
|
require_once dirname(__DIR__, 3) . '/public/install/includes/installer-state.php';
|
|
require_once dirname(__DIR__, 3) . '/public/install/includes/functions.php';
|
|
require_once dirname(__DIR__, 3) . '/public/install/includes/task-runner.php';
|
|
|
|
self::$loaded = true;
|
|
}
|
|
|
|
private function invokePrivate(object $obj, string $method, array $args)
|
|
{
|
|
$ref = new ReflectionClass($obj);
|
|
$m = $ref->getMethod($method);
|
|
return $m->invokeArgs($obj, $args);
|
|
}
|
|
|
|
/**
|
|
* 테스트가 도중 종료되어도 사용자 작업 디렉토리에 state.json 잔여물이 남지 않도록 항상 정리.
|
|
* BASE_PATH 가 다른 테스트 인프라에 의해 프로젝트 루트로 바인딩되는 경우를 대비해
|
|
* 프로젝트 루트의 storage/installer-state.json 도 함께 정리.
|
|
*/
|
|
protected function tearDown(): void
|
|
{
|
|
@unlink(STATE_PATH);
|
|
$projectStateFile = dirname(__DIR__, 3) . '/storage/installer-state.json';
|
|
@unlink($projectStateFile);
|
|
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_getComposerCommand_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_getComposerCommand_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_getComposerCommand_rejects_nonexistent_path(): void
|
|
{
|
|
$this->writeState(['composer_binary' => '/nonexistent/path/to/composer']);
|
|
|
|
$cmd = getComposerCommand();
|
|
|
|
$this->assertSame('composer', $cmd);
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_getComposerCommand_empty_returns_default(): void
|
|
{
|
|
$this->writeState(['composer_binary' => '']);
|
|
|
|
$this->assertSame('composer', getComposerCommand());
|
|
$this->clearState();
|
|
}
|
|
|
|
public function test_getComposerCommandForDisplay_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_isInstallerExecutablePath_rejects_metachars(): void
|
|
{
|
|
foreach (['foo bar', 'a;b', 'a`b', 'a$b', 'a|b', "a\nb", 'a"b', "a'b", 'a\\b'] as $bad) {
|
|
$this->assertFalse(isInstallerExecutablePath($bad), "메타문자 거부: {$bad}");
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// High-2 — checkCorePendingPath traversal 차단
|
|
// ========================================================================
|
|
|
|
public function test_checkCorePendingPath_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_checkCorePendingPath_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_checkCorePendingPath_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 개행 제거
|
|
// ========================================================================
|
|
|
|
public function test_escapeEnvValue_strips_newlines(): void
|
|
{
|
|
$payload = "secret\nINJECTED=true";
|
|
$result = escapeEnvValue($payload);
|
|
|
|
// 핵심 보안 속성: 결과에 개행 문자가 없어야 한다 (라인 주입 차단).
|
|
// INJECTED=true 가 따옴표 내부 일부로 포함되는 것은 문제 아님 — .env 파서는
|
|
// 따옴표 닫힘 전까지 단일 값으로만 해석.
|
|
$this->assertStringNotContainsString("\n", $result, 'LF 가 결과에 포함되면 안 됨');
|
|
$this->assertStringNotContainsString("\r", $result);
|
|
// 결과는 따옴표로 시작/종료 (라인 주입이 성립하려면 따옴표가 닫힌 뒤 개행이 와야 하나 개행이 제거됨)
|
|
$this->assertStringStartsWith('"', $result);
|
|
$this->assertStringEndsWith('"', $result);
|
|
}
|
|
|
|
public function test_escapeEnvValue_strips_crlf(): void
|
|
{
|
|
$result = escapeEnvValue("a\r\nb");
|
|
|
|
$this->assertSame('"ab"', $result);
|
|
}
|
|
|
|
public function test_escapeEnvValue_preserves_normal_password(): void
|
|
{
|
|
$result = escapeEnvValue('p@ssw0rd!#$%');
|
|
|
|
$this->assertSame('"p@ssw0rd!#$%"', $result);
|
|
}
|
|
|
|
public function test_escapeEnvValue_escapes_quotes_and_backslashes(): void
|
|
{
|
|
$result = escapeEnvValue('a"b\\c');
|
|
|
|
$this->assertSame('"a\\"b\\\\c"', $result);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Medium-2 — PDO DSN 파라미터 sanitize
|
|
// ========================================================================
|
|
|
|
public function test_getDatabaseConnection_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_getDatabaseConnection_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_getDatabaseConnection_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);
|
|
}
|
|
}
|