KISA 신고포상제 접수 3건(KVE-2026-1677/1678/1679)의 공통 원인은 하나다 — "위험한 것을 골라 막았고, 목록 밖으로 우회됐다". 세 지점 모두 판정을 뒤집는다. 예약 작업 Artisan (KVE-2026-1679) 차단 9개 / 허용 229개였던 구조를 허용목록으로 바꾸고 명령마다 허용 옵션을 선언한다. 검증 과정에서 보고서보다 짧은 경로를 추가로 발견했다 — 검증기는 preg_split 의 첫 토큰을 명령명으로 봤지만 실행부는 문자열째 넘겼고, parameters 가 비면 Laravel 이 StringInput 으로 재파싱한다. 따옴표 한 쌍이면 차단목록의 tinker 가 그대로 실행됐다 (마커 파일로 확인). 파서를 엄격 형태 하나로 고정하고 실행은 (명령명, 인자배열)로 넘겨 재해석 지점 자체를 없앴다. 언어팩 (KVE-2026-1678) 위험 함수 11개를 정규식으로 나열하던 검사를 토큰 상태기계로 교체해 "번역 배열만" 통과시킨다. 목록에 없는 함수도, 함수 이름을 아예 쓰지 않는 형태도 원리적으로 막힌다. 파일 형식·PHP 위치·심볼릭 링크도 화이트리스트로 뒤집었다. 활성 승격이 require 배선의 필수 조건이므로 설치와 활성화의 권한 경계를 네 설치 경로 전부에 같은 강도로 건다 — 경로별 면제는 곧 우회로가 되기 때문이다. 화면은 운영자의 활성화 권한을 보고 auto_activate 를 실을지 결정하므로 재설치 흐름은 그대로 동작한다. 인스톨러 (KVE-2026-1677) 실행 바이너리 자리에 인자가 들어가는 것을 형태 규칙으로 차단한다. escapeshellarg 는 "하나의 인자" 임만 보장할 뿐 그 인자가 실행 파일인지 스크립트인지는 보장하지 않는다. 판정을 의존성 0 공용 정책으로 모아 진단 API·설치 워커·저장 시점이 같은 규칙을 쓴다. stat 은 호출하지 않는다 — 접근이 제한된 서버에서 정상 경로가 거부되던 회귀를 재발시키지 않기 위함이다. 검증: 백엔드 392건 + 프론트 7건 green(skip 1, symlink 미지원 환경). PoC 마커로 red 를 먼저 확보하고, 수정 후 stash 역회귀로 다시 red 가 되는 것까지 확인했다.
319 lines
11 KiB
PHP
319 lines
11 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
|
|
: '';
|
|
|
|
// 코어 업데이트 _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
|
|
};
|
|
}
|