KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과 그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다. globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를 직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다. 서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이 그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고, 같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다. 아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩 걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
462 lines
18 KiB
PHP
462 lines
18 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Installer;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* 인스톨러 런타임 헬퍼 단위 테스트
|
|
*
|
|
* public/install/includes/installer-runtime.php 의 헬퍼 함수들을 검증한다.
|
|
* 인스톨러는 Laravel 외부에서 동작하므로 BASE_PATH 상수와 헬퍼 인클루드를
|
|
* 테스트 setUp 단계에서 직접 처리한다.
|
|
*
|
|
* @see https://github.com/gnuboard/g7/issues/23
|
|
*/
|
|
class InstallerRuntimeHelperTest extends TestCase
|
|
{
|
|
private string $tempBase = '';
|
|
|
|
private string $skipReason = '';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// 격리된 BASE_PATH — 운영 환경 storage/installer 와 충돌 방지
|
|
$this->tempBase = sys_get_temp_dir().'/g7-installer-runtime-test-'.bin2hex(random_bytes(4));
|
|
|
|
// 안전 가드: INSTALLER_RUNTIME_PATH(= BASE_PATH/storage/installer/runtime.php) 는
|
|
// PHP 상수라 한 프로세스에서 단 한 번만 정의된다. 다른 Installer 테스트가 먼저
|
|
// 프로젝트 루트로 BASE_PATH 를 박으면 tearDown 의 @unlink 가 실제 운영 runtime.php
|
|
// (DB 자격증명/APP_KEY 임시 보관 파일)를 삭제하게 된다. temp 하위가 아니면 skip.
|
|
$tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
|
|
|
|
if (defined('BASE_PATH')) {
|
|
$resolved = realpath((string) BASE_PATH) ?: (string) BASE_PATH;
|
|
if (strpos($resolved, $tempPrefix) !== 0) {
|
|
$this->skipReason = 'BASE_PATH ('.$resolved.') 가 시스템 temp 하위가 아님 — '
|
|
.'다른 Installer 테스트의 BASE_PATH 정의가 선행됨. 실제 storage/installer/runtime.php '
|
|
.'파괴 방지를 위해 skip. 격리 실행: php vendor/bin/phpunit --filter=InstallerRuntimeHelperTest';
|
|
$this->markTestSkipped($this->skipReason);
|
|
}
|
|
$this->tempBase = (string) BASE_PATH;
|
|
} else {
|
|
define('BASE_PATH', $this->tempBase);
|
|
}
|
|
|
|
if (! is_dir($this->tempBase.'/storage/installer')) {
|
|
@mkdir($this->tempBase.'/storage/installer', 0755, true);
|
|
}
|
|
|
|
// INSTALLER_RUNTIME_PATH 는 BASE_PATH 기반으로 한 번만 정의되므로,
|
|
// 첫 테스트가 끝까지 책임진다 (BASE_PATH 자체가 const 라 재정의 불가).
|
|
// installer-runtime.php 자체에 escapeEnvValue polyfill 이 있어 functions.php
|
|
// 미로드 환경에서도 mergeRuntimeIntoEnv 가 정상 동작한다.
|
|
require_once dirname(__DIR__, 3).'/public/install/includes/installer-runtime.php';
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
// skip 상태(= BASE_PATH 가 실 루트)면 파괴적 정리를 절대 수행하지 않는다.
|
|
if ($this->skipReason === '' && defined('INSTALLER_RUNTIME_PATH') && is_file(INSTALLER_RUNTIME_PATH)) {
|
|
@unlink(INSTALLER_RUNTIME_PATH);
|
|
}
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function test_read_returns_null_when_file_absent(): void
|
|
{
|
|
$this->assertFileDoesNotExist(INSTALLER_RUNTIME_PATH);
|
|
$this->assertNull(readInstallerRuntime());
|
|
}
|
|
|
|
public function test_write_creates_file_atomically(): void
|
|
{
|
|
$data = [
|
|
'db' => ['write' => ['host' => '127.0.0.1', 'port' => '3306']],
|
|
'app' => ['key' => 'base64:abc'],
|
|
];
|
|
|
|
$this->assertTrue(writeInstallerRuntime($data));
|
|
$this->assertFileExists(INSTALLER_RUNTIME_PATH);
|
|
|
|
$read = readInstallerRuntime();
|
|
$this->assertSame($data['db']['write']['host'], $read['db']['write']['host']);
|
|
$this->assertSame('base64:abc', $read['app']['key']);
|
|
}
|
|
|
|
public function test_write_then_delete_round_trip(): void
|
|
{
|
|
writeInstallerRuntime(['app' => ['key' => 'base64:xyz']]);
|
|
$this->assertFileExists(INSTALLER_RUNTIME_PATH);
|
|
|
|
$this->assertTrue(deleteInstallerRuntime());
|
|
$this->assertFileDoesNotExist(INSTALLER_RUNTIME_PATH);
|
|
|
|
// 멱등성 — 부재 상태에서 재호출 OK
|
|
$this->assertTrue(deleteInstallerRuntime());
|
|
}
|
|
|
|
public function test_read_returns_null_for_corrupted_file(): void
|
|
{
|
|
file_put_contents(INSTALLER_RUNTIME_PATH, "<?php\nreturn 'not-an-array';\n");
|
|
|
|
$this->assertNull(readInstallerRuntime());
|
|
}
|
|
|
|
public function test_generate_app_key_inline_produces_valid_laravel_format(): void
|
|
{
|
|
$key = generateAppKeyInline();
|
|
|
|
$this->assertStringStartsWith('base64:', $key);
|
|
|
|
$raw = base64_decode(substr($key, 7), true);
|
|
$this->assertNotFalse($raw, 'base64 portion must decode');
|
|
$this->assertSame(32, strlen($raw), 'AES-256-CBC requires 32-byte key');
|
|
|
|
// 매 호출마다 다른 값
|
|
$this->assertNotSame($key, generateAppKeyInline());
|
|
}
|
|
|
|
public function test_build_runtime_preserves_existing_app_key(): void
|
|
{
|
|
$existingKey = generateAppKeyInline();
|
|
writeInstallerRuntime(['app' => ['key' => $existingKey]]);
|
|
|
|
$built = buildInstallerRuntimeFromState([
|
|
'db_write_host' => 'localhost',
|
|
'db_write_database' => 'g7',
|
|
'db_write_username' => 'root',
|
|
]);
|
|
|
|
// 재시도 시 키 보존 — 새 키로 덮어쓰지 않음
|
|
$this->assertSame($existingKey, $built['app']['key']);
|
|
}
|
|
|
|
public function test_build_runtime_generates_new_app_key_when_absent(): void
|
|
{
|
|
$this->assertFileDoesNotExist(INSTALLER_RUNTIME_PATH);
|
|
|
|
$built = buildInstallerRuntimeFromState([
|
|
'db_write_host' => 'localhost',
|
|
'db_write_database' => 'g7',
|
|
'db_write_username' => 'root',
|
|
]);
|
|
|
|
$this->assertStringStartsWith('base64:', $built['app']['key']);
|
|
$this->assertSame(32, strlen(base64_decode(substr($built['app']['key'], 7), true)));
|
|
}
|
|
|
|
public function test_build_runtime_includes_separate_read_when_specified(): void
|
|
{
|
|
$built = buildInstallerRuntimeFromState([
|
|
'use_read_db' => true, // Read DB 사용 선택 (판정 SSoT)
|
|
'db_write_host' => 'write-host',
|
|
'db_write_database' => 'g7',
|
|
'db_read_host' => 'read-host',
|
|
'db_read_database' => 'g7_replica',
|
|
]);
|
|
|
|
$this->assertArrayHasKey('read', $built['db']);
|
|
$this->assertSame('read-host', $built['db']['read']['host']);
|
|
$this->assertSame('g7_replica', $built['db']['read']['database']);
|
|
}
|
|
|
|
public function test_build_runtime_omits_read_when_same_as_write(): void
|
|
{
|
|
$built = buildInstallerRuntimeFromState([
|
|
'use_read_db' => true,
|
|
'db_write_host' => 'localhost',
|
|
'db_read_host' => 'localhost', // 동일 호스트 → 별도 read 불필요
|
|
]);
|
|
|
|
$this->assertArrayNotHasKey('read', $built['db']);
|
|
}
|
|
|
|
/**
|
|
* 이슈 #63 회귀 — use_read_db=false 인데 db_read_host 에 잘못된 값이 잔존한 경우
|
|
* read 커넥션을 만들지 않아야 한다 (Laravel 이 write 로 자동 fallback).
|
|
*/
|
|
public function test_build_runtime_omits_read_when_use_read_db_false(): void
|
|
{
|
|
$built = buildInstallerRuntimeFromState([
|
|
'use_read_db' => false,
|
|
'db_write_host' => 'mysql',
|
|
'db_write_database' => 'g7',
|
|
'db_read_host' => 'l3ocalhost', // 잔존 잘못된 값 (이슈 재현값)
|
|
'db_read_database' => 'wrong_db',
|
|
]);
|
|
|
|
$this->assertArrayNotHasKey('read', $built['db']);
|
|
}
|
|
|
|
/**
|
|
* 이슈 #63 회귀 — use_read_db 키 자체가 없는 레거시 state 도
|
|
* read 를 만들지 않아야 한다 (기본 미사용).
|
|
*/
|
|
public function test_build_runtime_omits_read_when_use_read_db_absent(): void
|
|
{
|
|
$built = buildInstallerRuntimeFromState([
|
|
'db_write_host' => 'mysql',
|
|
'db_read_host' => 'read-host', // use_read_db 미지정 → read 무시
|
|
]);
|
|
|
|
$this->assertArrayNotHasKey('read', $built['db']);
|
|
}
|
|
|
|
public function test_build_runtime_falls_back_to_legacy_db_keys(): void
|
|
{
|
|
// 일부 인스톨러 경로가 db_host/db_database 등 prefix 없는 키를 사용
|
|
$built = buildInstallerRuntimeFromState([
|
|
'db_host' => 'legacy-host',
|
|
'db_database' => 'g7',
|
|
'db_username' => 'admin',
|
|
]);
|
|
|
|
$this->assertSame('legacy-host', $built['db']['write']['host']);
|
|
$this->assertSame('g7', $built['db']['write']['database']);
|
|
$this->assertSame('admin', $built['db']['write']['username']);
|
|
}
|
|
|
|
public function test_merge_replaces_app_key_line_in_env_content(): void
|
|
{
|
|
$envContent = "APP_NAME=Test\nAPP_KEY=\nDB_HOST=localhost\n";
|
|
$runtime = ['app' => ['key' => 'base64:abc']];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
$this->assertStringContainsString('APP_KEY=base64:abc', $merged);
|
|
$this->assertStringNotContainsString("APP_KEY=\n", $merged);
|
|
$this->assertStringContainsString('DB_HOST=localhost', $merged); // 다른 라인 보존
|
|
}
|
|
|
|
public function test_merge_appends_app_key_when_not_present(): void
|
|
{
|
|
$envContent = "APP_NAME=Test\nDB_HOST=localhost\n";
|
|
$runtime = ['app' => ['key' => 'base64:xyz']];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
$this->assertStringContainsString('APP_KEY=base64:xyz', $merged);
|
|
}
|
|
|
|
public function test_merge_appends_installer_completed_flag(): void
|
|
{
|
|
$envContent = "APP_KEY=base64:test\n";
|
|
$runtime = ['app' => ['key' => 'base64:test']];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
$this->assertStringContainsString('INSTALLER_COMPLETED=true', $merged);
|
|
$this->assertStringContainsString('# Installation Status', $merged);
|
|
}
|
|
|
|
public function test_merge_does_not_duplicate_installer_completed(): void
|
|
{
|
|
$envContent = "APP_KEY=base64:test\n\nINSTALLER_COMPLETED=true\n";
|
|
$runtime = ['app' => ['key' => 'base64:test']];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
// 정확히 1회만 등장
|
|
$count = substr_count($merged, 'INSTALLER_COMPLETED=true');
|
|
$this->assertSame(1, $count, 'INSTALLER_COMPLETED 가 중복되지 않아야 함');
|
|
}
|
|
|
|
public function test_merge_skips_app_key_when_invalid_format(): void
|
|
{
|
|
$envContent = "APP_KEY=existing\n";
|
|
$runtime = ['app' => ['key' => 'not-base64-format']];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
// base64: prefix 가 없으면 치환하지 않음
|
|
$this->assertStringContainsString('APP_KEY=existing', $merged);
|
|
$this->assertStringNotContainsString('APP_KEY=not-base64', $merged);
|
|
}
|
|
|
|
/**
|
|
* 회귀 테스트: state.json 결손 시에도 runtime 의 DB 정보로 .env 가 완성되어야 한다.
|
|
*
|
|
* 회귀 시나리오: setInstallationCompleteSSE 가 state.json 을 finalize 호출 전에
|
|
* 삭제 → finalize 가 generateEnvContent() 호출 시 state.config 를 못 읽어 .env.example
|
|
* 의 placeholder 값(127.0.0.1 / g7 / root / 빈 비밀번호) 으로 .env 가 작성되던 회귀.
|
|
*/
|
|
public function test_merge_replaces_db_credentials_with_runtime_values(): void
|
|
{
|
|
$envContent = <<<'ENV'
|
|
DB_WRITE_HOST=127.0.0.1
|
|
DB_WRITE_PORT=3306
|
|
DB_WRITE_DATABASE=g7
|
|
DB_WRITE_USERNAME=root
|
|
DB_WRITE_PASSWORD=
|
|
DB_PREFIX=g7_
|
|
APP_KEY=
|
|
ENV;
|
|
|
|
$runtime = [
|
|
'db' => [
|
|
'write' => [
|
|
'host' => 'real-host.example.com',
|
|
'port' => '3307',
|
|
'database' => 'real_db',
|
|
'username' => 'real_user',
|
|
'password' => 'real_pass',
|
|
],
|
|
'prefix' => 'real_',
|
|
],
|
|
'app' => ['key' => 'base64:'.base64_encode(str_repeat('a', 32))],
|
|
];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
// 값은 전부 serializeEnvValue 를 지나 따옴표로 감싸진다 (개행 주입 차단 관문 일원화).
|
|
$this->assertStringContainsString('DB_WRITE_HOST="real-host.example.com"', $merged);
|
|
$this->assertStringContainsString('DB_WRITE_PORT="3307"', $merged);
|
|
$this->assertStringContainsString('DB_WRITE_DATABASE="real_db"', $merged);
|
|
$this->assertStringContainsString('DB_WRITE_USERNAME="real_user"', $merged);
|
|
$this->assertStringContainsString('DB_WRITE_PASSWORD="real_pass"', $merged);
|
|
$this->assertStringContainsString('DB_PREFIX="real_"', $merged);
|
|
|
|
// Read 라인도 write 와 동기화되어 치환되어야 함
|
|
$this->assertStringNotContainsString('DB_WRITE_HOST=127.0.0.1', $merged);
|
|
$this->assertStringNotContainsString('DB_WRITE_DATABASE=g7'.PHP_EOL, $merged);
|
|
}
|
|
|
|
/**
|
|
* 이슈 #63 — read 미지정(use_read_db=false) 시 DB_READ_* 를 빈 값으로 남긴다.
|
|
* write 값을 복사하지 않고 config/database.php 의 Elvis fallback 에 위임한다.
|
|
*/
|
|
public function test_merge_leaves_read_empty_when_read_not_specified(): void
|
|
{
|
|
$envContent = "DB_READ_HOST=127.0.0.1\nDB_READ_DATABASE=g7\nDB_READ_USERNAME=root\nDB_READ_PASSWORD=\n";
|
|
|
|
$runtime = [
|
|
'db' => [
|
|
'write' => [
|
|
'host' => 'w-host',
|
|
'port' => '3306',
|
|
'database' => 'wdb',
|
|
'username' => 'wu',
|
|
'password' => 'wp',
|
|
],
|
|
],
|
|
];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
// read 미지정 시 DB_READ_* 는 빈 값 — write 값이 복사되면 안 됨
|
|
$this->assertStringContainsString("DB_READ_HOST=\n", $merged);
|
|
$this->assertStringContainsString("DB_READ_DATABASE=\n", $merged);
|
|
$this->assertStringContainsString("DB_READ_USERNAME=\n", $merged);
|
|
$this->assertStringNotContainsString('DB_READ_HOST="w-host"', $merged);
|
|
$this->assertStringNotContainsString('DB_READ_DATABASE="wdb"', $merged);
|
|
// write 라인은 정상 기록
|
|
$this->assertStringContainsString('DB_WRITE_HOST="w-host"', $merged);
|
|
}
|
|
|
|
public function test_merge_uses_separate_read_when_specified(): void
|
|
{
|
|
$envContent = "DB_READ_HOST=\nDB_READ_DATABASE=\n";
|
|
|
|
$runtime = [
|
|
'db' => [
|
|
'write' => ['host' => 'w-host', 'database' => 'wdb', 'username' => 'wu', 'password' => 'wp'],
|
|
'read' => ['host' => 'r-host', 'database' => 'rdb', 'username' => 'ru', 'password' => 'rp'],
|
|
],
|
|
];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
$this->assertStringContainsString('DB_READ_HOST="r-host"', $merged);
|
|
$this->assertStringContainsString('DB_READ_DATABASE="rdb"', $merged);
|
|
}
|
|
|
|
public function test_merge_appends_db_lines_when_absent_from_template(): void
|
|
{
|
|
$envContent = "APP_NAME=Test\n";
|
|
|
|
$runtime = [
|
|
'db' => [
|
|
'write' => [
|
|
'host' => 'h',
|
|
'port' => '3306',
|
|
'database' => 'd',
|
|
'username' => 'u',
|
|
'password' => 'p',
|
|
],
|
|
],
|
|
];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
// .env.example 에 라인이 없는 비표준 케이스 — 끝에 추가
|
|
$this->assertStringContainsString('DB_WRITE_HOST="h"', $merged);
|
|
$this->assertStringContainsString('DB_WRITE_DATABASE="d"', $merged);
|
|
}
|
|
|
|
public function test_merge_preserves_db_lines_when_runtime_has_no_db(): void
|
|
{
|
|
$envContent = "DB_WRITE_HOST=user-input.com\nAPP_KEY=\n";
|
|
|
|
// runtime 에 db 키 자체가 없는 케이스 (이상 상태) — 기존 .env 값 보존
|
|
$runtime = ['app' => ['key' => 'base64:'.base64_encode(str_repeat('x', 32))]];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
$this->assertStringContainsString('DB_WRITE_HOST=user-input.com', $merged);
|
|
}
|
|
|
|
/**
|
|
* 이슈 #465 불변식 — runtime.php 의 admin 섹션은 절대 `.env` 로 새어나가지 않는다.
|
|
*
|
|
* mergeRuntimeIntoEnv 는 db/app.key 만 명시 치환하는 화이트리스트 방식이다.
|
|
* 이후 누군가 "runtime 배열을 통째로 .env 로 덤프" 하는 방식으로 리팩터링하면
|
|
* 관리자 평문 비밀번호가 .env 에 영구 기록되므로, 그 회귀를 여기서 차단한다.
|
|
*/
|
|
public function test_merge_runtime_into_env_never_writes_admin_section(): void
|
|
{
|
|
$envContent = "APP_KEY=\nDB_WRITE_PASSWORD=\n";
|
|
|
|
$runtime = [
|
|
'db' => [
|
|
'write' => ['host' => 'h', 'port' => '3306', 'database' => 'd', 'username' => 'u', 'password' => 'dbpw'],
|
|
],
|
|
'app' => ['key' => 'base64:'.base64_encode(str_repeat('k', 32))],
|
|
'admin' => ['password' => 'super-secret-admin-pw'],
|
|
];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
$this->assertStringNotContainsString('super-secret-admin-pw', $merged, '관리자 평문이 .env 로 유출되면 안 됨');
|
|
$this->assertStringNotContainsString('ADMIN', $merged, 'ADMIN 계열 키가 .env 에 기록되면 안 됨');
|
|
|
|
// db/app.key 는 정상 기록 (화이트리스트가 좁아지지 않았음을 함께 고정)
|
|
$this->assertStringContainsString('DB_WRITE_PASSWORD="dbpw"', $merged);
|
|
$this->assertStringContainsString('APP_KEY=base64:', $merged);
|
|
}
|
|
|
|
public function test_merge_escapes_password_with_special_characters(): void
|
|
{
|
|
$envContent = "DB_WRITE_PASSWORD=\n";
|
|
|
|
$runtime = [
|
|
'db' => [
|
|
'write' => [
|
|
'host' => 'h',
|
|
'database' => 'd',
|
|
'username' => 'u',
|
|
'password' => 'p@ss"with$pecial',
|
|
],
|
|
],
|
|
];
|
|
|
|
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
|
|
|
|
// escapeEnvValue 가 큰따옴표/백슬래시 escape + 큰따옴표 wrapping
|
|
$this->assertStringContainsString('DB_WRITE_PASSWORD="p@ss\\"with$pecial"', $merged);
|
|
}
|
|
}
|