Files
Gnuboard7/public/install/includes/request-handler.php
T
HeuJung 9cf9c3ff8c fix(core,board,ecommerce,payments,basic): KVE-2026 보안 게이트 6묶음 + 자격증명 전송로 정합
KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과
그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다.

globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를
직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다.
서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이
그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고,
같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다.

아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩
걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
2026-09-06 00:42:29 +09:00

344 lines
13 KiB
PHP

<?php
use App\Support\PrivilegedDatabaseAccounts;
/**
* 그누보드7 웹 인스톨러 요청 처리 핸들러
*
* 모든 POST 요청 및 비즈니스 로직을 처리합니다.
*/
/**
* 설치 흐름 검증 및 리다이렉트
*
* @param int $currentStep 현재 단계
* @param array $state 설치 상태
*/
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();
}
// 설치 진행 중 체크 (Step 5 제외 - Installation 화면)
if (isInstallationRunning() && $currentStep !== 5) {
showInstallationRunningAlert();
}
// 상태 파일의 last_completed_step 가져오기
$lastCompletedStep = getLastCompletedStep();
// 허용된 최대 단계 계산 (마지막 완료 단계 + 1)
$allowedMaxStep = $lastCompletedStep + 1;
// 현재 단계가 허용된 범위를 벗어나면 알림 후 올바른 단계로 리다이렉트
if ($currentStep > $allowedMaxStep) {
showInvalidStepAccessAlert($allowedMaxStep);
}
}
/**
* 잘못된 단계 접근 시 알림 표시
*
* @param int $correctStep 올바른 단계 번호
*/
function showInvalidStepAccessAlert(int $correctStep): void
{
global $translations;
// 번역이 로드되지 않은 경우 로드
if (! isset($translations)) {
$translations = loadTranslations(getCurrentLanguage());
}
// 세션에 올바른 단계 설정
$_SESSION['installer_current_step'] = $correctStep;
showAlertAndRedirect(
lang('error_invalid_step_access'),
lang('error_invalid_step_access_message'),
INSTALLER_BASE_URL.'/'
);
}
/**
* Step 0 POST 처리 (환영 화면)
*/
function handleStep0Post(): void
{
if (isset($_POST['language'])) {
handleLanguageChange($_POST['language']);
}
// 다음 버튼 클릭 시 (언어 선택 변경이 아닌 경우)
if (! isset($_POST['language_change_only'])) {
unset($_SESSION['license_agreed']);
updateStepStatus(0, 1);
redirectToStep(1);
}
redirectToStep(0);
}
/**
* Step 1 POST 처리 (라이선스 동의)
*
* @param string $currentLang 현재 언어
* @param string|null &$error 에러 메시지 (참조)
*/
function handleStep1Post(string $currentLang, ?string &$error = null): void
{
$translations = loadTranslations($currentLang);
if (! isset($_POST['agree']) || $_POST['agree'] !== '1') {
$error = lang('must_agree');
} else {
$_SESSION['license_agreed'] = true;
updateStepStatus(1, 2);
redirectToStep(2);
}
}
/**
* Step 2 POST 처리 (요구사항 검증)
*/
function handleStep2Post(): void
{
if (isset($_POST['proceed'])) {
updateStepStatus(2, 3);
redirectToStep(3);
}
}
/**
* DB 사용자명을 검증해 에러 배열에 결과를 기록합니다.
*
* 빈 값(설정 누락)과 DB 최고권한 계정을 모두 거부합니다. 최고권한 계정 자격증명이
* 유출되면 데이터베이스 전체가 위험해지므로 설치 단계에서 차단합니다.
* 판정은 App\Support\PrivilegedDatabaseAccounts 가 SSoT 입니다.
*
* @param string $username 입력된 DB 사용자명
* @param string $field 에러 배열에 사용할 필드명 (db_write_username 등)
* @param array &$errors 에러 배열 (참조)
*/
function validateDbUsername(string $username, string $field, array &$errors): void
{
$username = trim($username);
if ($username === '') {
$errors[$field] = lang('error_db_username_required');
return;
}
if (PrivilegedDatabaseAccounts::isBlocked($username)) {
$errors[$field] = lang('error_db_username_privileged', ['username' => $username]);
}
}
/**
* Step 3 POST 처리 (데이터베이스 및 사이트 설정)
*
* @param string $currentLang 현재 언어
* @param array &$formData 폼 데이터 (참조)
* @param array &$errors 에러 배열 (참조)
*/
function handleStep3Post(string $currentLang, array &$formData, array &$errors): void
{
$translations = loadTranslations($currentLang);
$formData = array_merge($formData, $_POST);
$formData['use_read_db'] = isset($_POST['use_read_db']) ? true : false;
// Write DB 검증
if (empty($formData['db_write_host'])) {
$errors['db_write_host'] = lang('error_db_host_required');
}
if (empty($formData['db_write_database'])) {
$errors['db_write_database'] = lang('error_db_name_required');
}
validateDbUsername($formData['db_write_username'] ?? '', 'db_write_username', $errors);
// Read DB 검증 (사용하는 경우)
if (! empty($formData['use_read_db'])) {
if (empty($formData['db_read_host'])) {
$errors['db_read_host'] = lang('error_db_host_required');
}
if (empty($formData['db_read_database'])) {
$errors['db_read_database'] = lang('error_db_name_required');
}
validateDbUsername($formData['db_read_username'] ?? '', 'db_read_username', $errors);
}
// 관리자 정보 검증
if (empty($formData['admin_email']) || ! filter_var($formData['admin_email'], FILTER_VALIDATE_EMAIL)) {
$errors['admin_email'] = lang('error_admin_email_invalid');
}
if (! array_key_exists($formData['admin_language'] ?? '', SUPPORTED_LANGUAGES)) {
$errors['admin_language'] = lang('error_admin_language_invalid');
}
if (empty($formData['admin_password']) || strlen($formData['admin_password']) < 8) {
$errors['admin_password'] = lang('error_admin_password_min');
}
if ($formData['admin_password'] !== $formData['admin_password_confirm']) {
$errors['admin_password_confirm'] = lang('error_password_mismatch');
}
// PHP CLI / Composer 경로 처리 — 저장 시점에 허용 형태를 강제한다.
// 이 값들은 설치 워커에서 실제 명령의 실행 바이너리가 되므로, 형태 위반 값이
// 애초에 .env / 설치 상태에 기록되지 않게 여기서 막는다.
require_once __DIR__.'/binary-path-policy.php';
$phpBinary = trim($formData['php_binary'] ?? 'php');
$phpBinary = $phpBinary !== '' ? $phpBinary : 'php';
if ($phpBinary !== 'php' && ! installer_binary_path_shape_ok($phpBinary)) {
$errors['php_binary'] = lang('error_php_binary_path_not_allowed', ['path' => $phpBinary]);
}
$formData['php_binary'] = $phpBinary;
$composerBinary = trim($formData['composer_binary'] ?? '');
if ($composerBinary !== '' && $composerBinary !== 'composer') {
// 단일 토큰이면 Composer 자리 규칙, 공백 분리 입력이면 (PHP, Composer) 쌍 해석.
$allowed = str_contains($composerBinary, ' ')
? installer_resolve_php_composer_pair($composerBinary) !== null
: installer_is_composer_binary_path($composerBinary);
if (! $allowed) {
$errors['composer_binary'] = lang('error_composer_binary_path_not_allowed', ['path' => $composerBinary]);
}
}
$formData['composer_binary'] = $composerBinary;
// Vendor 설치 모드 처리 (auto|composer|bundled)
$vendorMode = trim($formData['vendor_mode'] ?? 'auto');
if (! in_array($vendorMode, ['auto', 'composer', 'bundled'], true)) {
$vendorMode = 'auto';
}
$formData['vendor_mode'] = $vendorMode;
// 자산 URL 방식 (이슈 #486) — Step 3 의 브라우저 프로브가 채운 hidden 필드.
// 정적 최적화 블록이 있는 서버는 확장자 붙은 동적 응답이 PHP 에 도달하지 못하므로
// 설치 시점에 확장자 없는 형태로 확정해야 첫 화면부터 정상 동작한다.
// 판정 불가(프로브 실패·JS 미실행)면 키를 비워 defaults.json 기본값을 따르게 한다.
$assetUrlMode = trim($formData['asset_url_mode'] ?? '');
$formData['asset_url_mode'] = in_array($assetUrlMode, ['extension', 'extensionless'], true)
? $assetUrlMode
: '';
// .env 로 기록되는 사용자 입력의 사전 거부 (KVE-2026-2042)
// 직렬화기가 최종 관문이지만, 그 단계의 실패는 설치 진행 중에 예외로 드러나 운영자가
// 어느 입력이 문제인지 알기 어렵다. 여기서 필드별로 거부해 화면에 사유를 표시한다.
require_once __DIR__.'/env-value.php';
foreach (['app_name', 'app_url', 'core_update_github_url'] as $envField) {
if (! installer_env_value_is_single_line((string) ($formData[$envField] ?? ''))) {
$errors[$envField] = lang('error_env_value_line_break');
}
}
// URL 필드는 형태까지 확인한다 — 개행이 없어도 스킴이 없는 값이 그대로 기록되면
// 배포 후 절대 URL 생성이 어긋난다.
foreach (['app_url', 'core_update_github_url'] as $urlField) {
$urlValue = trim((string) ($formData[$urlField] ?? ''));
if ($urlValue === '' || isset($errors[$urlField])) {
continue;
}
$scheme = strtolower((string) parse_url($urlValue, PHP_URL_SCHEME));
if (! filter_var($urlValue, FILTER_VALIDATE_URL) || ! in_array($scheme, ['http', 'https'], true)) {
$errors[$urlField] = lang('error_env_value_invalid_url', ['value' => $urlValue]);
}
}
// 코어 업데이트 _pending 경로 검증 (입력된 경우만)
$corePendingPath = trim($formData['core_update_pending_path'] ?? '');
if ($corePendingPath !== '') {
// 절대 경로 변환
$absolutePath = str_starts_with($corePendingPath, '/')
? $corePendingPath
: BASE_PATH.'/'.$corePendingPath;
if (file_exists($absolutePath) && ! is_dir($absolutePath)) {
$errors['core_update_pending_path'] = lang('error_core_pending_not_directory');
}
}
// DB 테스트 완료 확인
if (empty($errors) && ! isset($_SESSION['db_write_tested'])) {
$errors['db_test'] = lang('error_db_not_tested');
}
if (empty($errors) && ! empty($formData['use_read_db']) && ! isset($_SESSION['db_read_tested'])) {
$errors['db_test'] = lang('error_db_not_tested');
}
// 검증 통과 시 다음 단계로 이동
if (empty($errors)) {
// 세션에는 비밀번호를 유지한다 (install-process.php 가 runtime.php 로 이송).
// 단 admin_password_confirm 은 검증 이후 소비처가 전무한 순수 잔여물이므로 제거.
$sessionConfig = $formData;
unset($sessionConfig['admin_password_confirm']);
$_SESSION['install_config'] = $sessionConfig;
// state.json 에는 비밀(DB/관리자 비밀번호) 을 저장하지 않는다 (이슈 #465).
// read 필드 정리(이슈 #63 2단계 방어) 도 sanitizeConfigForState 가 함께 수행.
$safeFormData = sanitizeConfigForState($formData);
updateStepStatus(3, 4, [
'config' => $safeFormData,
'installation_status' => 'ready',
]);
redirectToStep(4);
}
}
/**
* POST 요청 라우팅
*
* @param int $currentStep 현재 단계
* @param string $currentLang 현재 언어
* @param array &$formData 폼 데이터 (참조)
* @param array &$errors 에러 배열 (참조)
* @param string|null &$error 에러 메시지 (참조)
*/
function handlePostRequest(int $currentStep, string $currentLang, array &$formData = [], array &$errors = [], ?string &$error = null): void
{
// 단계 이동 처리 (go_to_step POST 파라미터)
if (isset($_POST['go_to_step'])) {
$step = (int) $_POST['go_to_step'];
if ($step >= 0 && $step <= 5) {
$_SESSION['installer_current_step'] = $step;
}
header('Location: '.INSTALLER_BASE_URL.'/');
exit;
}
// 구 방식 언어 전환 처리 (호환성 유지)
if (isset($_POST['change_language'])) {
$lang = $_POST['lang'] ?? 'ko';
handleLanguageChange($lang);
redirectToStep($currentStep);
}
// Step별 POST 처리
match ($currentStep) {
0 => handleStep0Post(),
1 => handleStep1Post($currentLang, $error),
2 => handleStep2Post(),
3 => handleStep3Post($currentLang, $formData, $errors),
default => null
};
}