fix(installer): 설치 상태 파일에 관리자 비밀번호가 평문으로 잔존하는 문제

비밀을 state.json(0664, 실패 시 무기한 잔존)에 기록하지 않고
runtime.php(0600, finalize 시 삭제)로 채널을 일원화한다.

정상 완료 경로에서는 finalize 가 state.json 을 삭제하지만, finalize
미호출 / unlink 실패 / 설치 중단 시에는 평문이 영구히 남았고, finalize
가드가 재호출을 410 차단해 자가 회복도 불가능했다. 삭제라는 단일 경로에
의존해 평문을 보호하던 구조 자체를 제거한다.

SSE 워커는 세션에 접근할 수 없어 파일 경유가 불가피한데, 그 파일이 0664 의
state.json 이었던 것이 구조적 원인이다. 세션을 보유한 유일한 공통 시작점인
install-process.php 를 비밀 이송 지점으로 삼고, db_seed 가 소비한 즉시
제거한다. 레거시 / 실패 경로는 완료·중단·초기화·finalize 시점 redact 로 방어.

동반 수정:
- 세션 config 재병합으로 무력화되던 기존 DB 비밀번호 마스킹 복구
- db_seed 후 putenv 미해제로 모든 exec 자식에 평문이 ENV 상속되던 누출 차단
- runtime read 비밀번호 폴백이 write 값으로 잘못 대체되던 문제 ( 인접)

이미 설치된 사이트는 7.0.4 업그레이드 스텝이 잔존 파일을 정리한다.
완료 증거가 있으면 삭제하고, 설치 진행 중일 수 있으면 비밀만 제거한다.
This commit is contained in:
HeuJung
2026-07-13 17:00:59 +09:00
parent ae447c9cc4
commit 9be8faaea2
16 changed files with 1534 additions and 119 deletions
+1
View File
@@ -8,6 +8,7 @@
### Fixed
- 설치 과정에서 임시로 만들어지는 파일에 관리자 비밀번호가 그대로 남을 수 있던 문제를 수정했습니다. 설치가 정상적으로 끝나면 이 파일은 지워지지만, 설치를 중간에 그만두거나 마지막 정리가 이뤄지지 않은 경우에는 남아 있을 수 있었습니다. 이제 비밀번호는 이 파일에 아예 기록되지 않으며, 설치에 꼭 필요한 동안에만 접근이 제한된 별도 파일에 보관했다가 관리자 계정이 만들어지는 즉시 지워집니다. 이미 설치를 마친 사이트도 업데이트하면 남아 있던 파일이 자동으로 정리됩니다. (sir.kr 커뮤니티의 코ppp 님께서 제보해주셨습니다.)
- 화면 편집기를 작은 화면에서 열면 상단 도구 모음의 버튼 글자가 세로로 쪼개지는 등 화면이 찌그러지던 문제를 수정했습니다. 화면 편집기는 왼쪽 화면 목록과 미리보기 화면을 나란히 놓고 쓰는 도구라 넓은 화면이 필요합니다. 이제 창이 좁아도 편집기를 억지로 우겨넣지 않고 원래 크기를 유지하며, 모자란 만큼은 가로 스크롤로 넘겨 보면 됩니다.
- 모바일에서 새로고침을 연속으로 눌렀을 때 간헐적으로 "초기화 실패" 또는 "페이지 로딩 실패" 화면이 뜨던 문제를 수정했습니다. 화면을 그리는 데 필요한 요청 하나가 회선 사정으로 잠깐 끊기면 그것만으로 페이지 전체가 뜨지 않았습니다. 이제 이런 일시적인 끊김은 자동으로 다시 시도해 복구하므로, 대부분의 경우 사용자는 아무것도 느끼지 못한 채 화면이 정상적으로 열립니다.
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못한 경우, 아무 안내 없이 빈 화면만 남거나 영문 오류 문구가 그대로 노출되던 문제를 수정했습니다. 이제 무슨 일이 있었는지 알려주는 안내와 새로고침 버튼이 표시됩니다. 관리자 화면에도 동일하게 적용됩니다.
+29 -11
View File
@@ -11,17 +11,16 @@
* 실패 시: runtime.php 보존 → InstallerRuntimeServiceProvider 가 계속 동작
* → 앱은 정상 (관리자 재호출 또는 다음 부팅 시 재시도 경로 확보).
*
* @package G7\Installer
* @see https://github.com/gnuboard/g7/issues/23
*/
declare(strict_types=1);
require_once __DIR__ . '/../includes/config.php';
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/installer-runtime.php';
require_once __DIR__ . '/../includes/installer-state.php';
require_once __DIR__ . '/_guard.php';
require_once __DIR__.'/../includes/config.php';
require_once __DIR__.'/../includes/functions.php';
require_once __DIR__.'/../includes/installer-runtime.php';
require_once __DIR__.'/../includes/installer-state.php';
require_once __DIR__.'/_guard.php';
// finalize 전용 가드 — `.env` 의 INSTALLER_COMPLETED=true 단독으로만 차단한다.
// 일반 인스톨러 엔드포인트의 `installer_guard_or_410()` 은 `g7_installed` 락 파일도
// 차단 사유로 삼는데, 그 락 파일은 finalize 호출 직전 단계의 complete_flag task 가
@@ -41,7 +40,7 @@ ignore_user_abort(true);
$accepted = json_encode(['accepted' => true]);
header('Content-Type: application/json; charset=utf-8');
header('Content-Length: ' . strlen($accepted));
header('Content-Length: '.strlen($accepted));
header('Connection: close');
echo $accepted;
@@ -68,11 +67,12 @@ try {
return;
}
$envPath = BASE_PATH . '/.env';
$envPath = BASE_PATH.'/.env';
$envBase = generateEnvContent();
if ($envBase === null) {
addLog('[finalize-env] generateEnvContent() returned null — .env.example missing');
return;
}
@@ -98,6 +98,7 @@ try {
$envPath,
$lastError['message'] ?? 'unknown',
));
return;
}
@@ -134,8 +135,14 @@ try {
// state.json 삭제 — setInstallationCompleteSSE 가 본 단계로 위임함
// (finalize 가 generateEnvContent() 호출 시 state.config 가 필요했기 때문)
//
// 삭제 실패 또는 DELETE_INSTALLER_AFTER_COMPLETE=false 로 파일이 남는 모든 경로에서는
// 비밀 필드를 제거한 뒤 재저장한다 (이슈 #465). .env 머지가 이미 성공한 뒤이므로
// state.config 가 더 이상 필요하지 않아 순서상 안전하다.
$stateFilePath = BASE_PATH.'/storage/installer-state.json';
$stateDeleted = false;
if (defined('DELETE_INSTALLER_AFTER_COMPLETE') && DELETE_INSTALLER_AFTER_COMPLETE) {
$stateFilePath = BASE_PATH . '/storage/installer-state.json';
if (is_file($stateFilePath)) {
if (@unlink($stateFilePath) === false) {
addLog(sprintf(
@@ -144,11 +151,22 @@ try {
(error_get_last()['message'] ?? 'unknown'),
));
} else {
$stateDeleted = true;
addLog('[finalize-env] state.json 삭제 완료');
}
} else {
$stateDeleted = true;
}
}
} catch (\Throwable $e) {
if (! $stateDeleted && is_file($stateFilePath)) {
if (saveInstallationState(redactInstallationStateSecrets(getInstallationState()))) {
addLog('[finalize-env] state.json 잔존 — 비밀 필드 redact 완료');
} else {
addLog('[finalize-env] state.json redact 재저장 FAILED — 수동 삭제 권장: '.$stateFilePath);
}
}
} catch (Throwable $e) {
// 예외 시 runtime.php 보존 → Provider 가 계속 config 주입 → 앱 정상 동작.
addLog('[finalize-env] unexpected exception: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
addLog('[finalize-env] unexpected exception: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
}
+58 -21
View File
@@ -7,15 +7,17 @@
* 실제 설치 작업은 install-worker.php가 SSE를 통해 실행합니다.
*
* @method POST
*
* @response JSON {"status": "started", "message": "설치가 시작되었습니다"}
*/
// 필수 파일 포함 (config.php가 BASE_PATH를 정의함)
require_once __DIR__ . '/../includes/config.php';
require_once __DIR__ . '/../includes/session.php';
require_once __DIR__ . '/../includes/installer-state.php';
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/_guard.php';
require_once __DIR__.'/../includes/config.php';
require_once __DIR__.'/../includes/session.php';
require_once __DIR__.'/../includes/installer-state.php';
require_once __DIR__.'/../includes/installer-runtime.php';
require_once __DIR__.'/../includes/functions.php';
require_once __DIR__.'/_guard.php';
installer_guard_or_410();
// 다국어 로드
@@ -74,7 +76,6 @@ try {
'app_url',
'admin_name',
'admin_email',
'admin_password',
];
$missingFields = [];
@@ -84,7 +85,24 @@ try {
}
}
if (!empty($missingFields)) {
/**
* admin_password 는 state.config 에 기록되지 않으므로(이슈 #465) 3중 출처로 판정한다.
*
* 1. 세션 config — 일반 경로 (Step 3 입력 직후)
* 2. runtime.php 의 admin 섹션 — 세션 유실 후 재시도/재개
* 3. db_seed 완료 마커 — 이미 관리자 계정이 생성되어 비밀번호가 더 이상 불필요
*
* 셋 다 없으면 Step 3 재입력이 필요한 상태 (의도된 안전 실패).
*/
$hasAdminPassword = ! empty($config['admin_password'])
|| ! empty(readInstallerRuntime()['admin']['password'] ?? '')
|| in_array('db_seed', getInstallationState()['completed_tasks'] ?? [], true);
if (! $hasAdminPassword) {
$missingFields[] = 'admin_password';
}
if (! empty($missingFields)) {
http_response_code(400);
echo json_encode([
'success' => false,
@@ -97,11 +115,11 @@ try {
* 필수 파일 존재 여부 사전 체크
*/
$missingRequiredFiles = [];
if (!file_exists(BASE_PATH . '/.env')) {
if (! file_exists(BASE_PATH.'/.env')) {
$missingRequiredFiles[] = '.env';
}
if (!empty($missingRequiredFiles)) {
if (! empty($missingRequiredFiles)) {
http_response_code(400);
echo json_encode([
'success' => false,
@@ -124,7 +142,7 @@ try {
*/
$requestBody = [];
$rawInput = file_get_contents('php://input');
if (!empty($rawInput)) {
if (! empty($rawInput)) {
$decoded = json_decode($rawInput, true);
if (is_array($decoded)) {
$requestBody = $decoded;
@@ -134,7 +152,7 @@ try {
$installationMode = $requestBody['installation_mode']
?? $_POST['installation_mode']
?? 'sse';
if (!in_array($installationMode, ['sse', 'polling'], true)) {
if (! in_array($installationMode, ['sse', 'polling'], true)) {
$installationMode = 'sse';
}
@@ -146,7 +164,7 @@ try {
$existingDbAction = $requestBody['existing_db_action']
?? $_POST['existing_db_action']
?? 'skip';
if (!in_array($existingDbAction, ['skip', 'drop_tables'], true)) {
if (! in_array($existingDbAction, ['skip', 'drop_tables'], true)) {
$existingDbAction = 'skip';
}
@@ -166,11 +184,30 @@ try {
$isRetry = isset($state['installation_status']) &&
in_array($state['installation_status'], ['running', 'failed', 'aborted']);
/**
* 비밀 채널 일원화 (이슈 #465)
*
* DB/관리자 비밀번호는 state.json(0664, 실패 시 무기한 잔존) 이 아니라
* storage/installer/runtime.php(0600, finalize 시 삭제) 로만 전달한다.
* SSE 워커는 세션에 접근할 수 없으므로 파일 경유가 불가피하며, 세션을 보유한
* 유일한 공통 시작점인 본 엔드포인트가 이송 지점이다 (SSE/폴링 공통, 재시도 포함).
*
* buildInstallerRuntimeFromState() 가 기존 runtime 의 비밀번호/APP_KEY 를 보존하므로
* 세션 유실 후 재개 시에도 자격증명이 유지된다.
*/
$runtime = buildInstallerRuntimeFromState($config);
if (! empty($config['admin_password'])) {
$runtime['admin'] = ['password' => $config['admin_password']];
}
if (! writeInstallerRuntime($runtime)) {
throw new Exception(lang('state_save_failed'));
}
$state['current_step'] = 5; // Step 5 = Installation (Step 4 = Extension Selection)
$state['installation_status'] = 'running';
$state['completed_tasks'] = $isRetry ? ($state['completed_tasks'] ?? []) : [];
$state['current_task'] = null;
$state['config'] = $config;
$state['config'] = sanitizeConfigForState($config);
$state['error'] = null;
$state['installation_mode'] = $installationMode;
@@ -185,8 +222,8 @@ try {
* Step 4 -> Step 5로 새로 진입하는 경우 이전 로그를 초기화합니다.
* 재시도/재개인 경우에는 기존 로그를 유지합니다.
*/
if (!$isRetry) {
$logFilePath = BASE_PATH . '/storage/logs/installation.log';
if (! $isRetry) {
$logFilePath = BASE_PATH.'/storage/logs/installation.log';
if (file_exists($logFilePath)) {
@unlink($logFilePath);
}
@@ -197,7 +234,7 @@ try {
*/
$saved = saveInstallationState($state);
if (!$saved) {
if (! $saved) {
throw new Exception(lang('state_save_failed'));
}
@@ -263,7 +300,7 @@ try {
// 응답 헤더 — 브라우저가 응답 완료를 인식하도록 Content-Length + Connection: close
http_response_code(200);
header('Content-Type: application/json; charset=UTF-8');
header('Content-Length: ' . strlen($responseJson));
header('Content-Length: '.strlen($responseJson));
header('Connection: close');
echo $responseJson;
@@ -290,7 +327,7 @@ try {
error_reporting(E_ALL);
addLog('=== Install Worker Polling Started ===');
addLog('Client IP: ' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
addLog('Client IP: '.($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
// 클라이언트가 SSE 호환성 사전 체크 후 폴링 모드를 명시 선택한 흐름이다.
// SSE 워커가 동시 실행되지 않으므로 takeover 로직 불필요 — 일반 lock 획득.
@@ -301,13 +338,13 @@ try {
}
$workerId = $lockResult['worker_id'];
addLog('Worker lock acquired: ' . $workerId . ' (reason: ' . $lockResult['reason'] . ')');
addLog('Worker lock acquired: '.$workerId.' (reason: '.$lockResult['reason'].')');
register_shutdown_function('releaseWorkerLock', $workerId);
// 폴링 모드 — NullEmitter 등록 후 task runner 실행
require_once __DIR__ . '/../includes/progress-emitter.php';
require_once __DIR__ . '/../includes/task-runner.php';
require_once __DIR__.'/../includes/progress-emitter.php';
require_once __DIR__.'/../includes/task-runner.php';
setProgressEmitter(new NullEmitter($workerId));
runInstallationTasks();
+65 -45
View File
@@ -1,4 +1,5 @@
<?php
/**
* G7 인스톨러 - 롤백 함수 모음
*
@@ -8,8 +9,8 @@
/**
* 중단된 작업을 롤백합니다.
*
* @param string $taskId 롤백할 작업 식별자
* @param array $state 현재 설치 상태
* @param string $taskId 롤백할 작업 식별자
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string] 롤백 결과
*/
function rollbackTask(string $taskId, array $state): array
@@ -25,7 +26,7 @@ function rollbackTask(string $taskId, array $state): array
case 'complete_flag':
return rollbackCompleteFlag();
// 롤백 불필요한 작업들 (새로 생성하면 되는 것들)
// 롤백 불필요한 작업들 (새로 생성하면 되는 것들)
case 'composer_check':
case 'composer_install':
case 'env_create':
@@ -38,7 +39,7 @@ function rollbackTask(string $taskId, array $state): array
'message' => lang('rollback_not_needed_recreatable'),
];
// 롤백 불필요한 확장 작업 (재설치/재활성화 시 덮어쓰기 가능)
// 롤백 불필요한 확장 작업 (재설치/재활성화 시 덮어쓰기 가능)
case 'template_install':
case 'template_activate':
case 'module_install':
@@ -55,14 +56,15 @@ function rollbackTask(string $taskId, array $state): array
default:
return [
'success' => false,
'message' => lang('rollback_unknown_task', ['task' => $taskId])
'message' => lang('rollback_unknown_task', ['task' => $taskId]),
];
}
} catch (Exception $e) {
addLog(lang('rollback_error', ['task' => $taskId, 'error' => $e->getMessage()]));
return [
'success' => false,
'message' => lang('rollback_exception', ['error' => $e->getMessage()])
'message' => lang('rollback_exception', ['error' => $e->getMessage()]),
];
}
}
@@ -70,14 +72,14 @@ function rollbackTask(string $taskId, array $state): array
/**
* 마이그레이션 롤백을 실행합니다 (migrate:rollback --force)
*
* @param string|null $logPrefix 로그 접두사 (null이면 lang('log_prefix_rollback') 사용)
* @param string|null $logPrefix 로그 접두사 (null이면 lang('log_prefix_rollback') 사용)
* @return array ['success' => bool, 'message' => string, 'output' => array]
*/
function executeMigrateRollback(?string $logPrefix = null): array
{
$logPrefix = $logPrefix ?? lang('log_prefix_rollback');
try {
addLog("{$logPrefix} " . lang('rollback_migrate_start'));
addLog("{$logPrefix} ".lang('rollback_migrate_start'));
$output = [];
$returnCode = 0;
@@ -90,7 +92,7 @@ function executeMigrateRollback(?string $logPrefix = null): array
exec($command, $output, $returnCode);
addLog("{$logPrefix} " . lang('rollback_migrate_result', ['result' => implode("\n", $output)]));
addLog("{$logPrefix} ".lang('rollback_migrate_result', ['result' => implode("\n", $output)]));
if ($returnCode === 0) {
return [
@@ -100,7 +102,7 @@ function executeMigrateRollback(?string $logPrefix = null): array
];
}
addLog("{$logPrefix} " . lang('rollback_migrate_failed_code', ['code' => $returnCode]));
addLog("{$logPrefix} ".lang('rollback_migrate_failed_code', ['code' => $returnCode]));
return [
'success' => false,
@@ -129,16 +131,24 @@ function rollbackDbMigrate(): array
/**
* 시드 데이터 TRUNCATE를 실행합니다 (PDO 세션 내 FOREIGN_KEY_CHECKS 제어)
*
* @param array $config DB 연결 설정
* @param bool $force true: 무조건 TRUNCATE, false: 미완료 시에만 TRUNCATE
* @param string|null $logPrefix 로그 접두사 (null이면 lang('log_prefix_rollback') 사용)
* @param array $config DB 연결 설정
* @param bool $force true: 무조건 TRUNCATE, false: 미완료 시에만 TRUNCATE
* @param string|null $logPrefix 로그 접두사 (null이면 lang('log_prefix_rollback') 사용)
* @return array ['success' => bool, 'message' => string]
*/
function executeSeedTruncate(array $config, bool $force = false, ?string $logPrefix = null): array
{
// state.config 에는 DB 비밀번호가 기록되지 않으므로(이슈 #465) runtime.php 에서 복원한다.
// 호출자(rollbackDbSeed / forceRollbackDbSeed) 가 state['config'] 를 그대로 넘기므로
// 각 호출자 대신 함수 진입부 1곳에서 처리한다. 롤백은 env_update 이후에만 발생하여
// runtime.php 가 항상 존재하며, 부재 시 헬퍼가 원본 config 를 그대로 반환한다.
require_once __DIR__.'/../includes/installer-runtime.php';
$config = hydrateDbSecretsFromRuntime($config);
$logPrefix = $logPrefix ?? lang('log_prefix_rollback');
if (empty($config['db_write_host']) || empty($config['db_write_database'])) {
addLog("{$logPrefix} " . lang('rollback_seed_no_config'));
addLog("{$logPrefix} ".lang('rollback_seed_no_config'));
return ['success' => true, 'message' => lang('rollback_seed_no_config')];
}
@@ -146,7 +156,8 @@ function executeSeedTruncate(array $config, bool $force = false, ?string $logPre
foreach (['db_write_host', 'db_write_port', 'db_write_database'] as $field) {
$val = (string) ($config[$field] ?? '');
if ($val !== '' && preg_match('/[;=\0\r\n]/', $val)) {
addLog("{$logPrefix} " . lang('rollback_seed_no_config'));
addLog("{$logPrefix} ".lang('rollback_seed_no_config'));
return ['success' => true, 'message' => lang('rollback_seed_no_config')];
}
}
@@ -164,18 +175,19 @@ function executeSeedTruncate(array $config, bool $force = false, ?string $logPre
]);
// 강제 모드가 아니면 완료 여부 확인
if (!$force) {
if (! $force) {
$stmt = $pdo->query("SHOW TABLES LIKE 'roles'");
if ($stmt->rowCount() > 0) {
$count = $pdo->query("SELECT COUNT(*) FROM roles")->fetchColumn();
$count = $pdo->query('SELECT COUNT(*) FROM roles')->fetchColumn();
if ($count > 0) {
addLog("{$logPrefix} " . lang('rollback_seed_already_done'));
addLog("{$logPrefix} ".lang('rollback_seed_already_done'));
return ['success' => true, 'message' => lang('rollback_seed_already_done')];
}
}
addLog("{$logPrefix} " . lang('rollback_seed_interrupted'));
addLog("{$logPrefix} ".lang('rollback_seed_interrupted'));
} else {
addLog("{$logPrefix} " . lang('rollback_seed_force_truncate'));
addLog("{$logPrefix} ".lang('rollback_seed_force_truncate'));
}
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
@@ -184,9 +196,9 @@ function executeSeedTruncate(array $config, bool $force = false, ?string $logPre
foreach ($tables as $table) {
try {
$pdo->exec("TRUNCATE TABLE `{$table}`");
addLog("{$logPrefix} " . lang('rollback_table_truncated', ['table' => $table]));
addLog("{$logPrefix} ".lang('rollback_table_truncated', ['table' => $table]));
} catch (PDOException $e) {
addLog("{$logPrefix} " . lang('rollback_table_truncate_skipped', ['table' => $table, 'error' => $e->getMessage()]));
addLog("{$logPrefix} ".lang('rollback_table_truncate_skipped', ['table' => $table, 'error' => $e->getMessage()]));
}
}
@@ -197,7 +209,8 @@ function executeSeedTruncate(array $config, bool $force = false, ?string $logPre
if ($force) {
return ['success' => false, 'message' => lang('rollback_db_connection_failed', ['error' => $e->getMessage()])];
}
addLog("{$logPrefix} " . lang('rollback_db_connection_failed_skip'));
addLog("{$logPrefix} ".lang('rollback_db_connection_failed_skip'));
return ['success' => true, 'message' => lang('rollback_db_connection_failed_skip')];
}
}
@@ -205,7 +218,7 @@ function executeSeedTruncate(array $config, bool $force = false, ?string $logPre
/**
* db_seed 작업 롤백: 중단된 경우에만 TRUNCATE
*
* @param array $state 현재 설치 상태
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string]
*/
function rollbackDbSeed(array $state): array
@@ -230,7 +243,7 @@ function forceRollbackDbMigrate(): array
/**
* db_seed 작업 강제 롤백 (설정으로 돌아갈 때 사용)
*
* @param array $state 현재 설치 상태
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string]
*/
function forceRollbackDbSeed(array $state): array
@@ -252,10 +265,10 @@ function forceRollbackDbSeed(array $state): array
function rollbackCompleteFlag(): array
{
try {
require_once __DIR__ . '/../includes/installer-runtime.php';
require_once __DIR__.'/../includes/installer-runtime.php';
$envPath = BASE_PATH . '/.env';
$installedFlagPath = BASE_PATH . '/storage/app/g7_installed';
$envPath = BASE_PATH.'/.env';
$installedFlagPath = BASE_PATH.'/storage/app/g7_installed';
$results = [];
@@ -289,12 +302,12 @@ function rollbackCompleteFlag(): array
return [
'success' => true,
'message' => lang('rollback_complete_flag_removed', ['details' => implode(', ', $results)])
'message' => lang('rollback_complete_flag_removed', ['details' => implode(', ', $results)]),
];
} catch (Exception $e) {
return [
'success' => false,
'message' => lang('rollback_complete_flag_error', ['error' => $e->getMessage()])
'message' => lang('rollback_complete_flag_error', ['error' => $e->getMessage()]),
];
}
}
@@ -306,13 +319,13 @@ function rollbackCompleteFlag(): array
* 이는 인스톨러가 프로젝트 구조에 필수적인 파일/디렉토리를
* 실수로 삭제하는 것을 방지합니다.
*
* @param string $dir 삭제할 디렉토리 경로
* @param bool $removeDir true이면 디렉토리 자체도 삭제 (기본: false, 내용만 삭제)
* @param string $dir 삭제할 디렉토리 경로
* @param bool $removeDir true이면 디렉토리 자체도 삭제 (기본: false, 내용만 삭제)
* @return bool 삭제 성공 여부
*/
function deleteDirectory(string $dir, bool $removeDir = false): bool
{
if (!is_dir($dir)) {
if (! is_dir($dir)) {
return false;
}
@@ -323,7 +336,7 @@ function deleteDirectory(string $dir, bool $removeDir = false): bool
$hasPreservedFiles = false;
foreach ($items as $item) {
$path = $dir . DIRECTORY_SEPARATOR . $item;
$path = $dir.DIRECTORY_SEPARATOR.$item;
if (is_dir($path)) {
deleteDirectory($path, true);
@@ -331,6 +344,7 @@ function deleteDirectory(string $dir, bool $removeDir = false): bool
// 보존 대상 파일은 건너뛰기
if (in_array($item, $preserveFiles, true)) {
$hasPreservedFiles = true;
continue;
}
@unlink($path);
@@ -338,7 +352,7 @@ function deleteDirectory(string $dir, bool $removeDir = false): bool
}
// 보존 파일이 남아있거나 removeDir이 false이면 디렉토리 유지
if ($hasPreservedFiles || !$removeDir) {
if ($hasPreservedFiles || ! $removeDir) {
return true;
}
@@ -348,15 +362,16 @@ function deleteDirectory(string $dir, bool $removeDir = false): bool
/**
* 현재 진행 중인 작업을 롤백합니다. (abort.php에서 사용)
*
* @param array $state 현재 설치 상태
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string, 'task' => string|null]
*/
function rollbackCurrentTask(array $state): array
{
$currentTask = $state['current_task'] ?? null;
if (!$currentTask) {
if (! $currentTask) {
addLog(lang('rollback_no_current_task'));
return [
'success' => true,
'message' => lang('rollback_no_current_task'),
@@ -366,6 +381,7 @@ function rollbackCurrentTask(array $state): array
if (in_array($currentTask, $state['completed_tasks'] ?? [])) {
addLog(lang('rollback_task_already_completed', ['task' => $currentTask]));
return [
'success' => true,
'message' => lang('rollback_task_already_completed', ['task' => $currentTask]),
@@ -378,6 +394,7 @@ function rollbackCurrentTask(array $state): array
if ($rollbackResult['success']) {
addLog(lang('abort_rollback_success', ['message' => $rollbackResult['message']]));
return [
'success' => true,
'message' => lang('abort_rollback_success', ['message' => $rollbackResult['message']]),
@@ -385,6 +402,7 @@ function rollbackCurrentTask(array $state): array
];
} else {
addLog(lang('abort_rollback_failed', ['message' => $rollbackResult['message']]));
return [
'success' => false,
'message' => lang('failed_rollback_failed', ['message' => $rollbackResult['message']]),
@@ -396,9 +414,9 @@ function rollbackCurrentTask(array $state): array
/**
* 완료된 작업들을 역순으로 롤백합니다. (reset-state.php에서 사용)
*
* @param array $completedTasks 완료된 작업 목록
* @param array $rollbackableTasks 롤백 가능한 작업 목록
* @param array $state 현재 설치 상태
* @param array $completedTasks 완료된 작업 목록
* @param array $rollbackableTasks 롤백 가능한 작업 목록
* @param array $state 현재 설치 상태
* @return array ['results' => array, 'errors' => array]
*/
function rollbackCompletedTasks(array $completedTasks, array $rollbackableTasks, array $state): array
@@ -408,6 +426,7 @@ function rollbackCompletedTasks(array $completedTasks, array $rollbackableTasks,
if (empty($completedTasks)) {
addLog(lang('rollback_no_completed_tasks'));
return ['results' => $results, 'errors' => $errors];
}
@@ -419,6 +438,7 @@ function rollbackCompletedTasks(array $completedTasks, array $rollbackableTasks,
if (empty($tasksToRollback)) {
addLog(lang('rollback_no_matching_tasks'));
return ['results' => $results, 'errors' => $errors];
}
@@ -436,10 +456,10 @@ function rollbackCompletedTasks(array $completedTasks, array $rollbackableTasks,
}
if ($result['success']) {
$results[] = $taskId . ': ' . $result['message'];
$results[] = $taskId.': '.$result['message'];
addLog(lang('rollback_task_success', ['task' => $taskId]));
} else {
$errors[] = $taskId . ': ' . $result['message'];
$errors[] = $taskId.': '.$result['message'];
addLog(lang('rollback_task_failed', ['task' => $taskId, 'error' => $result['message']]));
}
}
@@ -450,8 +470,8 @@ function rollbackCompletedTasks(array $completedTasks, array $rollbackableTasks,
/**
* 실패한 작업에 대한 수동 명령어를 생성합니다.
*
* @param string $taskId 작업 ID
* @param string|null $target 대상 (확장 식별자)
* @param string $taskId 작업 ID
* @param string|null $target 대상 (확장 식별자)
* @return array 수동 명령어 배열
*/
function getManualCommands(string $taskId, ?string $target = null): array
@@ -530,7 +550,7 @@ function getManualCommands(string $taskId, ?string $target = null): array
break;
case 'create_settings_json':
$commands[] = '# ' . lang('manual_cmd_settings_json');
$commands[] = '# '.lang('manual_cmd_settings_json');
break;
case 'cache_clear':
+8
View File
@@ -207,6 +207,11 @@ class StateManagementApi
$state['manual_commands'] = null;
$state['last_updated'] = gmdate('Y-m-d\TH:i:s\Z');
// 보존되는 config 에서 비밀 제거 (이슈 #465) — 초기화 후 무기한 잔존 가능하므로
// 평문이 남지 않아야 한다. 비밀번호는 runtime.php 에 있고, 재설치 시 Step 3
// 재입력 또는 runtime 폴백으로 복원된다.
$state = redactInstallationStateSecrets($state);
// 상태 저장
$saved = saveInstallationState($state);
@@ -311,6 +316,9 @@ class StateManagementApi
}
// 세션도 Step 5를 유지 (중단 화면 표시)
// 중단 상태는 무기한 잔존 가능 — 보존되는 config 에서 비밀 제거 (이슈 #465)
$state = redactInstallationStateSecrets($state);
$saveResult = saveInstallationState($state);
addLog(lang('abort_api_save_result', ['result' => $saveResult ? 'success' : 'failed']));
+67 -2
View File
@@ -249,6 +249,14 @@ if (! function_exists('buildInstallerRuntimeFromState')) {
$appKey = generateAppKeyInline();
}
// state.config 에는 더 이상 비밀번호가 기록되지 않으므로(이슈 #465), 세션이 유실된
// 재개/재시도 경로에서는 기존 runtime.php 의 값을 보존해야 한다. app.key 보존과
// 동일한 패턴.
$writePassword = (string) ($stateConfig['db_write_password'] ?? ($stateConfig['db_password'] ?? ''));
if ($writePassword === '') {
$writePassword = (string) ($existing['db']['write']['password'] ?? '');
}
$runtime = [
'db' => [
'write' => [
@@ -256,7 +264,7 @@ if (! function_exists('buildInstallerRuntimeFromState')) {
'port' => $stateConfig['db_write_port'] ?? ($stateConfig['db_port'] ?? '3306'),
'database' => $stateConfig['db_write_database'] ?? ($stateConfig['db_database'] ?? ''),
'username' => $stateConfig['db_write_username'] ?? ($stateConfig['db_username'] ?? ''),
'password' => $stateConfig['db_write_password'] ?? ($stateConfig['db_password'] ?? ''),
'password' => $writePassword,
],
'prefix' => $stateConfig['db_prefix'] ?? '',
],
@@ -266,6 +274,13 @@ if (! function_exists('buildInstallerRuntimeFromState')) {
'created_at' => date('c'),
];
// admin 비밀번호는 db_seed 가 소비할 때까지 runtime 에 보존한다. 호출자
// (install-process.php) 가 세션 값으로 덮어쓰는 경우를 제외하면, 재시도/재개 시
// 기존 runtime 의 값이 유실되지 않아야 한다 (유실 시 db_seed 재실행 불가).
if (isset($existing['admin']) && is_array($existing['admin'])) {
$runtime['admin'] = $existing['admin'];
}
// Read 커넥션은 use_read_db 플래그가 켜진 경우에만 포함한다 (판정 SSoT).
// 플래그가 꺼져 있으면 db_read_host 에 잔존 값이 있어도 무시 → runtime 에 read 키
// 미생성 → 하류(mergeRuntimeIntoEnv / InstallerRuntimeServiceProvider)가 write 로
@@ -273,15 +288,65 @@ if (! function_exists('buildInstallerRuntimeFromState')) {
if (! empty($stateConfig['use_read_db'])
&& ! empty($stateConfig['db_read_host'])
&& $stateConfig['db_read_host'] !== ($stateConfig['db_write_host'] ?? null)) {
// read 비밀번호 폴백 순서: state.config → 기존 runtime 의 read 비밀번호 →
// write 비밀번호. 기존 runtime 을 건너뛰고 write 값으로 대체하면 read 전용
// 계정의 비밀번호가 write 값으로 오염된다 (이슈 #465 부수 수정).
$readPassword = (string) ($stateConfig['db_read_password'] ?? '');
if ($readPassword === '') {
$readPassword = (string) ($existing['db']['read']['password'] ?? '');
}
if ($readPassword === '') {
$readPassword = (string) $runtime['db']['write']['password'];
}
$runtime['db']['read'] = [
'host' => $stateConfig['db_read_host'],
'port' => $stateConfig['db_read_port'] ?? $runtime['db']['write']['port'],
'database' => $stateConfig['db_read_database'] ?? $runtime['db']['write']['database'],
'username' => $stateConfig['db_read_username'] ?? $runtime['db']['write']['username'],
'password' => $stateConfig['db_read_password'] ?? $runtime['db']['write']['password'],
'password' => $readPassword,
];
}
return $runtime;
}
}
if (! function_exists('hydrateDbSecretsFromRuntime')) {
/**
* state.config 에서 제거된 DB 비밀번호를 runtime.php 값으로 채운다 (이슈 #465).
*
* state.json 은 더 이상 DB 비밀번호를 보관하지 않으므로, state.config 로 DB 에
* 접속하던 소비처(db_cleanup / 롤백 seed truncate) 는 이 헬퍼로 자격증명을 복원해야
* 한다. 두 소비처 모두 env_update 태스크 이후에 실행되므로 runtime.php 는 항상 존재.
*
* runtime 부재 또는 config 에 이미 비밀번호가 있으면 원본을 그대로 반환.
*
* @param array<string, mixed> $config state.config (비밀번호 결손 가능)
* @return array<string, mixed> DB 비밀번호가 복원된 config
*/
function hydrateDbSecretsFromRuntime(array $config): array
{
$runtime = readInstallerRuntime();
if ($runtime === null) {
return $config;
}
if (empty($config['db_write_password'])) {
$writePassword = $runtime['db']['write']['password'] ?? '';
if ($writePassword !== '') {
$config['db_write_password'] = $writePassword;
}
}
if (empty($config['db_read_password'])) {
$readPassword = $runtime['db']['read']['password'] ?? '';
if ($readPassword !== '') {
$config['db_read_password'] = $readPassword;
}
}
return $config;
}
}
@@ -146,6 +146,82 @@ function saveInstallationState(array $state): bool
return true;
}
if (! function_exists('installerSecretConfigKeys')) {
/**
* state.json 에 절대 기록해서는 안 되는 config 비밀 키 목록.
*
* 이 키들의 값은 storage/installer/runtime.php (0600) 로만 전달한다.
* state.json 은 0664 라 웹 서버 그룹 전체가 읽을 수 있고, 설치 실패/중단 시
* 무기한 잔존하므로 평문 비밀의 보관처로 부적합하다 (이슈 #465).
*
* @return array<int, string> 비밀 config 키 목록
*/
function installerSecretConfigKeys(): array
{
return [
'db_write_password',
'db_read_password',
'admin_password',
'admin_password_confirm',
];
}
}
if (! function_exists('sanitizeConfigForState')) {
/**
* state.json 에 저장하기 전 config 배열을 정제한다.
*
* 1) 비밀 4종 제거 (이슈 #465)
* 2) use_read_db 미사용 시 read 접속 필드 잔존값 제거 (이슈 #63 2단계 방어)
* — use_read_db=false 인데 db_read_host 등이 남아 있으면 하류
* (installer-runtime.php) 로 유입되어 잘못된 read 커넥션을 만들 수 있다.
*
* @param array<string, mixed> $config 폼/세션에서 온 원본 config
* @return array<string, mixed> state 저장용 안전 config
*/
function sanitizeConfigForState(array $config): array
{
foreach (installerSecretConfigKeys() as $secretKey) {
unset($config[$secretKey]);
}
if (empty($config['use_read_db'])) {
unset(
$config['db_read_host'],
$config['db_read_port'],
$config['db_read_database'],
$config['db_read_username']
);
}
return $config;
}
}
if (! function_exists('redactInstallationStateSecrets')) {
/**
* 이미 기록된 state 배열에서 비밀 4종만 제거한다 (레거시/실패 경로 방어).
*
* sanitizeConfigForState 와 달리 read 접속 필드(비밀 아님) 는 건드리지 않는다 —
* 롤백/db_cleanup 이 그 값을 사용하기 때문.
*
* @param array<string, mixed> $state 현재 인스톨러 state
* @return array<string, mixed> 비밀이 제거된 state
*/
function redactInstallationStateSecrets(array $state): array
{
if (! isset($state['config']) || ! is_array($state['config'])) {
return $state;
}
foreach (installerSecretConfigKeys() as $secretKey) {
unset($state['config'][$secretKey]);
}
return $state;
}
}
/**
* 설치 완료 여부 확인
*
+17 -16
View File
@@ -16,6 +16,15 @@ function validateInstallationFlow(int $currentStep, array $state): void
{
// 설치 완료 시 홈으로 리다이렉트
if (isInstallationCompleted()) {
// 세션에 남은 설치 config (DB/관리자 비밀번호 포함) 를 정리한다 (이슈 #465).
// 설치가 끝나면 세션 config 는 소비처가 없으므로 세션 파일에 평문이 잔존할 이유가
// 없다. 완료 후 인스톨러에 재접근하는 모든 경로가 이 지점을 통과한다.
unset(
$_SESSION['install_config'],
$_SESSION['db_write_tested'],
$_SESSION['db_read_tested']
);
$translations = loadTranslations(getCurrentLanguage());
showInstallationCompletedAlert();
}
@@ -192,23 +201,15 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
// 검증 통과 시 다음 단계로 이동
if (empty($errors)) {
$_SESSION['install_config'] = $formData;
// 세션에는 비밀번호를 유지한다 (install-process.php 가 runtime.php 로 이송).
// 단 admin_password_confirm 은 검증 이후 소비처가 전무한 순수 잔여물이므로 제거.
$sessionConfig = $formData;
unset($sessionConfig['admin_password_confirm']);
$_SESSION['install_config'] = $sessionConfig;
// state.json에는 비밀번호를 저장하지 않음 (보안)
$safeFormData = $formData;
unset($safeFormData['db_write_password'], $safeFormData['db_read_password']);
// Read DB 미사용 시 read 필드 잔존값을 제거한다 (이슈 #63 2단계 방어).
// use_read_db=false 인데 db_read_host 등이 남아 있으면 하류(installer-runtime.php)
// 로 유입되어 잘못된 read 커넥션을 만들 수 있으므로 원천 차단한다.
if (empty($safeFormData['use_read_db'])) {
unset(
$safeFormData['db_read_host'],
$safeFormData['db_read_port'],
$safeFormData['db_read_database'],
$safeFormData['db_read_username']
);
}
// state.json 에는 비밀(DB/관리자 비밀번호) 을 저장하지 않는다 (이슈 #465).
// read 필드 정리(이슈 #63 2단계 방어) 도 sanitizeConfigForState 가 함께 수행.
$safeFormData = sanitizeConfigForState($formData);
updateStepStatus(3, 4, [
'config' => $safeFormData,
+56 -4
View File
@@ -931,7 +931,9 @@ if (! function_exists('cleanupExistingTablesSSE')) {
return ['success' => true];
}
$config = $state['config'] ?? [];
// state.config 에는 DB 비밀번호가 기록되지 않으므로(이슈 #465) runtime.php 에서
// 자격증명을 복원한다. db_cleanup 은 env_update 이후 실행되어 runtime 이 항상 존재.
$config = hydrateDbSecretsFromRuntime($state['config'] ?? []);
try {
$pdo = getDatabaseConnection($config, false);
@@ -1000,22 +1002,69 @@ if (! function_exists('runDatabaseSeedingSSE')) {
$state = getInstallationState();
$config = $state['config'] ?? [];
if (empty($config['admin_email']) || empty($config['admin_password'])) {
// 관리자 비밀번호는 runtime.php(0600) 가 SSoT (이슈 #465). state.config 폴백은
// 이 수정 이전에 시작된 설치(레거시 state) 를 위한 한시 경로.
$adminPassword = readInstallerRuntime()['admin']['password'] ?? ($config['admin_password'] ?? '');
if (empty($config['admin_email']) || empty($adminPassword)) {
return ['success' => false, 'error' => '관리자 이메일과 비밀번호가 설정되지 않았습니다.'];
}
// 이름/이메일/언어는 비밀이 아니므로 state.config 에 그대로 둔다.
putenv('INSTALLER_ADMIN_NAME='.($config['admin_name'] ?? 'Administrator'));
putenv('INSTALLER_ADMIN_EMAIL='.$config['admin_email']);
putenv('INSTALLER_ADMIN_PASSWORD='.$config['admin_password']);
putenv('INSTALLER_ADMIN_PASSWORD='.$adminPassword);
putenv('INSTALLER_ADMIN_LANGUAGE='.($config['admin_language'] ?? $state['g7_locale'] ?? 'ko'));
return executeDbCommandSSE(
$result = executeDbCommandSSE(
artisanCommand: 'db:seed --force',
taskId: 'db_seed',
taskNameKey: 'task_db_seed',
successMsgKey: 'log_db_seed_success',
errorMsgKey: 'error_db_seed_failed'
);
if (($result['success'] ?? false) === true) {
purgeAdminPasswordAfterSeeding();
}
return $result;
}
}
if (! function_exists('purgeAdminPasswordAfterSeeding')) {
/**
* db_seed 성공 직후 관리자 비밀번호의 모든 잔존처를 제거한다 (이슈 #465).
*
* 시딩이 끝나면 관리자 계정이 DB 에 해시로 존재하므로 평문은 더 이상 필요 없다.
* 노출 창을 최소화하기 위해 소비 즉시 삭제한다:
*
* 1. runtime.php 의 admin 섹션 (0600 이지만 finalize 까지 잔존)
* 2. state.json 의 비밀 4종 (이 수정 이전에 시작된 레거시 설치 대비)
* 3. process ENV — 해제하지 않으면 이후 모든 exec() 자식(확장 설치 artisan 전부)
* 이 평문을 상속받는다
*
* 재시도 안전성: db_seed 는 completed_tasks 마커로 스킵된다. drop_tables 재시도로
* 마커가 제거되는 경로는 반드시 install-process.php 를 다시 거치므로 세션 또는
* runtime 에서 비밀번호가 재주입된다.
*/
function purgeAdminPasswordAfterSeeding(): void
{
$runtime = readInstallerRuntime();
if (is_array($runtime) && isset($runtime['admin'])) {
unset($runtime['admin']);
writeInstallerRuntime($runtime);
}
// executeDbCommandSSE 가 markTaskCompleted 로 state 를 갱신했으므로 재로드 후
// redact 한다 (스냅샷을 덮어쓰면 completed 마커가 유실됨).
saveInstallationState(redactInstallationStateSecrets(getInstallationState()));
// 값 없이 키만 전달 → 해제. 자식 프로세스 ENV 상속 차단.
putenv('INSTALLER_ADMIN_PASSWORD');
putenv('INSTALLER_ADMIN_NAME');
putenv('INSTALLER_ADMIN_EMAIL');
putenv('INSTALLER_ADMIN_LANGUAGE');
}
}
@@ -1705,6 +1754,9 @@ if (! function_exists('setInstallationCompleteSSE')) {
$state['step_status']['5'] = 'completed';
$state['installation_status'] = 'completed';
$state['installation_completed_at'] = date('Y-m-d\TH:i:s\Z');
// 완료 시점 이중 방어 (이슈 #465) — Phase 1/2 가 정상 동작하면 no-op.
// 레거시 state 로 시작된 설치가 이 코드로 완료되는 경우를 커버한다.
$state = redactInstallationStateSecrets($state);
saveInstallationState($state);
sendSSEEvent('log', ['message' => lang('log_state_updated')]);
+27 -20
View File
@@ -5,18 +5,18 @@
* @author sirsoft
*/
require_once __DIR__ . '/includes/config.php';
require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/session.php';
require_once __DIR__ . '/includes/installer-state.php';
require_once __DIR__ . '/includes/request-handler.php';
require_once __DIR__ . '/api/_guard.php';
require_once __DIR__.'/includes/config.php';
require_once __DIR__.'/includes/functions.php';
require_once __DIR__.'/includes/session.php';
require_once __DIR__.'/includes/installer-state.php';
require_once __DIR__.'/includes/request-handler.php';
require_once __DIR__.'/api/_guard.php';
installer_guard_or_410();
$currentLang = getCurrentLanguage();
// 세션 기반 단계 관리 (URL 파라미터 무시)
if (!isset($_SESSION['installer_current_step'])) {
if (! isset($_SESSION['installer_current_step'])) {
// 세션이 없으면 state.json에서 현재 step 가져오기
$state = getInstallationState();
@@ -33,10 +33,10 @@ $currentStep = $_SESSION['installer_current_step'];
// URL 파라미터로 step 접근 시 알림 후 리다이렉트
if (isset($_GET['step'])) {
$urlStep = (int)$_GET['step'];
$urlStep = (int) $_GET['step'];
// 번역 로드
if (!isset($translations)) {
if (! isset($translations)) {
$translations = loadTranslations($currentLang);
}
@@ -46,14 +46,14 @@ if (isset($_GET['step'])) {
lang('url_parameter_not_supported'),
lang('url_parameter_redirect_message', [
'requested' => $urlStep,
'current' => $currentStep
'current' => $currentStep,
]),
INSTALLER_BASE_URL . '/'
INSTALLER_BASE_URL.'/'
);
}
// 같은 경우에도 깔끔한 URL로 리다이렉트
header('Location: ' . INSTALLER_BASE_URL . '/');
header('Location: '.INSTALLER_BASE_URL.'/');
exit;
}
@@ -65,11 +65,18 @@ $error = null;
// Step 3 기본값 설정
if ($currentStep === 3) {
$defaults = DEFAULT_INSTALL_CONFIG;
$defaults['app_url'] = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
$defaults['app_url'] = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http').'://'.$_SERVER['HTTP_HOST'];
$defaults['admin_language'] = getCurrentLanguage();
// state.json에서 비밀번호 제외된 config 복원 (세션에서 비밀번호를 사전 입력하지 않음)
// state.json에서 비밀번호 제외된 config 복원 (폼에 비밀번호를 사전 입력하지 않음).
// state 에는 이미 비밀이 기록되지 않지만(이슈 #465), 레거시 state.json 이 남아 있는
// 환경에서도 평문이 HTML 로 렌더되지 않도록 프리필 단계에서 다시 제거한다.
$savedConfig = $state['config'] ?? $defaults;
unset($savedConfig['db_write_password'], $savedConfig['db_read_password']);
unset(
$savedConfig['db_write_password'],
$savedConfig['db_read_password'],
$savedConfig['admin_password'],
$savedConfig['admin_password_confirm']
);
$formData = array_merge($defaults, $savedConfig);
}
@@ -82,7 +89,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
// 번역 로드
if (!isset($translations)) {
if (! isset($translations)) {
$translations = loadTranslations($currentLang);
}
@@ -91,7 +98,7 @@ if ($currentStep < 0 || $currentStep > 6) {
$currentStep = 0;
}
$stepFile = __DIR__ . '/views/' . $currentStep . '-' . (STEP_FILE_MAP[$currentStep] ?? 'welcome') . '.php';
$stepFile = __DIR__.'/views/'.$currentStep.'-'.(STEP_FILE_MAP[$currentStep] ?? 'welcome').'.php';
?>
<!DOCTYPE html>
<html lang="<?= $currentLang ?>">
@@ -119,7 +126,7 @@ $stepFile = __DIR__ . '/views/' . $currentStep . '-' . (STEP_FILE_MAP[$currentSt
<link rel="stylesheet" href="<?= INSTALLER_BASE_URL ?>/assets/css/installer.css?v=<?= time() ?>">
</head>
<body>
<?php if ($currentStep > 0): ?>
<?php if ($currentStep > 0) { ?>
<!-- Installer Header Bar -->
<div class="installer-header-bar">
<div class="installer-header-content">
@@ -142,7 +149,7 @@ $stepFile = __DIR__ . '/views/' . $currentStep . '-' . (STEP_FILE_MAP[$currentSt
</div>
</div>
</div>
<?php endif; ?>
<?php } ?>
<!-- Main Content -->
<?php
@@ -151,7 +158,7 @@ $stepFile = __DIR__ . '/views/' . $currentStep . '-' . (STEP_FILE_MAP[$currentSt
} else {
showStepFileNotFoundError($currentStep);
}
?>
?>
<!-- Installer Footer -->
<footer class="installer-footer">
@@ -0,0 +1,357 @@
<?php
namespace Tests\Feature\Upgrade;
use App\Extension\UpgradeContext;
use App\Upgrades\Data\V7_0_4\Migrations\RedactResidualInstallerStateSecrets;
use Tests\TestCase;
/**
* 7.0.4 잔존 installer-state.json 비밀 정리 회귀 가드 (이슈 #465).
*
* axis 전수:
* 1. state.json 부재 → no-op
* 2. 완료 증거(.env INSTALLER_COMPLETED=true) + state 존재 → 파일 삭제
* 3. 완료 증거(g7_installed 플래그) + state 존재 → 파일 삭제
* 4. 완료 증거 있음 + 삭제 불가 → redact 폴백 (파일 보존, 비밀만 제거)
* 5. 완료 증거 없음 → 파일 보존 + redact 만 (설치 진행 중 파괴 금지)
* 6. JSON 파싱 실패 → 파일 원본 그대로 보존
* 7. 멱등 — 2회 호출해도 동일 결과
*/
class RedactResidualInstallerStateSecretsTest extends TestCase
{
private string $statePath;
private string $envPath;
private string $installedFlagPath;
private bool $originalEnvExisted = false;
private ?string $originalEnvContent = null;
private bool $originalStateExisted = false;
private ?string $originalStateContent = null;
private bool $originalFlagExisted = false;
private ?string $originalFlagContent = null;
protected function setUp(): void
{
parent::setUp();
$this->statePath = base_path('storage/installer-state.json');
$this->envPath = base_path('.env');
$this->installedFlagPath = base_path('storage/app/g7_installed');
// 실 운영 아티팩트 백업 — 본 테스트가 삭제/변조하므로 tearDown 에서 정확히 복원
if (is_file($this->envPath)) {
$this->originalEnvExisted = true;
$this->originalEnvContent = file_get_contents($this->envPath);
}
if (is_file($this->statePath)) {
$this->originalStateExisted = true;
$this->originalStateContent = file_get_contents($this->statePath);
}
if (is_file($this->installedFlagPath)) {
$this->originalFlagExisted = true;
$this->originalFlagContent = file_get_contents($this->installedFlagPath);
}
// DataMigration 은 autoload 대상이 아님 — 동적 require
require_once base_path('upgrades/data/7.0.4/migrations/01_RedactResidualInstallerStateSecrets.php');
$this->clearArtifacts();
}
protected function tearDown(): void
{
$this->clearArtifacts();
if ($this->originalEnvExisted && $this->originalEnvContent !== null) {
file_put_contents($this->envPath, $this->originalEnvContent);
}
if ($this->originalStateExisted && $this->originalStateContent !== null) {
file_put_contents($this->statePath, $this->originalStateContent);
}
if ($this->originalFlagExisted && $this->originalFlagContent !== null) {
file_put_contents($this->installedFlagPath, $this->originalFlagContent);
}
parent::tearDown();
}
/**
* 각 axis 가 완료 증거를 명시 제어할 수 있도록 세 아티팩트를 모두 제거한다.
*/
private function clearArtifacts(): void
{
if (is_file($this->statePath)) {
// axis 4 가 파일을 0444 로 만들 수 있다 — 단언 실패로 조기 종료된 경우에도
// 정리가 가능하도록 권한을 먼저 되돌린다.
@chmod($this->statePath, 0644);
@unlink($this->statePath);
}
if (is_file($this->installedFlagPath)) {
@unlink($this->installedFlagPath);
}
if (is_file($this->envPath)) {
@unlink($this->envPath);
}
}
private function context(): UpgradeContext
{
return new UpgradeContext('7.0.3', '7.0.4', '7.0.4');
}
private function migration(): RedactResidualInstallerStateSecrets
{
return new RedactResidualInstallerStateSecrets;
}
/**
* 평문 비밀이 들어 있는 레거시 state.json (7.0.3 이하 인스톨러가 남긴 형태).
*
* @return array<string, mixed>
*/
private function legacyState(): array
{
return [
'installation_status' => 'completed',
'current_step' => 5,
'completed_tasks' => ['db_migrate', 'db_seed', 'complete_flag'],
'config' => [
'app_name' => 'G7 Site',
'db_write_host' => '127.0.0.1',
'db_write_database' => 'g7',
'db_write_username' => 'root',
'db_write_password' => 'db-plain-pw',
'db_read_password' => 'read-plain-pw',
'admin_email' => 'admin@example.com',
'admin_password' => 'admin-plain-pw',
'admin_password_confirm' => 'admin-plain-pw',
],
];
}
private function writeState(array $state): void
{
file_put_contents($this->statePath, json_encode($state, JSON_PRETTY_PRINT));
}
private function markInstalledViaEnv(): void
{
file_put_contents($this->envPath, "APP_ENV=production\nINSTALLER_COMPLETED=true\n");
}
private function markInstalledViaFlag(): void
{
$dir = dirname($this->installedFlagPath);
if (! is_dir($dir)) {
mkdir($dir, 0775, true);
}
file_put_contents($this->installedFlagPath, date('Y-m-d H:i:s'));
}
/**
* @return array<string, mixed>
*/
private function readState(): array
{
return json_decode((string) file_get_contents($this->statePath), true);
}
private function assertNoPlaintextSecrets(array $config): void
{
foreach (['db_write_password', 'db_read_password', 'admin_password', 'admin_password_confirm'] as $key) {
$this->assertArrayNotHasKey($key, $config, "{$key} 가 state 에 남으면 안 됨");
}
}
// -----------------------------------------------------------------------
// axis 1: state.json 부재 → no-op
// -----------------------------------------------------------------------
public function test_no_op_when_state_file_absent(): void
{
$this->assertFileDoesNotExist($this->statePath);
$this->migration()->run($this->context());
$this->assertFileDoesNotExist($this->statePath);
}
// -----------------------------------------------------------------------
// axis 2: 완료 증거(.env) + state 존재 → 파일 삭제
// -----------------------------------------------------------------------
public function test_deletes_state_file_when_env_marks_installation_complete(): void
{
$this->writeState($this->legacyState());
$this->markInstalledViaEnv();
$this->migration()->run($this->context());
$this->assertFileDoesNotExist($this->statePath, '설치 완료 환경의 잔존 state.json 은 삭제되어야 함');
}
// -----------------------------------------------------------------------
// axis 3: 완료 증거(g7_installed) + state 존재 → 파일 삭제
// -----------------------------------------------------------------------
public function test_deletes_state_file_when_installed_flag_present(): void
{
$this->writeState($this->legacyState());
$this->markInstalledViaFlag();
// .env 는 없음 — g7_installed 단독 신호로도 완료 판정되어야 함
$this->migration()->run($this->context());
$this->assertFileDoesNotExist($this->statePath);
}
// -----------------------------------------------------------------------
// axis 4: 완료 증거 있음 + 삭제 불가 → 예외 없이 계속 진행 (업그레이드 중단 금지)
//
// 파일을 읽기 전용으로 만들어 unlink 를 실제로 실패시킨다. 이 상태에서는
// redact 재저장(file_put_contents) 도 함께 실패하므로 평문 제거는 불가능하다 —
// 파일시스템 레벨 제약이라 코드로 우회할 수 없다. 본 axis 가 고정하는 계약은
// "업그레이드가 중단되지 않고, 파일이 손상되지 않으며, 운영자가 수동 삭제할 수
// 있도록 원본이 보존된다" 이다 (경고 로그는 runInternal 이 기록).
// -----------------------------------------------------------------------
public function test_does_not_abort_upgrade_when_state_file_cannot_be_deleted(): void
{
$original = $this->legacyState();
$this->writeState($original);
$this->markInstalledViaEnv();
$before = file_get_contents($this->statePath);
// 이 환경에서 읽기 전용 파일의 삭제가 실제로 차단되는지 실측 (POSIX 는 삭제 권한이
// 부모 디렉토리 소유라 차단되지 않을 수 있다 — 그 경우 본 axis 는 재현 불가).
if (! $this->unlinkIsBlocked()) {
$this->markTestSkipped(
'이 플랫폼에서는 읽기 전용 파일의 unlink 가 차단되지 않아 삭제 실패 경로를 '
.'결정적으로 재현할 수 없음 (POSIX: 삭제 권한은 부모 디렉토리 소유)'
);
}
@chmod($this->statePath, 0444);
// 예외를 던지지 않아야 한다 (업그레이드 전체 중단 금지)
$this->migration()->run($this->context());
$this->assertFileExists($this->statePath, '삭제 실패 시 파일은 보존되어야 함');
$this->assertSame($before, file_get_contents($this->statePath), '삭제 실패 시 파일이 손상되면 안 됨');
@chmod($this->statePath, 0644);
}
/**
* 현재 플랫폼에서 읽기 전용 파일의 unlink 가 차단되는지 실측한다.
*/
private function unlinkIsBlocked(): bool
{
$probe = base_path('storage/installer-state-unlink-probe.tmp');
file_put_contents($probe, 'x');
@chmod($probe, 0444);
$blocked = @unlink($probe) === false;
@chmod($probe, 0644);
if (is_file($probe)) {
@unlink($probe);
}
return $blocked;
}
// -----------------------------------------------------------------------
// axis 5: 완료 증거 없음 → 파일 보존 + redact 만
// -----------------------------------------------------------------------
public function test_redacts_but_preserves_file_when_installation_not_complete(): void
{
$state = $this->legacyState();
$state['installation_status'] = 'running'; // 설치 진행 중
$this->writeState($state);
// 완료 증거 없음 (.env / g7_installed 모두 부재)
$this->migration()->run($this->context());
$this->assertFileExists($this->statePath, '진행 중인 설치의 state.json 을 삭제하면 안 됨');
$after = $this->readState();
$this->assertNoPlaintextSecrets($after['config']);
// 비-비밀 필드는 보존되어야 설치를 이어갈 수 있다
$this->assertSame('G7 Site', $after['config']['app_name']);
$this->assertSame('127.0.0.1', $after['config']['db_write_host']);
$this->assertSame('root', $after['config']['db_write_username']);
$this->assertSame('admin@example.com', $after['config']['admin_email']);
$this->assertSame(['db_migrate', 'db_seed', 'complete_flag'], $after['completed_tasks']);
$this->assertSame('running', $after['installation_status']);
}
// -----------------------------------------------------------------------
// axis 6: JSON 파싱 실패 → 파일 원본 보존 (파괴 금지)
// -----------------------------------------------------------------------
public function test_preserves_corrupted_state_file(): void
{
$corrupted = '{ this is not valid json';
file_put_contents($this->statePath, $corrupted);
$this->markInstalledViaEnv();
$this->migration()->run($this->context());
$this->assertFileExists($this->statePath, '파싱 실패 파일을 삭제하면 안 됨');
$this->assertSame($corrupted, file_get_contents($this->statePath));
}
// -----------------------------------------------------------------------
// axis 7: 멱등 — 2회 호출해도 동일 결과
// -----------------------------------------------------------------------
public function test_is_idempotent_on_repeated_runs(): void
{
$state = $this->legacyState();
$state['installation_status'] = 'aborted';
$this->writeState($state);
$this->migration()->run($this->context());
$firstPass = file_get_contents($this->statePath);
$this->migration()->run($this->context());
$secondPass = file_get_contents($this->statePath);
$this->assertSame($firstPass, $secondPass, '재실행이 파일을 추가로 변경하면 안 됨');
$this->assertNoPlaintextSecrets($this->readState()['config']);
}
// -----------------------------------------------------------------------
// 삭제 경로 멱등 — 완료 환경에서 2회 호출
// -----------------------------------------------------------------------
public function test_delete_path_is_idempotent(): void
{
$this->writeState($this->legacyState());
$this->markInstalledViaEnv();
$this->migration()->run($this->context());
$this->assertFileDoesNotExist($this->statePath);
// 2회차 — 파일 부재 상태에서 예외 없이 no-op
$this->migration()->run($this->context());
$this->assertFileDoesNotExist($this->statePath);
}
// -----------------------------------------------------------------------
// config 섹션 없는 state → 예외 없이 통과
// -----------------------------------------------------------------------
public function test_handles_state_without_config_section(): void
{
$this->writeState(['installation_status' => 'running', 'completed_tasks' => []]);
$this->migration()->run($this->context());
$this->assertFileExists($this->statePath);
$this->assertSame('running', $this->readState()['installation_status']);
}
}
@@ -408,6 +408,35 @@ ENV;
$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";
@@ -0,0 +1,327 @@
<?php
namespace Tests\Unit\Installer;
use PHPUnit\Framework\TestCase;
/**
* 인스톨러 state.json 비밀 잔존 결함 회귀 테스트 (이슈 #465)
*
* 결함: 설치 후 storage/installer-state.json 에 admin_password /
* admin_password_confirm 이 평문으로 남았다.
*
* 수정 설계: 비밀은 state.json 에 아예 기록하지 않고 runtime.php(0600) 로
* 일원화한다. 본 테스트는 그 계약을 고정한다.
*
* - sanitizeConfigForState() — state 저장 직전 비밀 4종 제거
* - redactInstallationStateSecrets() — 레거시 state 정리
* - buildInstallerRuntimeFromState() — state 결손 시 기존 runtime 값 보존
* - hydrateDbSecretsFromRuntime() — DB 비밀번호 소비처 폴백
*
* @see https://github.com/gnuboard/dev-g7/issues/465
*/
class InstallerStateSecretsTest extends TestCase
{
private string $skipReason = '';
protected function setUp(): void
{
parent::setUp();
$tempBase = sys_get_temp_dir().'/g7-installer-secrets-'.bin2hex(random_bytes(4));
// 안전 가드: BASE_PATH / INSTALLER_RUNTIME_PATH 는 PHP 상수라 프로세스당 1회만
// 정의된다. 다른 Installer 테스트가 먼저 프로젝트 루트로 BASE_PATH 를 정의하면
// 본 테스트의 writeInstallerRuntime/tearDown 이 실제 운영 runtime.php 를
// (DB 자격증명 보관 파일) 파괴한다. 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=InstallerStateSecretsTest';
$this->markTestSkipped($this->skipReason);
}
$tempBase = (string) BASE_PATH;
} else {
define('BASE_PATH', $tempBase);
}
if (! is_dir($tempBase.'/storage/installer')) {
@mkdir($tempBase.'/storage/installer', 0755, true);
}
require_once dirname(__DIR__, 3).'/public/install/includes/installer-state.php';
require_once dirname(__DIR__, 3).'/public/install/includes/installer-runtime.php';
}
protected function tearDown(): void
{
if ($this->skipReason === '' && defined('INSTALLER_RUNTIME_PATH') && is_file(INSTALLER_RUNTIME_PATH)) {
@unlink(INSTALLER_RUNTIME_PATH);
}
parent::tearDown();
}
// -----------------------------------------------------------------
// sanitizeConfigForState
// -----------------------------------------------------------------
public function test_sanitize_config_for_state_removes_all_four_secrets(): void
{
$config = [
'app_name' => 'G7 Site',
'db_write_host' => 'localhost',
'db_write_username' => 'root',
'db_write_password' => 'db-secret',
'db_read_password' => 'read-secret',
'use_read_db' => true,
'db_read_host' => 'read-host',
'admin_email' => 'admin@example.com',
'admin_password' => 'plain-admin-pw',
'admin_password_confirm' => 'plain-admin-pw',
];
$safe = sanitizeConfigForState($config);
foreach (installerSecretConfigKeys() as $secretKey) {
$this->assertArrayNotHasKey($secretKey, $safe, "{$secretKey} 가 state 로 유출되면 안 됨");
}
// 비-비밀 키는 보존
$this->assertSame('G7 Site', $safe['app_name']);
$this->assertSame('localhost', $safe['db_write_host']);
$this->assertSame('root', $safe['db_write_username']);
$this->assertSame('admin@example.com', $safe['admin_email']);
// use_read_db=true 이므로 read 필드 보존
$this->assertSame('read-host', $safe['db_read_host']);
}
public function test_installer_secret_config_keys_covers_four_secrets(): void
{
$keys = installerSecretConfigKeys();
$this->assertEqualsCanonicalizing(
['db_write_password', 'db_read_password', 'admin_password', 'admin_password_confirm'],
$keys,
);
}
/**
* 이슈 #63 기존 동작 보존 — use_read_db=false 이면 read 접속 필드 잔존값 제거.
*/
public function test_sanitize_config_for_state_drops_read_fields_when_use_read_db_false(): void
{
$config = [
'use_read_db' => false,
'db_write_host' => 'mysql',
'db_read_host' => 'stale-host',
'db_read_port' => '3307',
'db_read_database' => 'stale_db',
'db_read_username' => 'stale_user',
'db_read_password' => 'stale_pw',
];
$safe = sanitizeConfigForState($config);
$this->assertArrayNotHasKey('db_read_host', $safe);
$this->assertArrayNotHasKey('db_read_port', $safe);
$this->assertArrayNotHasKey('db_read_database', $safe);
$this->assertArrayNotHasKey('db_read_username', $safe);
$this->assertArrayNotHasKey('db_read_password', $safe);
$this->assertSame('mysql', $safe['db_write_host']);
}
// -----------------------------------------------------------------
// redactInstallationStateSecrets
// -----------------------------------------------------------------
public function test_redact_installation_state_secrets_strips_config_secrets(): void
{
$state = [
'installation_status' => 'completed',
'completed_tasks' => ['db_migrate', 'db_seed'],
'config' => [
'app_name' => 'G7',
'admin_password' => 'plain',
'admin_password_confirm' => 'plain',
'db_write_password' => 'dbpw',
'db_read_password' => 'rdpw',
],
];
$redacted = redactInstallationStateSecrets($state);
$this->assertArrayNotHasKey('admin_password', $redacted['config']);
$this->assertArrayNotHasKey('admin_password_confirm', $redacted['config']);
$this->assertArrayNotHasKey('db_write_password', $redacted['config']);
$this->assertArrayNotHasKey('db_read_password', $redacted['config']);
// 비밀이 아닌 것은 불변
$this->assertSame('G7', $redacted['config']['app_name']);
$this->assertSame('completed', $redacted['installation_status']);
$this->assertSame(['db_migrate', 'db_seed'], $redacted['completed_tasks']);
}
public function test_redact_installation_state_secrets_is_noop_without_config(): void
{
$state = ['installation_status' => 'running', 'completed_tasks' => []];
$this->assertSame($state, redactInstallationStateSecrets($state));
}
/**
* redact 는 use_read_db 정리를 수행하지 않는다 — 이미 기록된 state 의 접속
* 정보(비밀 아님)를 지우면 롤백/cleanup 이 깨진다. 비밀 4종만 제거.
*/
public function test_redact_preserves_non_secret_db_fields(): void
{
$state = [
'config' => [
'use_read_db' => false,
'db_read_host' => 'leftover',
'db_write_password' => 'x',
],
];
$redacted = redactInstallationStateSecrets($state);
$this->assertArrayNotHasKey('db_write_password', $redacted['config']);
$this->assertSame('leftover', $redacted['config']['db_read_host']);
}
// -----------------------------------------------------------------
// buildInstallerRuntimeFromState — 결손 폴백
// -----------------------------------------------------------------
public function test_build_runtime_carries_admin_password_when_provided_by_caller(): void
{
// 호출자(install-process.php) 가 세션 비밀번호를 넣는 정상 경로는 admin 섹션을
// 직접 대입한다. 본 테스트는 그 섹션이 기존 runtime 에 있을 때 보존되는지 확인.
writeInstallerRuntime([
'app' => ['key' => generateAppKeyInline()],
'admin' => ['password' => 'kept-admin-pw'],
]);
$built = buildInstallerRuntimeFromState([
'db_write_host' => 'localhost',
'db_write_database' => 'g7',
'db_write_username' => 'root',
]);
$this->assertSame('kept-admin-pw', $built['admin']['password'] ?? null);
}
public function test_build_runtime_preserves_existing_db_password_when_state_config_lacks_it(): void
{
writeInstallerRuntime([
'db' => [
'write' => [
'host' => 'localhost',
'port' => '3306',
'database' => 'g7',
'username' => 'root',
'password' => 'existing-write-pw',
],
],
'app' => ['key' => generateAppKeyInline()],
]);
// state.config 에는 비밀번호가 없다 (sanitize 로 제거됨)
$built = buildInstallerRuntimeFromState([
'db_write_host' => 'localhost',
'db_write_database' => 'g7',
'db_write_username' => 'root',
]);
$this->assertSame('existing-write-pw', $built['db']['write']['password']);
}
public function test_build_runtime_read_password_falls_back_to_existing_runtime_not_write_password(): void
{
writeInstallerRuntime([
'db' => [
'write' => ['host' => 'w-host', 'database' => 'g7', 'username' => 'wu', 'password' => 'write-pw'],
'read' => ['host' => 'r-host', 'database' => 'g7r', 'username' => 'ru', 'password' => 'read-pw'],
],
'app' => ['key' => generateAppKeyInline()],
]);
$built = buildInstallerRuntimeFromState([
'use_read_db' => true,
'db_write_host' => 'w-host',
'db_write_database' => 'g7',
'db_write_username' => 'wu',
'db_read_host' => 'r-host',
'db_read_database' => 'g7r',
'db_read_username' => 'ru',
// db_read_password / db_write_password 는 state 에 없음
]);
$this->assertSame('read-pw', $built['db']['read']['password'], 'read 비밀번호가 write 값으로 대체되면 안 됨');
$this->assertSame('write-pw', $built['db']['write']['password']);
}
public function test_build_runtime_keeps_state_config_password_when_present(): void
{
// 세션이 살아있는 정상 경로 — state 가 아닌 호출자가 넘긴 config 에 비밀번호가
// 있으면 그것이 우선한다 (기존 동작 보존).
writeInstallerRuntime([
'db' => ['write' => ['password' => 'old-pw']],
'app' => ['key' => generateAppKeyInline()],
]);
$built = buildInstallerRuntimeFromState([
'db_write_host' => 'localhost',
'db_write_password' => 'new-pw',
]);
$this->assertSame('new-pw', $built['db']['write']['password']);
}
// -----------------------------------------------------------------
// hydrateDbSecretsFromRuntime
// -----------------------------------------------------------------
public function test_hydrate_db_secrets_from_runtime_fills_empty_passwords(): void
{
writeInstallerRuntime([
'db' => [
'write' => ['password' => 'rt-write-pw'],
'read' => ['password' => 'rt-read-pw'],
],
]);
$config = [
'db_write_host' => 'localhost',
'db_write_username' => 'root',
];
$hydrated = hydrateDbSecretsFromRuntime($config);
$this->assertSame('rt-write-pw', $hydrated['db_write_password']);
$this->assertSame('rt-read-pw', $hydrated['db_read_password']);
$this->assertSame('localhost', $hydrated['db_write_host']);
}
public function test_hydrate_db_secrets_does_not_override_existing_password(): void
{
writeInstallerRuntime(['db' => ['write' => ['password' => 'rt-pw']]]);
$hydrated = hydrateDbSecretsFromRuntime(['db_write_password' => 'session-pw']);
$this->assertSame('session-pw', $hydrated['db_write_password']);
}
public function test_hydrate_db_secrets_returns_config_unchanged_when_runtime_absent(): void
{
@unlink(INSTALLER_RUNTIME_PATH);
$config = ['db_write_host' => 'localhost'];
$this->assertSame($config, hydrateDbSecretsFromRuntime($config));
}
}
@@ -0,0 +1,153 @@
# audit:allow test-scenario-coverage reason: 인스톨러 비밀 수명주기의 시나리오 매트릭스 SSoT. 회귀 가드는 test_files 의 통과 테스트 + Manual 시나리오(실제 설치 2회 + storage grep) 로 커버.
feature: 인스톨러 비밀 수명주기 (state.json 평문 제거 → runtime.php 일원화)
description: |
설치 과정에서 입력받는 비밀(DB 비밀번호 2종, 관리자 비밀번호 2종) 이
storage/installer-state.json 에 평문으로 기록되어, 설치 실패/중단/finalize 미호출
환경에서 무기한 잔존하던 결함을 수정한다 (이슈 #465).
설계:
- state.json(0664, 실패 시 무기한 잔존) 에는 비밀을 아예 기록하지 않는다.
- 비밀 채널은 storage/installer/runtime.php(0600, finalize 시 삭제) 로 일원화.
SSE 워커는 세션 접근이 불가하므로 파일 경유가 불가피하며, 세션을 보유한 유일한
공통 시작점인 install-process.php 가 이송 지점이다 (SSE/폴링 공통, 재시도 포함).
- 관리자 비밀번호는 db_seed 가 소비한 즉시 runtime 에서 제거 + putenv 해제
(해제하지 않으면 이후 모든 exec() 자식이 평문을 ENV 상속).
- 레거시/실패 경로 방어: 완료·중단·초기화·finalize 시점 redact 폴백.
- 이미 설치된 사이트는 7.0.4 업그레이드 스텝이 잔존 파일을 정리.
구조적 제약:
state.config 에서 DB 비밀번호가 사라지므로 그 값을 읽던 소비처(db_cleanup /
롤백 seed truncate) 는 hydrateDbSecretsFromRuntime() 으로 자격증명을 복원한다.
두 소비처 모두 env_update 이후에만 실행되어 runtime.php 가 항상 존재한다.
axes:
installation_mode: [sse, polling]
session_state: [alive, lost_after_step3]
retry_kind: [none, resume_after_failure, drop_tables_reconsent]
runtime_state: [absent, present_with_db_only, present_with_db_and_admin]
seed_outcome: [not_reached, success, failure]
completion_path: [normal_finalize, finalize_unlink_failed, finalize_not_called, aborted, reset]
read_db: [disabled, enabled_separate_host]
legacy_state: [clean, has_plaintext_secrets_from_7_0_3_or_earlier]
upgrade_trigger: [not_applicable, state_present_installed, state_present_not_installed, state_absent, state_corrupted]
exclusions:
- { seed_outcome: not_reached, runtime_state: absent, reason: "env_update 이전 실패 — runtime 미생성이 정상" }
- { session_state: lost_after_step3, runtime_state: absent, retry_kind: resume_after_failure, reason: "세션+runtime 동시 유실은 재개 불가 400 → Step 3 재입력 (의도된 안전 실패)" }
- { upgrade_trigger: state_absent, legacy_state: has_plaintext_secrets_from_7_0_3_or_earlier, reason: "state 파일이 없으면 잔존 평문도 없음" }
- { completion_path: reset, seed_outcome: success, reason: "reset 은 완료 전 상태 초기화 — 시딩 성공 후 경로 아님" }
effects:
# 소스 차단 — state.json 에 비밀 미기록
- step3_post_writes_state_config_without_db_write_password
- step3_post_writes_state_config_without_db_read_password
- step3_post_writes_state_config_without_admin_password
- step3_post_writes_state_config_without_admin_password_confirm
- step3_post_keeps_admin_password_in_session_for_runtime_transfer
- step3_post_drops_admin_password_confirm_from_session_no_consumer_exists
- sanitize_config_for_state_preserves_non_secret_fields_like_app_name_and_db_host
- sanitize_config_for_state_drops_read_connection_fields_when_use_read_db_false
- install_process_writes_admin_password_into_runtime_php_before_state_save
- install_process_writes_state_config_through_sanitize_helper
- install_process_fails_installation_start_when_runtime_write_fails
- step3_form_prefill_never_renders_admin_password_value_even_from_legacy_state
# runtime.php 스키마 + 보존 폴백
- build_runtime_adds_admin_section_only_when_caller_supplies_password
- build_runtime_preserves_existing_admin_section_across_retries
- build_runtime_preserves_existing_db_write_password_when_state_config_lacks_it
- build_runtime_read_password_falls_back_to_existing_runtime_before_write_password
- build_runtime_prefers_caller_supplied_password_over_existing_runtime_value
- build_runtime_preserves_existing_app_key_unchanged_regression_guard
# DB 비밀번호 소비처 hydrate 폴백
- hydrate_db_secrets_fills_empty_write_password_from_runtime
- hydrate_db_secrets_fills_empty_read_password_from_runtime
- hydrate_db_secrets_does_not_override_password_already_present_in_config
- hydrate_db_secrets_returns_config_unchanged_when_runtime_absent
- db_cleanup_drop_tables_connects_successfully_with_hydrated_credentials
- rollback_seed_truncate_connects_successfully_with_hydrated_credentials
- generate_env_content_needs_no_hydration_because_merge_runtime_into_env_overwrites_db_lines
# 소비 즉시 삭제 (redact-on-consume)
- db_seed_reads_admin_password_from_runtime_php_not_state_config
- db_seed_falls_back_to_state_config_admin_password_for_legacy_installs_in_progress
- db_seed_removes_admin_section_from_runtime_php_immediately_after_success
- db_seed_redacts_legacy_state_secrets_immediately_after_success
- db_seed_redact_reloads_state_so_completed_task_markers_are_not_lost
- db_seed_unsets_installer_admin_password_env_after_success
- db_seed_unsets_installer_admin_name_email_language_env_after_success
- subsequent_exec_children_do_not_inherit_plaintext_admin_password_env
- db_seed_leaves_admin_password_intact_in_runtime_when_seeding_fails_so_retry_works
# .env 유출 차단 불변식
- merge_runtime_into_env_never_writes_admin_section_whitelist_invariant
- merge_runtime_into_env_still_writes_db_credentials_and_app_key
- installer_runtime_service_provider_ignores_unknown_admin_section
# 완료/실패 시점 폴백
- complete_flag_task_redacts_state_secrets_before_saving_completed_status
- finalize_deletes_state_json_on_success_when_DELETE_INSTALLER_AFTER_COMPLETE_set
- finalize_redacts_state_json_when_unlink_fails
- finalize_redacts_state_json_when_DELETE_INSTALLER_AFTER_COMPLETE_false
- reset_state_redacts_secrets_in_preserved_config
- abort_installation_redacts_secrets_in_preserved_config
- completed_installer_page_load_clears_install_config_from_session
- completed_installer_page_load_clears_db_test_flags_from_session
# 재시도 / 재개 안전성
- resume_after_failure_with_live_session_reinjects_admin_password_into_runtime
- resume_after_failure_with_lost_session_reuses_admin_password_from_runtime
- resume_after_db_seed_completion_passes_validation_via_completed_task_marker
- resume_with_no_session_and_no_runtime_and_no_seed_marker_returns_400_missing_admin_password
- drop_tables_reconsent_reruns_db_seed_with_password_reinjected_by_install_process
# 업그레이드 스텝 (7.0.4) — 기존 설치 사이트 정리
- upgrade_step_no_op_when_state_json_absent
- upgrade_step_deletes_state_json_when_env_installer_completed_is_true
- upgrade_step_deletes_state_json_when_g7_installed_flag_present
- upgrade_step_does_not_abort_upgrade_when_state_json_cannot_be_deleted
- upgrade_step_preserves_state_json_and_redacts_only_when_installation_not_complete
- upgrade_step_preserves_non_secret_config_fields_so_in_progress_install_can_continue
- upgrade_step_preserves_corrupted_state_json_without_destroying_it
- upgrade_step_is_idempotent_across_repeated_runs
- upgrade_step_handles_state_without_config_section
- upgrade_step_does_not_chmod_or_chown_preserving_existing_file_ownership
test_files:
- tests/Unit/Installer/InstallerStateSecretsTest.php
- tests/Unit/Installer/InstallerRuntimeHelperTest.php
- tests/Feature/Upgrade/RedactResidualInstallerStateSecretsTest.php
manual_verification:
- scenario: A_sse_full_install
description: |
SSE 모드로 실제 설치 1회 완료 → 완료 후 storage/ 하위에서 입력한 관리자
비밀번호 문자열 grep 무일치 (installer-state.json / installer/runtime.php /
logs/installation.log 전부) + 그 비밀번호로 관리자 로그인 성공.
- scenario: B_polling_full_install
description: |
폴링 모드로 동일 검증. SSE 와 동일하게 install-process.php 를 경유하므로
비밀 이송 경로가 같아야 한다.
- scenario: C_abort_midway
description: |
Step 5 진행 중 설치 중단 → 잔존 installer-state.json 에 admin_password /
db_write_password 부재 확인 (redact 폴백). 이후 재개 시 정상 진행.
- scenario: D_drop_tables_retry
description: |
기존 DB 가 있는 상태에서 drop_tables 재동의로 재설치 → db_seed 가 다시 실행되고
관리자 계정이 정상 생성됨 (install-process.php 가 비밀번호를 runtime 에 재주입).
- scenario: E_upgrade_existing_site
description: |
7.0.3 이하에서 설치되어 평문 비밀이 남은 installer-state.json 이 있는 사이트를
7.0.4 로 업그레이드 → 파일 삭제(완료 사이트) 또는 비밀만 redact 확인.
rules_layer_coverage:
- rule: upgrade-step-class-caching
coverage: Upgrade_7_0_4 는 빈 위임자, 로직은 data/7.0.4/migrations/ 의 버전 namespace 클래스에 격리
- rule: installer-pattern-no-copy-to-upgrade-step
coverage: 업그레이드 스텝은 인스톨러 파일을 require 하지 않고 비밀 키 목록/파싱/저장을 로컬 인라인 중복 구현. CLI 컨텍스트이므로 chmod/chown 미시도 (기존 inode 권한 보존)
- rule: service-provider-db-safety
coverage: InstallerRuntimeServiceProvider 무수정 — runtime['db'] / runtime['app']['key'] 만 참조하므로 신규 admin 섹션은 무시됨
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* 코어 7.0.4 업그레이드 스텝
*
* 모든 비즈니스 로직은 본 클래스 파일이 아닌 `upgrades/data/7.0.4/` 안에 격리된다:
*
* - migrations/
* 01_RedactResidualInstallerStateSecrets.php
* 7.0.3 이하 인스톨러가 storage/installer-state.json 에 평문으로 남긴
* 관리자/DB 비밀번호를 정리 (이슈 #465)
*
* 본 클래스는 `AbstractUpgradeStep` 의 default `run()` 에 위임 — 별도 override 없음.
*
* @upgrade-path 7.0.3 이하 → 7.0.4
*
* 의존성 제약: 본 스텝은 변환/핫픽스를 `data/7.0.4/migrations/` 의 버전 namespace
* 클래스에 위임한다. 미래 버전에서 *그 디렉토리는 동결* (수정 금지) 되어 "각 스텝별 동작
* 100% 동일 보장" invariant 가 성립.
*
* 상세: docs/extension/upgrade-step-guide.md §12 "버전별 데이터 스냅샷"
*/
class Upgrade_7_0_4 extends AbstractUpgradeStep
{
// 모든 로직 위임 — data/7.0.4/ 가 SSoT.
}
@@ -0,0 +1,234 @@
<?php
namespace App\Upgrades\Data\V7_0_4\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\Log;
/**
* 7.0.3 이하 인스톨러가 남긴 storage/installer-state.json 의 평문 비밀 정리 (이슈 #465).
*
* 결함:
* 설치 중 관리자 비밀번호(admin_password / admin_password_confirm) 와 DB 비밀번호가
* state.json(0664) 에 평문으로 기록되었다. 정상 완료 시 finalize-env.php 가 파일을
* 삭제하지만, finalize 미호출/삭제 실패/설치 중단 환경에서는 평문이 무기한 잔존한다.
*
* 본 마이그레이션의 동작:
*
* | 조건 | 동작 |
* |-----------------------------------------|-----------------------------------|
* | state.json 부재 | no-op |
* | JSON 파싱 실패 | 파일 보존 + warning (파괴 금지) |
* | 설치 완료 증거 있음 | 파일 삭제 (실패 시 redact 폴백) |
* | 완료 증거 없음 (설치 진행 중일 수 있음) | 파일 보존 + 비밀만 redact |
*
* "설치 완료 증거" = `.env` 의 INSTALLER_COMPLETED truthy 또는 storage/app/g7_installed 존재.
*
* 멱등: 재실행 시 이미 삭제/redact 된 상태에서 동일 결과.
*
* 권한 정책 (Upgrade Step 은 CLI 컨텍스트 — PHP-FPM 이 아님):
* chmod/chown 을 시도하지 않는다. redact 재저장은 file_put_contents 로 수행하여 기존
* inode 의 권한·소유자를 그대로 보존한다 (인스톨러의 tmp+rename+chmod 0664 패턴을
* 복제하면 CLI 사용자 소유의 새 inode 가 생겨 웹 서버가 읽지 못할 수 있다).
*
* 격리 원칙 (docs/extension/upgrade-step-guide.md §12):
* 인스톨러 파일(installer-state.php / config.php / functions.php) 을 require 하지 않는다
* (BASE_PATH 상수 충돌 + V-1 안전 격리). 비밀 키 목록, state 파싱/저장, INSTALLER_COMPLETED
* 판정을 본 클래스 안에 로컬 인라인 중복 구현한다.
*
* beta.7 의 01_FinalizeOrphanedInstallerRuntime 과의 관계:
* 그 스텝은 runtime.php 잔존이 트리거라 "runtime 은 지워지고 state.json 만 남은" 케이스를
* 잡지 못한다. 본 스텝이 그 사각을 커버한다 (beta.7 스텝은 동결 — 무수정).
*/
final class RedactResidualInstallerStateSecrets implements DataMigration
{
private const STATE_JSON_RELATIVE = 'storage/installer-state.json';
private const INSTALLED_FLAG_RELATIVE = 'storage/app/g7_installed';
/**
* state.config 에서 제거 대상인 비밀 키 (installer-state.php 의
* installerSecretConfigKeys() 와 동일 — 격리를 위해 로컬 중복 정의).
*/
private const SECRET_CONFIG_KEYS = [
'db_write_password',
'db_read_password',
'admin_password',
'admin_password_confirm',
];
public function name(): string
{
return 'RedactResidualInstallerStateSecrets';
}
public function run(UpgradeContext $context): void
{
try {
$this->runInternal($context);
} catch (\Throwable $e) {
// 업그레이드 중단 금지 — 잔존 파일 정리 실패가 업그레이드 전체를 막지 않는다.
$context->logger->warning(sprintf(
'[7.0.4] RedactResidualInstallerStateSecrets 실패 (state.json 보존, 계속 진행): %s',
$e->getMessage(),
));
Log::warning('7.0.4 RedactResidualInstallerStateSecrets 실패', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
private function runInternal(UpgradeContext $context): void
{
$basePath = base_path();
$statePath = $basePath.DIRECTORY_SEPARATOR.self::STATE_JSON_RELATIVE;
if (! is_file($statePath)) {
$context->logger->info('[7.0.4] installer-state.json 부재 — 정리 대상 없음, skip');
return;
}
$state = $this->readStateJson($statePath);
if ($state === null) {
$context->logger->warning(
'[7.0.4] installer-state.json 파싱 실패 — 파일 보존 (파괴 금지). '
.'평문 비밀이 남아 있을 수 있으니 수동 검토 후 삭제 권장: '.$statePath
);
return;
}
if ($this->installationIsComplete($basePath)) {
if (@unlink($statePath)) {
$context->logger->info('[7.0.4] 설치 완료 확인 — 잔존 installer-state.json 삭제 완료');
return;
}
$context->logger->warning(sprintf(
'[7.0.4] installer-state.json 삭제 실패 (%s) — 비밀 필드 redact 로 폴백',
error_get_last()['message'] ?? 'unknown',
));
$this->redactAndSave($statePath, $state, $context);
return;
}
// 완료 증거 없음 — 설치가 진행 중이거나 중단된 상태일 수 있으므로 파일을 삭제하면
// 진행 중인 설치를 파괴한다. 비밀만 제거하고 파일은 보존한다.
$context->logger->info('[7.0.4] 설치 완료 증거 없음 — 파일 보존, 비밀 필드만 redact');
$this->redactAndSave($statePath, $state, $context);
}
/**
* state 배열에서 비밀 4종을 제거한 뒤 재저장한다.
*
* 이미 비밀이 없으면 쓰기를 건너뛴다 (멱등 + 불필요한 mtime 갱신 회피).
*
* @param array<string, mixed> $state
*/
private function redactAndSave(string $statePath, array $state, UpgradeContext $context): void
{
if (! isset($state['config']) || ! is_array($state['config'])) {
$context->logger->info('[7.0.4] installer-state.json 에 config 섹션 없음 — redact 불필요');
return;
}
$removed = [];
foreach (self::SECRET_CONFIG_KEYS as $key) {
if (array_key_exists($key, $state['config'])) {
unset($state['config'][$key]);
$removed[] = $key;
}
}
if ($removed === []) {
$context->logger->info('[7.0.4] installer-state.json 에 잔존 비밀 없음 — redact 불필요 (멱등)');
return;
}
$encoded = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
$context->logger->warning('[7.0.4] installer-state.json 재인코딩 실패 — 파일 보존');
return;
}
// file_put_contents — 기존 inode 의 권한·소유자를 그대로 유지 (CLI 컨텍스트에서
// tmp+rename 하면 새 inode 가 CLI 사용자 소유가 되어 웹 서버 읽기가 깨질 수 있다).
if (@file_put_contents($statePath, $encoded, LOCK_EX) === false) {
$context->logger->warning(sprintf(
'[7.0.4] installer-state.json redact 재저장 실패 (%s) — 평문 잔존. 수동 삭제 권장: %s',
error_get_last()['message'] ?? 'unknown',
$statePath,
));
return;
}
$context->logger->info(sprintf(
'[7.0.4] installer-state.json 비밀 필드 redact 완료 — 제거된 키: %s',
implode(', ', $removed),
));
}
/**
* state.json 을 읽어 배열로 반환. 파싱 실패 시 null.
*
* @return array<string, mixed>|null
*/
private function readStateJson(string $statePath): ?array
{
$content = @file_get_contents($statePath);
if ($content === false) {
return null;
}
$decoded = json_decode($content, true);
return is_array($decoded) ? $decoded : null;
}
/**
* 설치 완료 증거 판정.
*
* `.env` 의 INSTALLER_COMPLETED 가 truthy 이거나 storage/app/g7_installed 가 존재하면
* 설치가 끝난 것으로 본다 (installer-state.php 의 isInstallationCompleted() 와 동일 신호).
*/
private function installationIsComplete(string $basePath): bool
{
if (is_file($basePath.DIRECTORY_SEPARATOR.self::INSTALLED_FLAG_RELATIVE)) {
return true;
}
return $this->envInstallerCompletedIsTrue($basePath.DIRECTORY_SEPARATOR.'.env');
}
/**
* `.env` 의 INSTALLER_COMPLETED 가 truthy 값인지 판정.
*
* `_guard.php` 의 installer_finalize_is_completed() 와 동일 정책.
*/
private function envInstallerCompletedIsTrue(string $envPath): bool
{
if (! is_file($envPath)) {
return false;
}
$parsed = @parse_ini_file($envPath, false, INI_SCANNER_RAW);
if (! is_array($parsed)) {
return false;
}
$flag = strtolower(trim((string) ($parsed['INSTALLER_COMPLETED'] ?? '')));
$flag = trim($flag, "\"'");
return in_array($flag, ['true', '1', 'yes'], true);
}
}