Files
Gnuboard7/public/install/includes/installer-runtime.php
T
HeuJung 9be8faaea2 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 업그레이드 스텝이 잔존 파일을 정리한다.
완료 증거가 있으면 삭제하고, 설치 진행 중일 수 있으면 비밀만 제거한다.
2026-07-13 17:00:59 +09:00

353 lines
14 KiB
PHP

<?php
/**
* 인스톨러 런타임 설정 헬퍼
*
* 설치 진행 중 동적 설정(DB 자격증명, APP_KEY) 을 .env 가 아닌
* storage/installer/runtime.php 에 PHP 배열로 기록한다.
*
* Laravel 부팅 시 InstallerRuntimeServiceProvider 가 이 파일을 읽어
* config('database.*') / config('app.key') 에 주입하므로,
* 설치 진행 중 .env 를 건드리지 않고도 마이그레이션/시더가 동작한다.
*
* 설치 완료 UI 노출 후 finalize-env.php 가 호출되면 이 파일의 내용을
* .env 에 머지하고 파일을 삭제한다.
*
* @see https://github.com/gnuboard/g7/issues/23
*/
if (! defined('BASE_PATH')) {
throw new RuntimeException('installer-runtime.php requires BASE_PATH constant.');
}
if (! defined('INSTALLER_RUNTIME_PATH')) {
define('INSTALLER_RUNTIME_PATH', BASE_PATH.'/storage/installer/runtime.php');
}
if (! function_exists('readInstallerRuntime')) {
/**
* runtime.php 를 읽어 배열로 반환한다.
*
* @return array<string, mixed>|null 파일이 없거나 형식이 잘못된 경우 null
*/
function readInstallerRuntime(): ?array
{
if (! is_file(INSTALLER_RUNTIME_PATH)) {
return null;
}
$data = @include INSTALLER_RUNTIME_PATH;
return is_array($data) ? $data : null;
}
}
if (! function_exists('writeInstallerRuntime')) {
/**
* runtime.php 를 atomic 하게 작성한다.
*
* 디렉토리 부재 시 생성. 파일 권한은 0600 으로 설정.
*
* @param array<string, mixed> $data 저장할 배열
* @return bool 성공 여부
*/
function writeInstallerRuntime(array $data): bool
{
$dir = dirname(INSTALLER_RUNTIME_PATH);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return false;
}
$php = "<?php\n\nreturn ".var_export($data, true).";\n";
$tmp = INSTALLER_RUNTIME_PATH.'.tmp';
if (@file_put_contents($tmp, $php, LOCK_EX) === false) {
return false;
}
// atomic rename
if (! @rename($tmp, INSTALLER_RUNTIME_PATH)) {
@unlink($tmp);
return false;
}
@chmod(INSTALLER_RUNTIME_PATH, 0600);
return true;
}
}
if (! function_exists('deleteInstallerRuntime')) {
/**
* runtime.php 를 삭제한다.
*
* @return bool 삭제 성공 또는 이미 부재 시 true
*/
function deleteInstallerRuntime(): bool
{
if (! is_file(INSTALLER_RUNTIME_PATH)) {
return true;
}
return @unlink(INSTALLER_RUNTIME_PATH);
}
}
if (! function_exists('generateAppKeyInline')) {
/**
* APP_KEY 문자열을 pure PHP 로 생성한다.
*
* Laravel 의 Encrypter::generateKey('AES-256-CBC') 와 동일한 32 byte 키를
* 'base64:' prefix 와 함께 반환한다. artisan key:generate 가 .env 를 직접
* 수정하므로 인스톨러는 본 함수를 사용해 .env 를 건드리지 않고 키를 확보.
*
* @return string Laravel 표준 형식 'base64:...'
*/
function generateAppKeyInline(): string
{
return 'base64:'.base64_encode(random_bytes(32));
}
}
if (! function_exists('mergeRuntimeIntoEnv')) {
/**
* runtime 배열을 .env 문자열에 머지하여 반환한다.
*
* .env.example 템플릿 기반 콘텐츠(generateEnvContent() 결과) 에 runtime 의
* DB 자격증명 + APP_KEY 를 치환하고 INSTALLER_COMPLETED=true 라인을 추가한다.
* 파일 IO 를 수행하지 않으므로 단위 테스트 가능.
*
* 안전망: generateEnvContent() 가 state.config 에서 DB 정보를 못 읽었을 때
* (예: state.json 결손) 에도 runtime 의 DB 정보로 .env 이 완성되도록 동작.
*
* @param string $envContent .env.example 기반 치환된 본문
* @param array<string, mixed> $runtime runtime.php 배열
* @return string 최종 .env 콘텐츠
*/
function mergeRuntimeIntoEnv(string $envContent, array $runtime): string
{
// DB 자격증명 치환 — state.config 결손 안전망
$write = $runtime['db']['write'] ?? null;
if (is_array($write)) {
$envContent = replaceEnvLine($envContent, 'DB_WRITE_HOST', (string) ($write['host'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_PORT', (string) ($write['port'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_DATABASE', (string) ($write['database'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_USERNAME', (string) ($write['username'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_PASSWORD', escapeEnvValue((string) ($write['password'] ?? '')));
}
// Read DB — runtime 에 read 키가 있을 때(use_read_db=true)만 명시 기록한다.
// 미지정 시 DB_READ_* 를 빈 값으로 남겨 config/database.php 의 write fallback(Elvis)
// 에 위임 → .env 에 write 값이 중복 기록되지 않고, write 변경 시 read stale 위험 제거.
// (이슈 #63)
$read = $runtime['db']['read'] ?? null;
if (is_array($read)) {
$envContent = replaceEnvLine($envContent, 'DB_READ_HOST', (string) ($read['host'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_PORT', (string) ($read['port'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_DATABASE', (string) ($read['database'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_USERNAME', (string) ($read['username'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_PASSWORD', escapeEnvValue((string) ($read['password'] ?? '')));
} else {
$envContent = replaceEnvLine($envContent, 'DB_READ_HOST', '');
$envContent = replaceEnvLine($envContent, 'DB_READ_PORT', '');
$envContent = replaceEnvLine($envContent, 'DB_READ_DATABASE', '');
$envContent = replaceEnvLine($envContent, 'DB_READ_USERNAME', '');
$envContent = replaceEnvLine($envContent, 'DB_READ_PASSWORD', '');
}
if (isset($runtime['db']['prefix'])) {
$envContent = replaceEnvLine($envContent, 'DB_PREFIX', (string) $runtime['db']['prefix']);
}
// APP_KEY 치환
$appKey = $runtime['app']['key'] ?? null;
if (is_string($appKey) && str_starts_with($appKey, 'base64:')) {
$envContent = replaceEnvLine($envContent, 'APP_KEY', $appKey);
}
// INSTALLER_COMPLETED 플래그 추가 (CachesModuleStatus 등이 사용)
if (! preg_match('/^INSTALLER_COMPLETED=/m', $envContent)) {
$envContent = rtrim($envContent)."\n\n# Installation Status\nINSTALLER_COMPLETED=true\n";
}
return $envContent;
}
}
if (! function_exists('escapeEnvValue')) {
/**
* .env 값 이스케이프 (functions.php 의 동일 함수 polyfill).
*
* 인스톨러 본 흐름은 functions.php 가 먼저 로드되므로 if 분기로 진입하지
* 않으나, 단위 테스트에서 functions.php 없이 installer-runtime.php 만
* 로드하는 경우를 위한 안전망.
*
* @param string $value 이스케이프할 값
* @return string 큰따옴표로 감싸고 내부 큰따옴표/백슬래시를 이스케이프한 값
*/
function escapeEnvValue(string $value): string
{
// CR/LF 제거 — .env 라인 주입 차단 (functions.php 의 정의와 동일 정책)
if ($value !== '') {
$value = str_replace(["\r", "\n"], '', $value);
}
if ($value === '') {
return '""';
}
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
return '"'.$escaped.'"';
}
}
if (! function_exists('replaceEnvLine')) {
/**
* .env 문자열에서 특정 키의 라인을 치환한다.
*
* 라인이 없으면 끝에 추가. 값에 공백/특수문자가 있는 경우 호출자가
* escapeEnvValue() 등으로 escape 후 전달.
*
* @param string $envContent .env 본문
* @param string $key 환경 변수 키
* @param string $value 대입할 값 (escape 처리된 상태)
* @return string 치환된 .env 본문
*/
function replaceEnvLine(string $envContent, string $key, string $value): string
{
$line = $key.'='.$value;
$pattern = '/^'.preg_quote($key, '/').'=.*$/m';
$replaced = preg_replace($pattern, $line, $envContent, 1, $count);
if ($count === 0) {
return rtrim($envContent)."\n".$line."\n";
}
return $replaced;
}
}
if (! function_exists('buildInstallerRuntimeFromState')) {
/**
* 설치 상태(state.json 의 config) 에서 runtime.php 용 배열을 생성한다.
*
* runtime.php 가 이미 존재하고 APP_KEY 가 있으면 재사용 (재시도 시 키 보존).
* 없으면 generateAppKeyInline() 으로 새 키 생성.
*
* @param array<string, mixed> $stateConfig state.json 의 config 섹션
* @return array<string, mixed> runtime.php 형식의 배열
*/
function buildInstallerRuntimeFromState(array $stateConfig): array
{
$existing = readInstallerRuntime();
$appKey = $existing['app']['key'] ?? null;
if (! $appKey || ! str_starts_with($appKey, 'base64:')) {
$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' => [
'host' => $stateConfig['db_write_host'] ?? ($stateConfig['db_host'] ?? '127.0.0.1'),
'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' => $writePassword,
],
'prefix' => $stateConfig['db_prefix'] ?? '',
],
'app' => [
'key' => $appKey,
],
'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 로
// 자동 동기화. (이슈 #63: use_read_db=false 인데 db_read_host 를 참조하던 회귀 차단)
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' => $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;
}
}