Files
Gnuboard7/public/install/api/check-configuration.php
T
HeuJung 4b6adf4788 fix(security): 예약 작업·언어팩·인스톨러의 차단목록을 허용목록으로 전환
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 가 되는 것까지 확인했다.
2026-08-04 17:48:05 +09:00

1154 lines
43 KiB
PHP

<?php
use App\Support\OpcacheStatus;
use App\Support\PrivilegedDatabaseAccounts;
/**
* G7 인스톨러 - 검증 통합 API
*
* 서버 요구사항 검증 및 데이터베이스 연결 테스트 기능을 하나의 API로 통합
*
* 엔드포인트:
* - GET ?action=requirements : 서버 요구사항 검증 (action 생략 시 기본값)
* - POST ?action=test-db : 데이터베이스 연결 테스트
*/
// 실행 바이너리 경로 허용 형태 정책 — 인스톨러 API 와 설치 워커가 같은 규칙을 공유한다.
// 한쪽만 고치면 다른 쪽이 우회로가 되므로 의존성 없는 공용 파일로 두고 양쪽에서 로드한다.
require_once __DIR__.'/../includes/binary-path-policy.php';
/**
* 검증 API 클래스
*/
class ValidationApi
{
/**
* 요청 처리 메인 메서드
*/
public function handleRequest(): void
{
// JSON 헤더 설정
$this->setJsonHeaders();
// HTTP 메서드 및 action 파라미터 확인
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? ($method === 'GET' ? 'requirements' : null);
try {
// action에 따라 적절한 메서드 호출
match ($action) {
'requirements' => $this->checkRequirements(),
'test-db' => $this->testDbConnection(),
'detect-php' => $this->detectPhpBinaries(),
'test-php-binary' => $this->testPhpBinary(),
'test-composer' => $this->testComposer(),
'check-core-pending-path' => $this->checkCorePendingPath(),
default => $this->error400('Invalid action parameter'),
};
} catch (Throwable $e) {
$this->error500($e);
}
}
/**
* JSON 응답 헤더 설정
*/
private function setJsonHeaders(): void
{
header('Content-Type: application/json; charset=utf-8');
}
/**
* GET ?action=requirements
* 서버 요구사항 검증
*
* PHP 버전, 확장 모듈, 디스크 공간, 디렉토리 권한, HTTPS 등을 검증합니다.
*/
private function checkRequirements(): void
{
// 요구사항 검증 결과 배열
$requirements = [
'php_version' => $this->checkPhpVersion(),
'php_extensions' => $this->checkPhpExtensions(),
'disabled_functions' => $this->checkDisabledFunctions(),
'php_cli_version' => $this->checkPhpCliVersion(),
'disk_space' => $this->checkDiskSpace(),
'directories' => $this->checkDirectoryPermissions(),
'required_files' => $this->checkRequiredFiles(),
'https' => $this->checkHttps(),
'opcache' => $this->checkOpcache(),
'asset_url_mode' => $this->checkAssetUrlMode(),
];
// 모든 필수 요구사항 통과 여부
$requirements['all_required_passed'] = $this->isAllRequiredPassed($requirements);
// OS 정보 (프론트엔드에서 명령어 분기용)
$requirements['is_windows'] = isWindows();
// JSON 응답 반환
echo json_encode($requirements, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
/**
* POST ?action=test-db
* 데이터베이스 연결 테스트
*
* POST로 전달받은 데이터베이스 연결 정보를 사용하여
* 연결 테스트 및 권한 검증을 수행합니다.
*/
private function testDbConnection(): void
{
// POST 메서드만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode([
'success' => false,
'message' => lang('api_method_not_allowed'),
], JSON_UNESCAPED_UNICODE);
exit;
}
try {
// POST 데이터 파싱
$input = json_decode(file_get_contents('php://input'), true);
if (! $input || ! is_array($input)) {
throw new Exception(lang('api_invalid_request'));
}
// DB 타입 확인 ('write' 또는 'read')
$type = $input['type'] ?? 'write';
$isReadDb = ($type === 'read');
// DB 연결 정보 구성
$config = [];
if ($isReadDb) {
$config['db_read_host'] = $input['host'] ?? 'localhost';
$config['db_read_port'] = $input['port'] ?? '3306';
$config['db_read_database'] = $input['database'] ?? '';
$config['db_read_username'] = $input['username'] ?? '';
$config['db_read_password'] = $input['password'] ?? '';
} else {
$config['db_write_host'] = $input['host'] ?? 'localhost';
$config['db_write_port'] = $input['port'] ?? '3306';
$config['db_write_database'] = $input['database'] ?? '';
$config['db_write_username'] = $input['username'] ?? '';
$config['db_write_password'] = $input['password'] ?? '';
}
// 필수 필드 검증
$database = $isReadDb ? $config['db_read_database'] : $config['db_write_database'];
$username = $isReadDb ? $config['db_read_username'] : $config['db_write_username'];
if (empty($database) || empty($username)) {
throw new Exception(lang('error_db_credentials_required'));
}
// DB 최고권한 계정 차단 (Write/Read 공통).
// 최고권한 계정 자격증명이 유출되면 데이터베이스 전체가 위험해지므로
// 연결 시도 전에 막는다. 판정은 App\Support\PrivilegedDatabaseAccounts 가 SSoT.
if (PrivilegedDatabaseAccounts::isBlocked($username)) {
throw new Exception(
lang('error_db_username_privileged', ['username' => $username])
);
}
// DB 테이블 접두사 길이 검증 (Write DB 입력에만 적용)
// 접두사가 길면 자동 생성 인덱스명이 MySQL identifier 한도(64자)를 초과해
// 일부 확장 설치 시 마이그레이션이 실패하므로 연결 시도 전에 차단한다.
if (! $isReadDb) {
$tablePrefix = (string) ($input['db_prefix'] ?? '');
if (strlen($tablePrefix) > MAX_DB_PREFIX_LENGTH) {
throw new Exception(
lang('error_db_prefix_too_long', [
'max' => MAX_DB_PREFIX_LENGTH,
'current' => strlen($tablePrefix),
])
);
}
}
// 데이터베이스 연결 시도
$pdo = getDatabaseConnection($config, $isReadDb);
// 권한 검증
$privileges = checkDatabasePrivileges($pdo, $database, $isReadDb);
// 권한 부족 확인
if (! $privileges['has_all'] && ! empty($privileges['missing'])) {
$missingPrivs = implode(', ', $privileges['missing']);
$dbTypeLabel = $isReadDb ? 'Read' : 'Write';
throw new Exception(
lang('error_db_privileges_insufficient_detail', ['type' => $dbTypeLabel, 'missing' => $missingPrivs])
);
}
// 연결 및 권한 검증 성공 플래그 (Write/Read 구분)
if ($isReadDb) {
$_SESSION['db_read_tested'] = true;
$message = lang('success_db_read_connected');
} else {
$_SESSION['db_write_tested'] = true;
$message = lang('success_db_write_connected');
}
// 기존 테이블 감지 (Write DB만 수행)
// 사용자가 입력한 db_prefix를 g7 시그니처에 적용하여 정확히 비교
$existingTables = null;
if (! $isReadDb) {
$detectPrefix = ($input['db_prefix'] ?? '') !== '' ? (string) $input['db_prefix'] : 'g7_';
$existingTables = checkExistingTables($pdo, $database, $detectPrefix);
}
// 성공 응답
echo json_encode([
'success' => true,
'message' => $message,
'type' => $type,
'privileges' => [
'has_all' => $privileges['has_all'],
'found' => $privileges['found'],
'required' => $privileges['required'],
],
'existing_tables' => $existingTables,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
} catch (PDOException $e) {
// 연결 실패 플래그
$isReadDb = isset($type) && $type === 'read';
if ($isReadDb) {
$_SESSION['db_read_tested'] = false;
} else {
$_SESSION['db_write_tested'] = false;
}
// 에러 로깅
logInstallationError(lang('error_db_connection_failed', ['error' => $e->getMessage()]), $e);
// 에러 응답 (200 OK + success: false)
echo json_encode([
'success' => false,
'message' => lang('error_db_connection_failed_detail', ['type' => ($isReadDb ? 'Read' : 'Write'), 'error' => $e->getMessage()]),
'type' => $type ?? 'write',
], JSON_UNESCAPED_UNICODE);
} catch (Exception $e) {
// 권한 검증 실패 또는 기타 에러
$isReadDb = isset($type) && $type === 'read';
if (isset($type)) {
if ($isReadDb) {
$_SESSION['db_read_tested'] = false;
} else {
$_SESSION['db_write_tested'] = false;
}
}
// 에러 로깅
logInstallationError(lang('error_db_test_failed'), $e);
// 에러 응답 (200 OK + success: false)
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
'type' => $type ?? 'write',
], JSON_UNESCAPED_UNICODE);
}
}
// ========================================
// 서버 요구사항 검증 헬퍼 메서드
// ========================================
/**
* PHP 버전 검증
*/
private function checkPhpVersion(): array
{
$currentVersion = PHP_VERSION;
$minVersion = MIN_PHP_VERSION;
$passed = version_compare($currentVersion, $minVersion, '>=');
return [
'required' => true,
'min_version' => $minVersion,
'current_version' => $currentVersion,
'passed' => $passed,
'message' => $passed
? lang('success_php_version_detail', ['current' => $currentVersion, 'min' => $minVersion])
: lang('error_php_version_insufficient_detail', ['current' => $currentVersion, 'min' => $minVersion]),
];
}
/**
* PHP 확장 모듈 검증
*
* 필수 및 선택적 확장 모듈을 검증합니다.
* 선택적 모듈은 설치 여부만 표시하며 전체 검증 통과에 영향을 주지 않습니다.
*/
private function checkPhpExtensions(): array
{
$requiredExtensions = REQUIRED_EXTENSIONS;
$optionalExtensions = defined('OPTIONAL_EXTENSIONS') ? OPTIONAL_EXTENSIONS : [];
$installed = [];
$allRequiredPassed = true;
// 필수 확장 검증
foreach ($requiredExtensions as $extension) {
$isLoaded = extension_loaded($extension);
$installed[$extension] = [
'required' => true,
'installed' => $isLoaded,
];
if (! $isLoaded) {
$allRequiredPassed = false;
}
}
// 선택적 확장 검증 (설치 여부만 표시)
$optionalInstalled = [];
foreach ($optionalExtensions as $extension) {
$optionalInstalled[$extension] = [
'required' => false,
'installed' => extension_loaded($extension),
];
}
return [
'required' => $requiredExtensions,
'optional' => $optionalExtensions,
'installed' => $installed,
'optional_installed' => $optionalInstalled,
'all_required_passed' => $allRequiredPassed,
'message' => $allRequiredPassed
? lang('success_php_extensions')
: lang('error_php_extensions_missing'),
];
}
/**
* 디스크 공간 검증
*/
private function checkDiskSpace(): array
{
$minSpaceMb = MIN_DISK_SPACE_MB;
$freeSpaceBytes = @disk_free_space(BASE_PATH);
// disk_free_space() 실패 시
if ($freeSpaceBytes === false) {
return [
'required' => true,
'min_mb' => $minSpaceMb,
'current_mb' => 0,
'passed' => false,
'message' => lang('error_disk_space_unknown'),
];
}
$freeSpaceMb = round($freeSpaceBytes / 1024 / 1024, 2);
$passed = $freeSpaceMb >= $minSpaceMb;
return [
'required' => true,
'min_mb' => $minSpaceMb,
'current_mb' => $freeSpaceMb,
'passed' => $passed,
'message' => $passed
? lang('success_disk_space_detail', ['current' => $freeSpaceMb, 'min' => $minSpaceMb])
: lang('error_disk_space_insufficient_detail', ['current' => $freeSpaceMb, 'min' => $minSpaceMb]),
];
}
/**
* 디렉토리 권한 검증
*
* functions.php의 공통 함수를 사용하여 권한을 체크합니다.
*/
private function checkDirectoryPermissions(): array
{
// 공통 함수 호출
$check = checkDirectoryPermissions(REQUIRED_DIRECTORIES);
// API 응답 형식에 맞게 변환
$results = [];
foreach ($check['results'] as $dir => $result) {
$results[$dir] = [
'path' => $dir,
'full_path' => BASE_PATH.'/'.$dir,
'relative_path' => './'.$dir,
'exists' => $result['exists'],
'writable' => $result['writable'],
'readable' => $result['readable'],
'permissions' => $result['permissions'],
'owner' => $result['owner'],
'group' => $result['group'],
'passed' => $result['passed'],
'error_type' => $result['error_type'],
'has_subdirectory_issues' => $result['has_subdirectory_issues'],
'failed_subdirectories' => $result['failed_subdirectories'],
];
}
return [
'required' => true,
'paths' => array_keys(REQUIRED_DIRECTORIES),
'results' => $results,
'required_permissions' => REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY,
'web_server_group' => getWebServerGroup() ?? getWebServerUser() ?? 'www-data',
'web_server_user' => getWebServerUser(),
'all_passed' => $check['all_passed'],
'message' => $check['all_passed']
? lang('success_directories_writable')
: lang('error_directory_not_writable_detail', ['permissions' => REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY]),
];
}
/**
* 필수 파일 존재 여부 검증
*
* .env 파일이 프로젝트 루트에 존재하는지 확인합니다.
*/
private function checkRequiredFiles(): array
{
$basePath = BASE_PATH;
$filePaths = [
'.env' => $basePath.'/.env',
];
$createCommands = [
'.env' => getEnvCopyCommand($basePath),
];
$webServerUser = getWebServerUser();
$files = [];
$allPassed = true;
$hasNotWritable = false;
foreach ($filePaths as $name => $fullPath) {
$exists = file_exists($fullPath);
$writable = $exists && is_writable($fullPath);
// 통과 조건: 존재 + 쓰기 가능
$passed = $exists && $writable;
if (! $passed) {
$allPassed = false;
}
// 권한 비트/소유자 정보 수집 (ownership_mismatch 판별용)
$permissions = 'N/A';
$permsOctal = 0;
$owner = null;
if ($exists) {
$perms = fileperms($fullPath);
$permissions = substr(sprintf('%o', $perms), -3);
$permsOctal = octdec($permissions);
$owner = getFileOwnerName($fullPath);
}
// 에러 타입 결정
// ownership_mismatch: 파일이 존재하고 권한이 0644 이상인데도 쓰기 불가
// → 소유자와 웹서버 실행 사용자 불일치
$errorType = null;
if (! $exists) {
$errorType = 'not_exists';
} elseif (! $writable) {
if ($permsOctal >= 0644 && $webServerUser && $owner && $owner !== $webServerUser) {
$errorType = 'ownership_mismatch';
} else {
$errorType = 'not_writable';
}
$hasNotWritable = true;
}
$files[$name] = [
'exists' => $exists,
'writable' => $writable,
'passed' => $passed,
'error_type' => $errorType,
'permissions' => $permissions,
'owner' => $owner,
'web_server_user' => $webServerUser,
'command' => $createCommands[$name],
];
}
// base_path 소유자 정보 — .env 파일 생성 시 chgrp 포함 여부 판단용
// 프로젝트 루트 소유자가 웹서버 실행 사용자와 다른 경우, 생성 직후 ownership_mismatch가
// 재현되므로 복사 명령 자체에 chgrp + chmod 664를 미리 포함시키기 위함
$basePathOwner = function_exists('posix_getpwuid') && is_dir($basePath)
? (posix_getpwuid(fileowner($basePath))['name'] ?? null)
: null;
$basePathOwnerMatchesWebUser = $basePathOwner && $webServerUser && $basePathOwner === $webServerUser;
return [
'required' => true,
'files' => $files,
'all_passed' => $allPassed,
'has_not_writable' => $hasNotWritable,
'web_server_group' => getWebServerGroup() ?? $webServerUser ?? 'www-data',
'web_server_user' => $webServerUser,
'base_path' => $basePath,
'base_path_owner' => $basePathOwner,
'base_path_owner_matches_web_user' => $basePathOwnerMatchesWebUser,
'relative_base_path' => '.',
'message' => $allPassed
? lang('success_required_files')
: lang('error_required_files_missing'),
];
}
/**
* HTTPS 사용 여부 확인
*/
private function checkHttps(): array
{
$isHttps = isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) === 'on';
// HTTP_X_FORWARDED_PROTO 헤더도 확인 (프록시 환경)
if (! $isHttps && isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
$isHttps = strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https';
}
return [
'required' => false, // HTTPS는 선택 사항
'enabled' => $isHttps,
'message' => $isHttps
? lang('https_enabled')
: lang('https_disabled'),
];
}
/**
* OPcache 활성화 여부 검증 (권장 사항 — 설치를 차단하지 않음)
*
* 확장 로드 여부만으로는 부족하다. `Zend OPcache` 가 로드돼 있어도
* `opcache.enable=0` 이면 실제로는 동작하지 않으므로 지시자까지 확인한다.
* 판정은 App\Support\OpcacheStatus 가 SSoT 이며, 코어 관리자 화면과 같은 답을 낸다.
*
* `enabled` 가 null 이면 "확인 불가"(ini_get 차단 환경)로, 경고도 차단도 하지 않는다.
*/
private function checkOpcache(): array
{
$status = OpcacheStatus::probe();
if ($status['enabled'] === null) {
$message = lang('opcache_unknown');
} else {
$message = $status['enabled']
? lang('opcache_enabled')
: lang('opcache_disabled_warning');
}
return [
'required' => false, // OPcache는 선택 사항 (성능 권장)
'loaded' => $status['loaded'],
'enabled' => $status['enabled'],
'message' => $message,
];
}
/**
* 자산 URL 방식 항목 정의 (권장 사항 — 설치를 차단하지 않음)
*
* 판정 자체는 **서버가 할 수 없다.** 서버에서 자기 APP_URL 로 curl 하면 loopback 이
* nginx vhost·SSL·프록시 체인을 우회하거나 다른 vhost 를 타서, 실제 방문자가 겪는
* 것과 다른 답을 낸다. 그래서 여기서는 프로브 경로만 내려주고 실제 판정은
* 브라우저가 수행한다 (`installer.js::probeAssetUrlMode`).
*
* `detected` 가 항상 null 인 것은 미구현이 아니라 위 이유에 따른 설계다.
* 프론트가 이 값을 받아 카드를 "확인 중" 상태로 먼저 그리고, 프로브가 끝나면 갱신한다.
*/
private function checkAssetUrlMode(): array
{
return [
'required' => false, // 어느 방식이든 정상 — 통과/실패 게이트가 아니다
'detected' => null, // 브라우저 프로브가 채운다 (서버 판정 불가)
'probe_extension' => '/api/system/asset-probe.js',
'probe_extensionless' => '/api/system/asset-probe',
];
}
/**
* 필수 PHP 함수(exec, proc_open) 비활성화 여부 검증
*/
private function checkDisabledFunctions(): array
{
$requiredFunctions = ['exec', 'proc_open', 'shell_exec'];
$disabledStr = ini_get('disable_functions');
$disabledList = array_map('trim', explode(',', $disabledStr));
$disabled = [];
foreach ($requiredFunctions as $func) {
if (in_array($func, $disabledList, true)) {
$disabled[] = $func;
}
}
$passed = empty($disabled);
return [
'required' => true,
'checked_functions' => $requiredFunctions,
'disabled' => $disabled,
'passed' => $passed,
'message' => $passed
? lang('success_required_functions')
: lang('error_disabled_functions', ['functions' => implode(', ', $disabled)]),
];
}
/**
* PHP CLI 버전과 웹 PHP 버전 일치 여부 확인
*/
private function checkPhpCliVersion(): array
{
$webVersion = PHP_VERSION;
$cliVersion = null;
$cliPath = 'php';
// exec 사용 가능 여부 먼저 확인
$disabledStr = ini_get('disable_functions');
$disabledList = array_map('trim', explode(',', $disabledStr));
if (in_array('exec', $disabledList, true)) {
return [
'required' => false,
'web_version' => $webVersion,
'cli_version' => null,
'cli_path' => null,
'matched' => null,
'message' => lang('php_cli_version_check_skipped'),
];
}
$output = [];
$returnCode = -1;
exec('php --version 2>&1', $output, $returnCode);
if ($returnCode === 0 && ! empty($output)) {
$outputStr = implode("\n", $output);
if (preg_match('/PHP\s+(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
$cliVersion = $matches[1];
}
}
// 메이저.마이너 버전 비교 (패치 차이는 무시)
$webMajorMinor = implode('.', array_slice(explode('.', $webVersion), 0, 2));
$cliMajorMinor = $cliVersion ? implode('.', array_slice(explode('.', $cliVersion), 0, 2)) : null;
$matched = $cliMajorMinor !== null && $webMajorMinor === $cliMajorMinor;
return [
'required' => false,
'web_version' => $webVersion,
'cli_version' => $cliVersion,
'cli_path' => $cliPath,
'matched' => $matched,
'message' => $cliVersion === null
? lang('php_cli_version_unknown')
: ($matched
? lang('php_cli_version_matched', ['web' => $webVersion, 'cli' => $cliVersion])
: lang('php_cli_version_mismatch', ['web' => $webVersion, 'cli' => $cliVersion])),
];
}
/**
* GET ?action=detect-php
* 서버에서 사용 가능한 PHP 바이너리 자동 탐색
*/
private function detectPhpBinaries(): void
{
$commonPaths = [
'/usr/local/php84/bin/php',
'/usr/local/php83/bin/php',
'/usr/local/php82/bin/php',
'/usr/bin/php8.4',
'/usr/bin/php8.3',
'/usr/bin/php8.2',
'/usr/local/bin/php',
'/usr/bin/php',
];
$found = [];
$checkedPaths = [];
$minVersion = MIN_PHP_VERSION;
// PHP_BINARY 상수
if (defined('PHP_BINARY') && PHP_BINARY !== '' && ! in_array(PHP_BINARY, $checkedPaths, true)) {
$checkedPaths[] = PHP_BINARY;
$result = $this->validatePhpPath(PHP_BINARY);
if ($result['valid']) {
$found[] = ['path' => PHP_BINARY, 'version' => $result['version']];
}
}
// 공통 경로 스캔
foreach ($commonPaths as $path) {
if (in_array($path, $checkedPaths, true)) {
continue;
}
$checkedPaths[] = $path;
if (! file_exists($path)) {
continue;
}
$result = $this->validatePhpPath($path);
if ($result['valid']) {
$found[] = ['path' => $path, 'version' => $result['version']];
}
}
// 시스템 PATH의 'php'
$defaultPhpAvailable = false;
if (! in_array('php', $checkedPaths, true)) {
$result = $this->validatePhpPath('php');
if ($result['valid']) {
$found[] = ['path' => 'php', 'version' => $result['version']];
$defaultPhpAvailable = true;
}
} else {
// 이미 체크된 경우 found 배열에서 확인
foreach ($found as $bin) {
if ($bin['path'] === 'php') {
$defaultPhpAvailable = true;
break;
}
}
}
// Composer 자동 감지 (감지된 PHP 중 첫 번째로 검증)
$phpForComposer = ! empty($found) ? $found[0]['path'] : 'php';
$composerResult = $this->detectComposerBinary($phpForComposer);
echo json_encode([
'success' => ! empty($found),
'binaries' => $found,
'default_php_available' => $defaultPhpAvailable,
'composer' => $composerResult,
'message' => empty($found) ? lang('no_php_detected') : lang('php_detected_count', ['count' => count($found)]),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
/**
* Composer 바이너리 자동 감지
*
* @param string $phpPath .phar 실행 시 사용할 PHP 경로
* @return array{found: bool, path: string|null, version: string|null}
*/
private function detectComposerBinary(string $phpPath = 'php'): array
{
// 1. 시스템 PATH의 composer
$result = $this->validateComposerPath('composer', $phpPath);
if ($result['valid']) {
return ['found' => true, 'path' => 'composer', 'version' => $result['version']];
}
// 2. 프로젝트 루트의 composer.phar
$pharPath = realpath(__DIR__.'/../../..').'/composer.phar';
if (file_exists($pharPath)) {
$result = $this->validateComposerPath($pharPath, $phpPath);
if ($result['valid']) {
return ['found' => true, 'path' => $pharPath, 'version' => $result['version']];
}
}
// 3. 현재 디렉토리(public/install/api)의 composer.phar
$localPhar = realpath(__DIR__).'/composer.phar';
if (file_exists($localPhar)) {
$result = $this->validateComposerPath($localPhar, $phpPath);
if ($result['valid']) {
return ['found' => true, 'path' => $localPhar, 'version' => $result['version']];
}
}
return ['found' => false, 'path' => null, 'version' => null];
}
/**
* POST ?action=test-php-binary
* 지정된 PHP 바이너리 경로의 유효성 검증
*/
private function testPhpBinary(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->error400('POST method required');
}
$input = json_decode(file_get_contents('php://input'), true);
$path = $input['path'] ?? 'php';
$result = $this->validatePhpPath($path);
echo json_encode([
'success' => $result['valid'],
'version' => $result['version'],
'message' => $result['message'],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
/**
* POST ?action=test-composer
* Composer 바이너리 경로의 유효성 검증
*/
private function testComposer(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->error400('POST method required');
}
$input = json_decode(file_get_contents('php://input'), true);
$composerPath = trim($input['path'] ?? '');
$phpPath = trim($input['php_path'] ?? 'php');
$result = $this->validateComposerPath($composerPath, $phpPath);
echo json_encode([
'success' => $result['valid'],
'version' => $result['version'],
'message' => $result['message'],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
/**
* GET ?action=check-core-pending-path
* 코어 업데이트 _pending 경로 퍼미션/소유자 체크
*/
private function checkCorePendingPath(): void
{
$path = $_GET['path'] ?? '';
if (empty($path)) {
echo json_encode(['success' => false, 'message' => lang('error_path_required')], JSON_UNESCAPED_UNICODE);
return;
}
// 경로 traversal 및 NUL 바이트 거부.
// 정상 사용자는 BASE_PATH 하위의 _pending 또는 자기 디스크의 절대경로만 입력하므로
// 상대경로 부모 추적이 필요한 시나리오가 없다.
$hasParentSegment = preg_match('#(^|[/\\\\])\.\.([/\\\\]|$)#', $path) === 1;
if ($hasParentSegment || str_contains($path, "\0")) {
echo json_encode([
'success' => false,
'message' => lang('error_core_pending_path_invalid'),
], JSON_UNESCAPED_UNICODE);
return;
}
$candidatePath = (str_starts_with($path, '/') || preg_match('#^[A-Za-z]:[/\\\\]#', $path) === 1)
? $path
: BASE_PATH.DIRECTORY_SEPARATOR.$path;
$resolved = @realpath($candidatePath);
if ($resolved === false || ! is_dir($resolved)) {
// 존재 여부/타입 차이를 응답으로 분기하지 않고 단일 메시지로 통일하여
// 임의 경로 enumeration 신호 차단.
echo json_encode([
'success' => false,
'message' => lang('error_core_pending_path_invalid'),
], JSON_UNESCAPED_UNICODE);
return;
}
$writable = is_writable($resolved);
echo json_encode([
'success' => $writable,
'message' => $writable
? lang('success_core_pending_path')
: lang('error_core_pending_not_writable'),
], JSON_UNESCAPED_UNICODE);
}
/**
* 셸 인자로 전달하기 안전한 단일 토큰인지 검증.
*
* 셸 메타문자(공백·따옴표·리다이렉션·세미콜론·백틱·$()·| 등) 와 제어문자(NUL/CR/LF 등)
* 가 없어야 함. 백슬래시(`\`) 는 Windows 경로 구분자이므로 차단 대상이 아니다 —
* 셸 인젝션 차단은 호출자의 escapeshellarg 가 담당 (Windows 는 큰따옴표 wrapping,
* Unix 는 작은따옴표 wrapping).
*
* 파일 시스템 stat 은 호출하지 않는다 — open_basedir 등 PHP 런타임 제약
* 환경에서 정상 절대경로가 false negative 로 거부되는 회귀를 피하기 위함.
* 실제 실행 가능 여부는 exec/proc_open 결과로 최종 판정.
*/
private function isInstallerSafePathArg(string $path): bool
{
return installer_binary_path_shape_ok($path);
}
/**
* 공백 분리 입력을 두 토큰(PHP 인터프리터 + Composer 바이너리) 으로 분해.
*
* 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP) 에서
* `composer` 를 특정 PHP 로 실행하려는 운영 의도를 지원한다.
* 두 토큰 모두 isInstallerSafePathArg 통과해야 정상 입력으로 인정.
*
* @return array{php: string, composer: string}|null 분해 실패 시 null
*/
private function splitPhpComposerTokens(string $path): ?array
{
return installer_resolve_php_composer_pair($path);
}
/**
* PHP 바이너리 경로 유효성 검증 헬퍼
*
* @param string $path PHP 바이너리 경로
* @return array{valid: bool, version: string|null, message: string}
*/
private function validatePhpPath(string $path): array
{
if (empty($path)) {
return ['valid' => false, 'version' => null, 'message' => lang('error_php_path_empty')];
}
// 'php' 기본값이 아니면 실행 경로 형태 규칙을 적용한다(이름은 제한하지 않는다 —
// 이 자리는 인자가 `--version` 으로 고정되어 있고 설치 환경마다 이름이 다르다).
// 파일 존재/실행 가능 검사는 open_basedir 같은 PHP 런타임 제약 환경의
// false negative 를 피하기 위해 생략하고, exec 결과로 최종 판정한다.
if ($path !== 'php' && ! installer_binary_path_shape_ok($path)) {
return ['valid' => false, 'version' => null, 'message' => lang('error_php_binary_path_not_allowed', ['path' => $path])];
}
$command = escapeshellarg($path).' --version 2>&1';
$output = [];
$returnCode = -1;
exec($command, $output, $returnCode);
if ($returnCode !== 0) {
return ['valid' => false, 'version' => null, 'message' => lang('error_php_exec_failed', ['path' => $path])];
}
$outputStr = implode("\n", $output);
if (preg_match('/PHP\s+(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
$version = $matches[1];
if (version_compare($version, MIN_PHP_VERSION, '>=')) {
return [
'valid' => true,
'version' => $version,
'message' => lang('success_php_binary_version', ['path' => $path, 'version' => $version]),
];
}
return [
'valid' => false,
'version' => $version,
'message' => lang('error_php_version_too_low', ['path' => $path, 'version' => $version, 'min' => MIN_PHP_VERSION]),
];
}
return ['valid' => false, 'version' => null, 'message' => lang('error_php_version_parse_failed')];
}
/**
* Composer 바이너리 경로 유효성 검증 헬퍼
*
* @param string $composerPath Composer 바이너리 경로 (빈 문자열이면 시스템 'composer')
* @param string $phpPath PHP 바이너리 경로 (.phar 실행 시 사용)
* @return array{valid: bool, version: string|null, message: string}
*/
private function validateComposerPath(string $composerPath, string $phpPath = 'php'): array
{
// 빈 문자열이면 시스템 기본 composer 사용
$effectivePath = $composerPath ?: 'composer';
// 공백 분리 입력은 "PHP 절대경로 + Composer 절대경로" 의 멀티 PHP 운영 패턴.
// 두 토큰으로 분해 후 각 토큰별 메타문자 차단 + 각각 escapeshellarg 적용한다.
// 옛 raw shell 전달(escape 없는 분기) 은 복원하지 않음.
if ($effectivePath !== 'composer' && str_contains($effectivePath, ' ')) {
// 자리별 규칙(PHP 자리=형태만, Composer 자리=이름 형태까지)은 공용 정책이 담당한다.
$tokens = $this->splitPhpComposerTokens($effectivePath);
if ($tokens === null) {
return [
'valid' => false,
'version' => null,
'message' => lang('error_composer_binary_path_not_allowed', ['path' => $effectivePath]),
];
}
$command = escapeshellarg($tokens['php']).' '.escapeshellarg($tokens['composer']).' --version 2>&1';
$output = [];
$returnCode = -1;
applyInstallerComposerEnvVars();
exec($command, $output, $returnCode);
if ($returnCode !== 0) {
return ['valid' => false, 'version' => null, 'message' => lang('error_composer_exec_failed', ['path' => $effectivePath])];
}
$outputStr = implode("\n", $output);
if (preg_match('/Composer\s+(?:version\s+)?(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
$version = $matches[1];
return [
'valid' => true,
'version' => $version,
'message' => lang('success_composer_version', ['path' => $effectivePath, 'version' => $version]),
];
}
return ['valid' => false, 'version' => null, 'message' => lang('error_composer_version_parse_failed')];
}
// 단일 토큰 — 시스템 기본('composer') 가 아니면 Composer 자리 규칙을 적용한다.
// 파일 존재/실행 가능 검사는 open_basedir 환경의 false negative 회피를 위해 생략.
if ($effectivePath !== 'composer' && ! installer_is_composer_binary_path($effectivePath)) {
return [
'valid' => false,
'version' => null,
'message' => lang('error_composer_binary_path_not_allowed', ['path' => $effectivePath]),
];
}
// .phar 파일이면 PHP 바이너리와 결합
if (str_ends_with(strtolower($effectivePath), '.phar')) {
// phpPath 는 실행 파일 자리 — 이름은 제한하지 않고 형태 규칙만 적용한다.
if ($phpPath !== 'php' && ! installer_binary_path_shape_ok($phpPath)) {
return [
'valid' => false,
'version' => null,
'message' => lang('error_php_binary_path_not_allowed', ['path' => $phpPath]),
];
}
$command = escapeshellarg($phpPath).' '.escapeshellarg($effectivePath).' --version 2>&1';
} else {
$command = escapeshellarg($effectivePath).' --version 2>&1';
}
$output = [];
$returnCode = -1;
// root/super user 환경 + 비대화형 컨텍스트에서 composer interactive 경고로 인한
// 비정상 종료를 차단 (Synology DSM 등 PHP-FPM root 실행 환경 대응)
applyInstallerComposerEnvVars();
exec($command, $output, $returnCode);
if ($returnCode !== 0) {
return ['valid' => false, 'version' => null, 'message' => lang('error_composer_exec_failed', ['path' => $effectivePath])];
}
$outputStr = implode("\n", $output);
if (preg_match('/Composer\s+(?:version\s+)?(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
$version = $matches[1];
return [
'valid' => true,
'version' => $version,
'message' => lang('success_composer_version', ['path' => $effectivePath, 'version' => $version]),
];
}
return ['valid' => false, 'version' => null, 'message' => lang('error_composer_version_parse_failed')];
}
/**
* 모든 필수 요구사항 통과 여부 확인
*/
private function isAllRequiredPassed(array $requirements): bool
{
// PHP 버전 확인
if (! $requirements['php_version']['passed']) {
return false;
}
// PHP 확장 확인
if (! $requirements['php_extensions']['all_required_passed']) {
return false;
}
// 필수 함수 비활성화 확인
if (isset($requirements['disabled_functions']) && ! $requirements['disabled_functions']['passed']) {
return false;
}
// 디스크 공간 확인
if (! $requirements['disk_space']['passed']) {
return false;
}
// 디렉토리 권한 확인
if (! $requirements['directories']['all_passed']) {
return false;
}
// 필수 파일 확인
if (! $requirements['required_files']['all_passed']) {
return false;
}
// HTTPS / OPcache 는 선택 사항이므로 검증하지 않음 (경고만 표시하고 설치는 진행)
return true;
}
// ========================================
// 공통 에러 처리 메서드
// ========================================
/**
* 400 Bad Request 응답
*/
private function error400(string $message): void
{
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'Bad Request',
'message' => $message,
], JSON_UNESCAPED_UNICODE);
exit;
}
/**
* 500 Internal Server Error 응답
*/
private function error500(Throwable $e): void
{
// 에러 로깅
if (function_exists('logInstallationError')) {
logInstallationError('Validation API error', $e);
}
// 에러 응답
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Internal Server Error',
'message' => $e->getMessage(),
'details' => $e->getMessage(),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
exit;
}
}
// ========================================
// 실행 부분
// ========================================
// 라이브러리 모드 — 단위 테스트가 ValidationApi 클래스 정의만 로드할 때 메인 실행을 건너뛴다.
$checkConfigurationLibraryMode = defined('CHECK_CONFIGURATION_LIBRARY') && constant('CHECK_CONFIGURATION_LIBRARY');
if (! $checkConfigurationLibraryMode) {
// 필수 파일 로드 (config.php가 BASE_PATH를 정의함)
require_once __DIR__.'/../includes/config.php';
require_once __DIR__.'/../includes/session.php';
require_once __DIR__.'/../includes/functions.php';
// 설치 완료 시 인스톨러 비즈니스 로직 진입 차단
require_once __DIR__.'/_guard.php';
installer_guard_or_410();
// 다국어 로드
$currentLang = getCurrentLanguage();
$translations = loadTranslations($currentLang);
// API 인스턴스 생성 및 요청 처리
$api = new ValidationApi;
$api->handleRequest();
}