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 에 등재했다.

아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩
걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
This commit is contained in:
HeuJung
2026-09-06 00:42:29 +09:00
parent 8f072895ff
commit 9cf9c3ff8c
104 changed files with 4522 additions and 412 deletions
+22 -11
View File
@@ -68,22 +68,34 @@ if (! function_exists('installer_binary_path_shape_ok')) {
if (! function_exists('installer_binary_path_is_absolute')) {
/**
* 절대경로인지 판정합니다 (POSIX / Windows 드라이브 / UNC).
* 절대경로인지 판정합니다 (POSIX / Windows 드라이브).
*
* UNC(`\\server\share`, `//server/share`)와 스트림 래퍼(`scheme://`)는 절대경로로
* 인정하지 않는다. UNC 는 공격자가 지정한 원격 SMB 공유의 파일을 그대로 실행하게 만들고,
* 스트림 래퍼는 로컬 파일이 아닌 것을 실행 대상으로 삼는다 (KVE-2026-2043).
* 판정은 전부 순수 문자열 규칙이라 `open_basedir` 회귀(#361)와 무관하다.
*
* @param string $token 경로 토큰
* @return bool 절대경로면 true
*/
function installer_binary_path_is_absolute(string $token): bool
{
// 스트림 래퍼(`scheme://`) 거부. 단일 문자 스킴은 Windows 드라이브와 구분되지
// 않으므로 두 글자 이상만 스킴으로 본다.
if (preg_match('#^[A-Za-z][A-Za-z0-9+.-]+://#', $token)) {
return false;
}
// UNC 거부 — 슬래시/백슬래시 두 형태 모두.
if (str_starts_with($token, '\\\\') || str_starts_with($token, '//')) {
return false;
}
if ($token[0] === '/') {
return true;
}
if (preg_match('#^[A-Za-z]:[\\\\/]#', $token)) {
return true;
}
return str_starts_with($token, '\\\\');
return (bool) preg_match('#^[A-Za-z]:[\\\\/]#', $token);
}
}
@@ -94,6 +106,9 @@ if (! function_exists('installer_is_composer_binary_path')) {
* 이 자리는 인터프리터가 실행하는 스크립트가 되므로 이름 형태를 제한한다.
* 제한이 없으면 `PHP경로 /tmp/올려둔파일` 형태가 그대로 남는다.
*
* 이름은 composer 계열만 허용한다. 임의 이름의 `.phar` 를 열어 두면 업로드해 둔
* 아카이브를 그대로 실행시킬 수 있다 (KVE-2026-2043).
*
* @param string $token 경로 토큰
* @return bool Composer 계열이면 true
*/
@@ -105,11 +120,7 @@ if (! function_exists('installer_is_composer_binary_path')) {
$basename = basename(str_replace('\\', '/', $token));
if (preg_match('/^composer[0-9]*(\.[0-9]+)*(\.phar|\.exe|\.bat|\.cmd)?$/i', $basename)) {
return true;
}
return (bool) preg_match('/\.phar$/i', $basename);
return (bool) preg_match('/^composer[0-9]*(\.[0-9]+)*(\.phar|\.exe|\.bat|\.cmd)?$/i', $basename);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* 인스톨러 .env 값 직렬화 정책.
*
* `.env` 는 줄 단위 형식이라 값에 개행이 섞이면 그 뒤가 새로운 환경변수 줄로 해석된다.
* 그래서 사용자 입력에서 온 모든 값은 이 파일의 serializeEnvValue() 한 관문을 지난다.
* 관문이 하나면 나중에 필드가 늘어도 그 값이 자동으로 같은 검사를 받는다.
*
* 개행을 조용히 지우지 않고 예외로 거부하는 이유: 삭제하면 운영자가 입력한 값과 실제로
* 저장된 값이 달라지는데 그 사실이 화면에 나타나지 않는다. 정상 입력에는 개행이 들어갈
* 일이 없으므로 거부가 안전한 기본값이다. (KVE-2026-2042)
*
* 이 파일은 의존성이 없어야 한다 — 인스톨러 본 흐름(functions.php)과 설치 워커
* (installer-runtime.php) 양쪽에서 단독으로 require 된다.
*/
if (! function_exists('installer_env_value_is_single_line')) {
/**
* 값이 한 줄인지(개행·NUL 이 없는지) 확인합니다.
*
* 서버측 사전 거부(폼 검증)에서 예외를 던지지 않고 판정만 필요할 때 씁니다.
*
* @param string $value 검사할 값
* @return bool 개행·NUL 이 없으면 true
*/
function installer_env_value_is_single_line(string $value): bool
{
return strpbrk($value, "\r\n\0") === false;
}
}
if (! function_exists('serializeEnvValue')) {
/**
* .env 에 기록할 값을 직렬화합니다.
*
* CR/LF/NUL 이 포함되면 InvalidArgumentException 을 던집니다(조용한 삭제 금지).
* 그 외에는 백슬래시·큰따옴표를 이스케이프하고 큰따옴표로 감쌉니다.
*
* @param string $value 직렬화할 값
* @return string 큰따옴표로 감싼 값
*
* @throws InvalidArgumentException 값에 개행 또는 NUL 이 포함된 경우
*/
function serializeEnvValue(string $value): string
{
if (! installer_env_value_is_single_line($value)) {
throw new InvalidArgumentException(
'Environment values must not contain line breaks or NUL bytes.'
);
}
if ($value === '') {
return '""';
}
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
return '"'.$escaped.'"';
}
}
+24 -35
View File
@@ -9,6 +9,7 @@
// UTF-8 정규화 / JSON 출력 헬퍼 (js_escape · getWebServerUser 가 사용)
require_once __DIR__.'/utf8.php';
require_once __DIR__.'/env-value.php';
/**
* HTML 이스케이프 함수
@@ -1061,22 +1062,10 @@ if (! function_exists('escapeEnvValue')) {
*/
function escapeEnvValue(string $value): string
{
// 개행 문자는 .env 라인 구분자이므로 사용자 입력에 포함되면 추가 변수 라인이 주입될 수 있다.
// CR/LF 모두 제거 — DB 비밀번호·바이너리 경로·토큰 등 정상 값에는 개행이 들어갈 일이 없다.
if ($value !== '') {
$value = str_replace(["\r", "\n"], '', $value);
}
// 빈 값은 빈 따옴표로
if ($value === '') {
return '""';
}
// 큰따옴표와 백슬래시를 이스케이프
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
// 큰따옴표로 감싸기
return '"'.$escaped.'"';
// 직렬화 정책은 serializeEnvValue 단일 관문이 소유한다 (KVE-2026-2042).
// 이 이름은 하위호환 별칭일 뿐이며, 개행을 조용히 지우던 종전 동작은 남기지 않는다 —
// 남겨 두면 다음 호출자가 그 경로로 같은 결함을 다시 들여온다.
return serializeEnvValue($value);
}
}
@@ -1102,21 +1091,21 @@ function generateEnvContent(): ?string
// 데이터베이스 설정 치환
$replacements = [
'DB_CONNECTION=mysql' => 'DB_CONNECTION=mysql',
'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST='.($config['db_write_host'] ?? '127.0.0.1'),
'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT='.($config['db_write_port'] ?? '3306'),
'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE='.($config['db_write_database'] ?? 'g7'),
'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME='.($config['db_write_username'] ?? 'root'),
'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD='.escapeEnvValue($config['db_write_password'] ?? ''),
'DB_PREFIX=g7_' => 'DB_PREFIX='.($config['db_prefix'] ?? 'g7_'),
'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST='.serializeEnvValue((string) ($config['db_write_host'] ?? '127.0.0.1')),
'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT='.serializeEnvValue((string) ($config['db_write_port'] ?? '3306')),
'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE='.serializeEnvValue((string) ($config['db_write_database'] ?? 'g7')),
'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME='.serializeEnvValue((string) ($config['db_write_username'] ?? 'root')),
'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD='.serializeEnvValue((string) ($config['db_write_password'] ?? '')),
'DB_PREFIX=g7_' => 'DB_PREFIX='.serializeEnvValue((string) ($config['db_prefix'] ?? 'g7_')),
];
// Read DB 설정
if (! empty($config['use_read_db']) && $config['use_read_db']) {
$replacements['DB_READ_HOST='] = 'DB_READ_HOST='.($config['db_read_host'] ?? '127.0.0.1');
$replacements['DB_READ_PORT='] = 'DB_READ_PORT='.($config['db_read_port'] ?? '3306');
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.($config['db_read_database'] ?? 'g7');
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.($config['db_read_username'] ?? 'root');
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.escapeEnvValue($config['db_read_password'] ?? '');
$replacements['DB_READ_HOST='] = 'DB_READ_HOST='.serializeEnvValue((string) ($config['db_read_host'] ?? '127.0.0.1'));
$replacements['DB_READ_PORT='] = 'DB_READ_PORT='.serializeEnvValue((string) ($config['db_read_port'] ?? '3306'));
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.serializeEnvValue((string) ($config['db_read_database'] ?? 'g7'));
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.serializeEnvValue((string) ($config['db_read_username'] ?? 'root'));
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.serializeEnvValue((string) ($config['db_read_password'] ?? ''));
} else {
// Read DB를 사용하지 않는 경우 DB_READ_* 를 빈 값으로 둔다.
// config/database.php 의 write fallback(Elvis) 이 SELECT 를 write DB 로 넘기므로
@@ -1129,9 +1118,9 @@ function generateEnvContent(): ?string
}
// 앱 설정 치환
$replacements['APP_NAME=그누보드7'] = 'APP_NAME="'.($config['app_name'] ?? '그누보드7').'"';
$replacements['APP_ENV=production'] = 'APP_ENV='.($config['app_env'] ?? 'production');
$replacements['APP_URL=http://localhost'] = 'APP_URL='.($config['app_url'] ?? 'http://localhost');
$replacements['APP_NAME=그누보드7'] = 'APP_NAME='.serializeEnvValue((string) ($config['app_name'] ?? '그누보드7'));
$replacements['APP_ENV=production'] = 'APP_ENV='.serializeEnvValue((string) ($config['app_env'] ?? 'production'));
$replacements['APP_URL=http://localhost'] = 'APP_URL='.serializeEnvValue((string) ($config['app_url'] ?? 'http://localhost'));
// 언어 설정
$currentLang = getCurrentLanguage();
@@ -1147,15 +1136,15 @@ function generateEnvContent(): ?string
$coreGithubUrl = $config['core_update_github_url'] ?? 'https://github.com/gnuboard/g7';
$coreGithubToken = $config['core_update_github_token'] ?? '';
$replacements['G7_UPDATE_PENDING_PATH='] = 'G7_UPDATE_PENDING_PATH='.escapeEnvValue($corePendingPath);
$replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL='.$coreGithubUrl;
$replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN='.escapeEnvValue($coreGithubToken);
$replacements['G7_UPDATE_PENDING_PATH='] = 'G7_UPDATE_PENDING_PATH='.serializeEnvValue((string) $corePendingPath);
$replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL='.serializeEnvValue((string) $coreGithubUrl);
$replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN='.serializeEnvValue((string) $coreGithubToken);
// PHP CLI / Composer 바이너리 경로 치환
$phpBinary = $config['php_binary'] ?? 'php';
$composerBinary = $config['composer_binary'] ?? '';
$replacements['PHP_BINARY=php'] = 'PHP_BINARY='.escapeEnvValue($phpBinary);
$replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY='.escapeEnvValue($composerBinary);
$replacements['PHP_BINARY=php'] = 'PHP_BINARY='.serializeEnvValue((string) $phpBinary);
$replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY='.serializeEnvValue((string) $composerBinary);
foreach ($replacements as $search => $replace) {
$envContent = str_replace($search, $replace, $envContent);
+16 -23
View File
@@ -19,6 +19,9 @@ if (! defined('BASE_PATH')) {
throw new RuntimeException('installer-runtime.php requires BASE_PATH constant.');
}
// .env 값 직렬화 정책 (개행 주입 차단) — functions.php 와 같은 관문을 공유한다.
require_once __DIR__.'/env-value.php';
if (! defined('INSTALLER_RUNTIME_PATH')) {
define('INSTALLER_RUNTIME_PATH', BASE_PATH.'/storage/installer/runtime.php');
}
@@ -130,11 +133,11 @@ if (! function_exists('mergeRuntimeIntoEnv')) {
// 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'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_HOST', serializeEnvValue((string) ($write['host'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_PORT', serializeEnvValue((string) ($write['port'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_DATABASE', serializeEnvValue((string) ($write['database'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_USERNAME', serializeEnvValue((string) ($write['username'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_WRITE_PASSWORD', serializeEnvValue((string) ($write['password'] ?? '')));
}
// Read DB — runtime 에 read 키가 있을 때(use_read_db=true)만 명시 기록한다.
@@ -143,11 +146,11 @@ if (! function_exists('mergeRuntimeIntoEnv')) {
// (이슈 #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'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_READ_HOST', serializeEnvValue((string) ($read['host'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_READ_PORT', serializeEnvValue((string) ($read['port'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_READ_DATABASE', serializeEnvValue((string) ($read['database'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_READ_USERNAME', serializeEnvValue((string) ($read['username'] ?? '')));
$envContent = replaceEnvLine($envContent, 'DB_READ_PASSWORD', serializeEnvValue((string) ($read['password'] ?? '')));
} else {
$envContent = replaceEnvLine($envContent, 'DB_READ_HOST', '');
$envContent = replaceEnvLine($envContent, 'DB_READ_PORT', '');
@@ -157,7 +160,7 @@ if (! function_exists('mergeRuntimeIntoEnv')) {
}
if (isset($runtime['db']['prefix'])) {
$envContent = replaceEnvLine($envContent, 'DB_PREFIX', (string) $runtime['db']['prefix']);
$envContent = replaceEnvLine($envContent, 'DB_PREFIX', serializeEnvValue((string) $runtime['db']['prefix']));
}
// APP_KEY 치환
@@ -188,18 +191,8 @@ if (! function_exists('escapeEnvValue')) {
*/
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.'"';
// 직렬화 정책은 serializeEnvValue 단일 관문이 소유한다 (KVE-2026-2042).
return serializeEnvValue($value);
}
}
@@ -236,6 +236,31 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
? $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 !== '') {
+4 -2
View File
@@ -861,6 +861,8 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'core_pending_path_ok' => 'Path is valid.',
'core_pending_info' => 'Owner: :owner, Group: :group, Permissions: :permissions',
'error_core_pending_not_directory' => 'The specified path is not a directory.',
'error_env_value_line_break' => 'Line breaks are not allowed.',
'error_env_value_invalid_url' => 'This is not a valid URL (:value). Enter an address starting with http:// or https://.',
'error_core_pending_not_writable' => 'Directory (:path) is not writable.',
'error_core_pending_parent_not_writable' => 'Parent directory (:path) is not writable, cannot create automatically.',
'error_path_required' => 'Please enter a path.',
@@ -885,8 +887,8 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'error_php_path_empty' => 'PHP binary path is empty.',
'error_php_path_not_exists' => 'File does not exist: :path',
'error_php_exec_failed' => 'PHP execution failed: :path',
'error_php_binary_path_not_allowed' => 'This PHP path format cannot be used (:path). Enter the absolute path of the executable only — options (starting with -), relative paths and .. are not allowed.',
'error_composer_binary_path_not_allowed' => 'This Composer path format cannot be used (:path). Enter the absolute path of the composer executable or a .phar file. For multi-PHP environments use the "absolute-php-path absolute-composer-path" format.',
'error_php_binary_path_not_allowed' => 'This PHP path format cannot be used (:path). Enter the absolute path of an executable on this server only — options (starting with -), relative paths, .., network paths (\\\\server\\share or //server/share) and scheme:// forms are not allowed.',
'error_composer_binary_path_not_allowed' => 'This Composer path format cannot be used (:path). Enter the absolute path of a composer executable on this server — the file name must be a composer variant (composer, composer.phar, composer2.phar and so on); other .phar names, network paths (\\\\server\\share or //server/share) and scheme:// forms are not allowed. For multi-PHP environments use the "absolute-php-path absolute-composer-path" format.',
'error_php_version_too_low' => ':path — PHP :version (minimum :min required)',
'error_php_version_parse_failed' => 'Failed to parse PHP version.',
'error_php_cli_not_verified' => 'PHP CLI path has not been verified. Please click the "Verify Version" button.',
+4 -2
View File
@@ -861,6 +861,8 @@ ini_set(\'zlib.output_compression\', \'off\');
'core_pending_path_ok' => '경로가 유효합니다.',
'core_pending_info' => '소유자: :owner, 그룹: :group, 퍼미션: :permissions',
'error_core_pending_not_directory' => '지정한 경로가 디렉토리가 아닙니다.',
'error_env_value_line_break' => '줄바꿈 문자는 사용할 수 없습니다.',
'error_env_value_invalid_url' => '올바른 주소 형식이 아닙니다 (:value). http:// 또는 https:// 로 시작하는 주소를 입력하세요.',
'error_core_pending_not_writable' => '디렉토리(:path)에 쓰기 권한이 없습니다.',
'error_core_pending_parent_not_writable' => '상위 디렉토리(:path)에 쓰기 권한이 없어 자동 생성이 불가합니다.',
'error_path_required' => '경로를 입력해주세요.',
@@ -885,8 +887,8 @@ ini_set(\'zlib.output_compression\', \'off\');
'error_php_path_empty' => 'PHP 바이너리 경로가 비어있습니다.',
'error_php_path_not_exists' => '파일이 존재하지 않습니다: :path',
'error_php_exec_failed' => 'PHP 실행 실패: :path',
'error_php_binary_path_not_allowed' => '사용할 수 없는 PHP 경로 형식입니다 (:path). 실행 파일의 절대경로만 입력하세요 — 옵션(- 로 시작), 상대경로, .. 는 쓸 수 없습니다.',
'error_composer_binary_path_not_allowed' => '사용할 수 없는 Composer 경로 형식입니다 (:path). composer 실행 파일 또는 .phar 의 절대경로만 입력하세요. 멀티 PHP 환경은 "PHP절대경로 composer절대경로" 형식으로 입력할 수 있습니다.',
'error_php_binary_path_not_allowed' => '사용할 수 없는 PHP 경로 형식입니다 (:path). 이 서버 안에 있는 실행 파일의 절대경로만 입력하세요 — 옵션(- 로 시작), 상대경로, .., 네트워크 경로(\\\\서버\\공유 또는 //서버/공유), scheme:// 형태는 쓸 수 없습니다.',
'error_composer_binary_path_not_allowed' => '사용할 수 없는 Composer 경로 형식입니다 (:path). 이 서버 안에 있는 composer 실행 파일의 절대경로만 입력하세요 — 파일 이름은 composer 계열(composer, composer.phar, composer2.phar 등)이어야 하고, 다른 이름의 .phar, 네트워크 경로(\\\\서버\\공유 또는 //서버/공유), scheme:// 형태는 쓸 수 없습니다. 멀티 PHP 환경은 "PHP절대경로 composer절대경로" 형식으로 입력할 수 있습니다.',
'error_php_version_too_low' => ':path — PHP :version (최소 :min 필요)',
'error_php_version_parse_failed' => 'PHP 버전을 파싱할 수 없습니다.',
'error_php_cli_not_verified' => 'PHP CLI 경로가 확인되지 않았습니다. "버전 확인" 버튼을 클릭해주세요.',