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:
1 parent
ae447c9cc4
commit
9be8faaea2
16 files changed
+1534
-119
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user