From 9be8faaea2a82aef802b6de2bd7923026b880ac3 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Mon, 13 Jul 2026 11:07:54 +0900 Subject: [PATCH] =?UTF-8?q?fix(installer):=20=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=8C=8C=EC=9D=BC=EC=97=90=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8?= =?UTF-8?q?=EA=B0=80=20=ED=8F=89=EB=AC=B8=EC=9C=BC=EB=A1=9C=20=EC=9E=94?= =?UTF-8?q?=EC=A1=B4=ED=95=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비밀을 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 업그레이드 스텝이 잔존 파일을 정리한다. 완료 증거가 있으면 삭제하고, 설치 진행 중일 수 있으면 비밀만 제거한다. --- CHANGELOG.md | 1 + public/install/api/finalize-env.php | 40 +- public/install/api/install-process.php | 79 ++-- public/install/api/rollback-functions.php | 110 +++--- public/install/api/state-management.php | 8 + public/install/includes/installer-runtime.php | 69 +++- public/install/includes/installer-state.php | 76 ++++ public/install/includes/request-handler.php | 33 +- public/install/includes/task-runner.php | 60 ++- public/install/index.php | 47 ++- ...edactResidualInstallerStateSecretsTest.php | 357 ++++++++++++++++++ .../Installer/InstallerRuntimeHelperTest.php | 29 ++ .../Installer/InstallerStateSecretsTest.php | 327 ++++++++++++++++ .../installer-secrets-lifecycle.yaml | 153 ++++++++ upgrades/Upgrade_7_0_4.php | 30 ++ ...01_RedactResidualInstallerStateSecrets.php | 234 ++++++++++++ 16 files changed, 1534 insertions(+), 119 deletions(-) create mode 100644 tests/Feature/Upgrade/RedactResidualInstallerStateSecretsTest.php create mode 100644 tests/Unit/Installer/InstallerStateSecretsTest.php create mode 100644 tests/scenarios/installer-secrets-lifecycle.yaml create mode 100644 upgrades/Upgrade_7_0_4.php create mode 100644 upgrades/data/7.0.4/migrations/01_RedactResidualInstallerStateSecrets.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ff60d853..0d1d5ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- 설치 과정에서 임시로 만들어지는 파일에 관리자 비밀번호가 그대로 남을 수 있던 문제를 수정했습니다. 설치가 정상적으로 끝나면 이 파일은 지워지지만, 설치를 중간에 그만두거나 마지막 정리가 이뤄지지 않은 경우에는 남아 있을 수 있었습니다. 이제 비밀번호는 이 파일에 아예 기록되지 않으며, 설치에 꼭 필요한 동안에만 접근이 제한된 별도 파일에 보관했다가 관리자 계정이 만들어지는 즉시 지워집니다. 이미 설치를 마친 사이트도 업데이트하면 남아 있던 파일이 자동으로 정리됩니다. (sir.kr 커뮤니티의 코ppp 님께서 제보해주셨습니다.) - 화면 편집기를 작은 화면에서 열면 상단 도구 모음의 버튼 글자가 세로로 쪼개지는 등 화면이 찌그러지던 문제를 수정했습니다. 화면 편집기는 왼쪽 화면 목록과 미리보기 화면을 나란히 놓고 쓰는 도구라 넓은 화면이 필요합니다. 이제 창이 좁아도 편집기를 억지로 우겨넣지 않고 원래 크기를 유지하며, 모자란 만큼은 가로 스크롤로 넘겨 보면 됩니다. - 모바일에서 새로고침을 연속으로 눌렀을 때 간헐적으로 "초기화 실패" 또는 "페이지 로딩 실패" 화면이 뜨던 문제를 수정했습니다. 화면을 그리는 데 필요한 요청 하나가 회선 사정으로 잠깐 끊기면 그것만으로 페이지 전체가 뜨지 않았습니다. 이제 이런 일시적인 끊김은 자동으로 다시 시도해 복구하므로, 대부분의 경우 사용자는 아무것도 느끼지 못한 채 화면이 정상적으로 열립니다. - 화면 구성에 필요한 스크립트를 끝내 불러오지 못한 경우, 아무 안내 없이 빈 화면만 남거나 영문 오류 문구가 그대로 노출되던 문제를 수정했습니다. 이제 무슨 일이 있었는지 알려주는 안내와 새로고침 버튼이 표시됩니다. 관리자 화면에도 동일하게 적용됩니다. diff --git a/public/install/api/finalize-env.php b/public/install/api/finalize-env.php index 2c41965e..992b02e5 100644 --- a/public/install/api/finalize-env.php +++ b/public/install/api/finalize-env.php @@ -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()); } diff --git a/public/install/api/install-process.php b/public/install/api/install-process.php index b4b58bcd..4d7e517a 100644 --- a/public/install/api/install-process.php +++ b/public/install/api/install-process.php @@ -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(); diff --git a/public/install/api/rollback-functions.php b/public/install/api/rollback-functions.php index cfc30ad0..ae135aff 100644 --- a/public/install/api/rollback-functions.php +++ b/public/install/api/rollback-functions.php @@ -1,4 +1,5 @@ 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': diff --git a/public/install/api/state-management.php b/public/install/api/state-management.php index b2115f57..7fa15939 100644 --- a/public/install/api/state-management.php +++ b/public/install/api/state-management.php @@ -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'])); diff --git a/public/install/includes/installer-runtime.php b/public/install/includes/installer-runtime.php index b2f80317..26542819 100644 --- a/public/install/includes/installer-runtime.php +++ b/public/install/includes/installer-runtime.php @@ -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 $config state.config (비밀번호 결손 가능) + * @return array 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; + } +} diff --git a/public/install/includes/installer-state.php b/public/install/includes/installer-state.php index 1984b4e7..9ad17a37 100644 --- a/public/install/includes/installer-state.php +++ b/public/install/includes/installer-state.php @@ -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 비밀 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 $config 폼/세션에서 온 원본 config + * @return array 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 $state 현재 인스톨러 state + * @return array 비밀이 제거된 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; + } +} + /** * 설치 완료 여부 확인 * diff --git a/public/install/includes/request-handler.php b/public/install/includes/request-handler.php index 2e7394d0..f28ab5e6 100644 --- a/public/install/includes/request-handler.php +++ b/public/install/includes/request-handler.php @@ -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, diff --git a/public/install/includes/task-runner.php b/public/install/includes/task-runner.php index 2caa6948..8a1b369f 100644 --- a/public/install/includes/task-runner.php +++ b/public/install/includes/task-runner.php @@ -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')]); diff --git a/public/install/index.php b/public/install/index.php index 0cf77c4d..63a01683 100644 --- a/public/install/index.php +++ b/public/install/index.php @@ -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'; ?> @@ -119,7 +126,7 @@ $stepFile = __DIR__ . '/views/' . $currentStep . '-' . (STEP_FILE_MAP[$currentSt - 0): ?> + 0) { ?>
@@ -142,7 +149,7 @@ $stepFile = __DIR__ . '/views/' . $currentStep . '-' . (STEP_FILE_MAP[$currentSt
- + +?>