Merge pull request from gnuboard:HeuJung/issue527
fix(security): 예약 작업·언어팩·인스톨러의 차단목록을 허용목록으로 전환
This commit is contained in:
@@ -8,6 +8,11 @@
|
||||
|
||||
### Security
|
||||
|
||||
- 언어팩 안의 PHP 파일에 번역 배열 외의 코드가 들어 있으면 설치 단계에서 거부하도록 바꿨습니다. 언어팩의 PHP 파일은 활성화되면 사이트가 그대로 실행하므로, 파일을 만들거나 외부 명령을 실행하는 코드가 섞여 있으면 서버에서 임의 코드가 실행될 수 있었습니다. 이전에는 위험한 함수 몇 가지를 이름으로 골라 막았기 때문에 목록에 없는 함수나 함수 이름을 쓰지 않는 방식으로 우회할 수 있었습니다. 이제는 번역 배열만 통과시키므로 어떤 형태의 코드든 들어갈 수 없으며, 거부될 때는 어느 파일 몇 번째 줄이 문제인지 알려 드립니다. 함께 언어팩에 담을 수 있는 파일 형식을 `.json`·`.php`·`.md` 로 한정하고, PHP 파일은 정해진 언어 폴더 안에만 둘 수 있게 했습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1678)
|
||||
- 언어팩을 설치하면서 곧바로 활성화하려면 언어팩 관리 권한이 필요하도록 바꿨습니다. 설치 권한과 활성화 권한은 원래 별도인데, 설치 요청에 '바로 켜기'를 함께 보내면 설치 권한만으로 활성화까지 되어 권한 경계가 무너져 있었습니다. 권한이 없으면 그 항목만 거부되고 설치 자체는 그대로 진행됩니다. 파일·URL·GitHub·번들 네 가지 설치 경로 모두 같은 기준을 적용하므로, 어느 경로로 설치하느냐에 따라 권한 경계가 달라지지 않습니다. 저장소에 동봉된 번들 언어팩의 재설치도 종전처럼 할 수 있으며, 다만 활성화 권한이 없으면 재설치한 언어팩이 꺼진 상태가 되어 다시 켜려면 관리 권한이 필요합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1678)
|
||||
- 설치 마법사의 PHP·Composer 실행 경로 입력에서 실행 파일이 아닌 값을 넣지 못하도록 형식 검사를 강화했습니다. 이전에는 실행 파일 뒤에 다른 파일이나 옵션을 덧붙이는 형태가 통과해, 설치가 끝나지 않은 상태에서 서버의 임의 코드가 실행될 수 있었습니다. 정상적인 입력(멀티 PHP 환경의 "PHP 경로 Composer 경로" 형태, 시놀로지·cPanel·Plesk 등의 다양한 PHP 실행 파일 이름, Windows 경로)은 그대로 사용할 수 있으며, 파일 존재 여부를 미리 확인하지 않으므로 접근이 제한된 서버에서도 정상 경로가 거부되지 않습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1677)
|
||||
- 아직 공개하지 않은 예약 작업 기능에서, Artisan 명령 예약의 실행 제한을 허용 목록 방식으로 바꿔 정식 공개에 대비했습니다. 종전에는 위험한 명령 몇 가지만 거부하고 나머지를 모두 허용했기 때문에, 예약 작업 권한을 위임받은 계정이 파일을 만들거나 데이터베이스 구조를 바꾸는 명령을 등록할 수 있었습니다. 이제 캐시 정리·큐 처리·만료 데이터 정리 같은 유지보수 명령만 등록할 수 있고, 각 명령에 붙일 수 있는 옵션도 정해진 것만 허용합니다. 설치된 모듈·플러그인이 제공하는 명령은 자동으로 허용됩니다. 등록이 거부되면 왜 거부됐는지(허용 목록 밖 / 형식 오류 / 허용되지 않은 옵션 / 추가 인자 불가)를 구분해 안내합니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1679)
|
||||
- 같은 기능에서, 저장한 예약 작업 명령이 실행 직전에 다시 해석되면서 저장할 때 확인한 것과 다른 명령이 실행될 수 있던 경로도 함께 막았습니다. 명령을 따옴표나 역슬래시로 감싸면 검사 단계와 실행 단계가 서로 다른 명령으로 읽었습니다. 이제 명령 형식을 `명령명 --옵션[=값]` 으로 한정하고 실행할 때도 해석된 결과를 그대로 사용하므로, 확인한 명령과 실행되는 명령이 달라질 수 없습니다. (KISA 측에서 제보해주신 내용을 확인하는 과정에서 함께 발견했습니다 — KVE-2026-1679)
|
||||
- 보안 환경설정의 계정 잠금 시간을 `0`(무한대)으로 설정하면 실제로 무기한 잠기도록 수정했습니다. 화면 안내는 "0을 입력하면 무한대"였지만 실제로는 1분 뒤 자동 해제되어, 무차별 대입 시도를 영구 차단할 수 없었습니다. 무기한 잠긴 계정에는 남은 시간 대신 관리자 문의 안내가 표시됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 환경설정 > 보안에 비밀번호 최소 길이와 특수문자 필수 여부를 추가했습니다. 두 항목은 설정 화면에만 있고 실제로는 어디에도 적용되지 않던 값으로, 이제 비밀번호를 새로 정하는 모든 화면(회원가입·비밀번호 변경·재설정·프로필 수정·관리자의 회원 등록과 수정)에 적용됩니다. 로그인에는 적용하지 않습니다 — 정책을 올렸을 때 기존 비밀번호를 쓰는 회원이 자기 계정에 로그인조차 못 하게 되는 것을 막기 위함입니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 본인인증 메일의 인증번호 자리수 설정이 4~10 범위를 벗어나면 조용히 잘려 저장 값과 다르게 동작하던 문제를 수정했습니다. 이제 허용 범위가 설정 화면에 표시되고, 범위를 벗어난 값은 기본값(6자리)으로 처리하며 기록을 남깁니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -12,14 +12,27 @@ use Exception;
|
||||
*/
|
||||
class ScheduleExecutionException extends Exception
|
||||
{
|
||||
/**
|
||||
* 거부 사유 코드 (`ScheduleCommandValidator::ARTISAN_REASON_*`).
|
||||
*
|
||||
* 관리자에게 보이는 메시지는 사유와 무관하게 동일하지만, 운영 진단 로그에는
|
||||
* 어느 규칙에 걸렸는지가 남아야 한다 — 업그레이드 점검 스텝을 두지 않기로 했으므로
|
||||
* 실행 이력과 로그가 유일한 통로다.
|
||||
*/
|
||||
public ?string $reasonCode = null;
|
||||
|
||||
/**
|
||||
* 허용되지 않은 Artisan 명령
|
||||
*
|
||||
* @param string|null $reasonCode 거부 사유 코드 (`ScheduleCommandValidator::ARTISAN_REASON_*`)
|
||||
* @return self 생성된 예외
|
||||
*/
|
||||
public static function artisanNotAllowed(): self
|
||||
public static function artisanNotAllowed(?string $reasonCode = null): self
|
||||
{
|
||||
return new self(__('schedule.artisan_not_allowed'));
|
||||
$exception = new self(__('schedule.artisan_not_allowed'));
|
||||
$exception->reasonCode = $reasonCode;
|
||||
|
||||
return $exception;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Http\Requests\LanguagePack;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\LanguagePack\RequiresActivationPermission;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
@@ -29,15 +31,27 @@ class InstallFromBundledRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
$rules = [
|
||||
'identifier' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:200',
|
||||
'regex:/^[a-zA-Z0-9._\-]+$/',
|
||||
],
|
||||
'auto_activate' => ['nullable', 'boolean'],
|
||||
/*
|
||||
* 외부 소스(파일·URL·GitHub)와 동일하게 활성화 권한 게이트를 건다 (PO 결정, 2026-08-04).
|
||||
* 설치(install)와 활성화(manage)를 별도 권한으로 두는 정책을 네 경로 전부에 같은 강도로
|
||||
* 적용해, "어느 경로로 들어오느냐" 가 권한 경계를 바꾸지 않게 한다.
|
||||
*
|
||||
* 부수 효과: 관리자 화면에서 auto_activate 를 보내는 곳이 번들 재설치 모달 하나뿐이므로,
|
||||
* 설치 권한만 가진 운영자가 활성 팩을 재설치하면 그 팩이 installed 로 내려간다.
|
||||
* 다시 켜려면 언어팩 관리 권한이 필요하다 — 의도된 동작이다.
|
||||
*/
|
||||
'auto_activate' => ['nullable', 'boolean', new RequiresActivationPermission],
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공
|
||||
return HookManager::applyFilters('core.language_packs.install_from_bundled_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Http\Requests\LanguagePack;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\LanguagePack\RequiresActivationPermission;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
@@ -26,10 +28,14 @@ class InstallFromFileRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
$rules = [
|
||||
'file' => ['required', 'file', 'max:10240', 'mimetypes:application/zip,application/x-zip-compressed,application/octet-stream'],
|
||||
'auto_activate' => ['nullable', 'boolean'],
|
||||
// 설치 권한만으로 활성화까지 수행하지 못하게 한다 — 활성 팩만 require 경로에 배선된다.
|
||||
'auto_activate' => ['nullable', 'boolean', new RequiresActivationPermission],
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공
|
||||
return HookManager::applyFilters('core.language_packs.install_from_file_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Http\Requests\LanguagePack;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\LanguagePack\RequiresActivationPermission;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
@@ -26,14 +28,18 @@ class InstallFromGithubRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
$rules = [
|
||||
'github_url' => [
|
||||
'required',
|
||||
'url',
|
||||
'regex:/^https?:\/\/(www\.)?github\.com\/[a-zA-Z0-9\-_]+\/[a-zA-Z0-9\-_]+\/?$/',
|
||||
],
|
||||
'auto_activate' => ['nullable', 'boolean'],
|
||||
// 설치 권한만으로 활성화까지 수행하지 못하게 한다 — 활성 팩만 require 경로에 배선된다.
|
||||
'auto_activate' => ['nullable', 'boolean', new RequiresActivationPermission],
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공
|
||||
return HookManager::applyFilters('core.language_packs.install_from_github_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\LanguagePack;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\LanguagePack\RequiresActivationPermission;
|
||||
use App\Rules\PublicOutboundUrl;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
@@ -32,7 +33,8 @@ class InstallFromUrlRequest extends FormRequest
|
||||
// 원격 코드를 내려받는 지점이므로 https 고정 + 내부 주소 허용 설정과 무관하게 항상 차단
|
||||
'url' => ['required', 'url', 'max:500', new PublicOutboundUrl(schemes: ['https'], allowInternalOptIn: false)],
|
||||
'checksum' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
|
||||
'auto_activate' => ['nullable', 'boolean'],
|
||||
// 설치 권한만으로 활성화까지 수행하지 못하게 한다 — 활성 팩만 require 경로에 배선된다.
|
||||
'auto_activate' => ['nullable', 'boolean', new RequiresActivationPermission],
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공
|
||||
|
||||
@@ -7,14 +7,25 @@ use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Artisan 타입 스케줄의 command 가 차단목록에 걸리지 않는지 검증하는 Custom Rule.
|
||||
* Artisan 타입 스케줄의 command 가 실행 허용 목록 안인지 검증하는 Custom Rule.
|
||||
*
|
||||
* `tinker` 처럼 임의 PHP 코드를 실행하거나 `db:wipe` 처럼 파괴적인 명령을 스케줄로 등록하는 것을
|
||||
* 저장 시점에 차단한다. 차단목록은 `config/schedule_security.php` 가 소유하며 관리자 환경설정에
|
||||
* 노출되지 않는다.
|
||||
* 허용목록에 없는 명령, 선언되지 않은 옵션, 위치 인자, 그리고 Symfony 가 다르게 해석할 수 있는
|
||||
* 형태(따옴표·백슬래시·단축 옵션)를 저장 시점에 차단한다. 정책은 `config/schedule_security.php`
|
||||
* 가 소유하며 관리자 환경설정에 노출되지 않는다.
|
||||
*
|
||||
* 어느 항목이 왜 막혔는지 드러나도록 거부 사유별로 메시지를 분기한다.
|
||||
*/
|
||||
class AllowedArtisanCommand implements ValidationRule
|
||||
{
|
||||
/** 거부 사유 코드 → 다국어 키 */
|
||||
private const REASON_MESSAGES = [
|
||||
ScheduleCommandValidator::ARTISAN_REASON_DENIED => 'validation.schedule_command.artisan_denied',
|
||||
ScheduleCommandValidator::ARTISAN_REASON_NOT_ALLOWED => 'validation.schedule_command.artisan_not_allowlisted',
|
||||
ScheduleCommandValidator::ARTISAN_REASON_MALFORMED => 'validation.schedule_command.artisan_malformed',
|
||||
ScheduleCommandValidator::ARTISAN_REASON_OPTION => 'validation.schedule_command.artisan_option_denied',
|
||||
ScheduleCommandValidator::ARTISAN_REASON_ARGUMENT => 'validation.schedule_command.artisan_argument_denied',
|
||||
];
|
||||
|
||||
/**
|
||||
* 값이 실행 허용된 artisan 명령인지 검증합니다.
|
||||
*
|
||||
@@ -29,13 +40,19 @@ class AllowedArtisanCommand implements ValidationRule
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.schedule_command.artisan_denied'));
|
||||
$fail(__('validation.schedule_command.artisan_malformed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! ScheduleCommandValidator::isArtisanCommandAllowed($value)) {
|
||||
$fail(__('validation.schedule_command.artisan_denied'));
|
||||
$verdict = ScheduleCommandValidator::inspectArtisanCommand($value);
|
||||
|
||||
if ($verdict['allowed']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = self::REASON_MESSAGES[$verdict['reason']] ?? 'validation.schedule_command.artisan_not_allowlisted';
|
||||
|
||||
$fail(__($key));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules\LanguagePack;
|
||||
|
||||
use App\Enums\PermissionType;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* `auto_activate` 로 설치 직후 활성화를 요청할 때 활성화 권한을 추가로 요구하는 Custom Rule.
|
||||
*
|
||||
* 언어팩 설치(`core.language_packs.install`)와 활성화(`core.language_packs.manage`)는 원래
|
||||
* 별도 권한이다. 그런데 설치 요청의 `auto_activate` 플래그는 install 권한만으로 활성 상태를
|
||||
* 만들 수 있어 경계가 무너져 있었다. 활성 팩만 fallback 경로에 배선되어 `require` 되므로,
|
||||
* 이 경계가 임의 PHP 실행의 마지막 관문이다.
|
||||
*
|
||||
* `authorize()` 가 아니라 필드 단위 Rule 이어야 한다 — `authorize()` 로 처리하면
|
||||
* `auto_activate` 를 보내지 않은 정상 설치 요청까지 403 이 된다. 필드 단위로 두면
|
||||
* 그 항목만 422 로 거부되고, 항목을 빼면 설치는 그대로 진행된다.
|
||||
*
|
||||
* 판정 기준은 활성화 라우트가 쓰는 권한과 같다(`core.language_packs.manage`, admin 타입) —
|
||||
* 두 경로가 다른 기준을 쓰면 한쪽이 우회로가 된다.
|
||||
*/
|
||||
class RequiresActivationPermission implements ValidationRule
|
||||
{
|
||||
/** 활성화에 필요한 권한 식별자 */
|
||||
public const PERMISSION = 'core.language_packs.manage';
|
||||
|
||||
/**
|
||||
* 활성화를 요청했다면 활성화 권한을 가지고 있는지 검증합니다.
|
||||
*
|
||||
* @param string $attribute 검증 대상 필드명
|
||||
* @param mixed $value 검증 대상 값
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! filter_var($value, FILTER_VALIDATE_BOOLEAN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user === null || ! $user->hasPermission(self::PERMISSION, PermissionType::Admin)) {
|
||||
$fail(__('language_packs.errors.auto_activate_requires_manage'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\LanguagePack;
|
||||
|
||||
use ParseError;
|
||||
|
||||
/**
|
||||
* 언어팩에 동봉된 PHP 파일이 "번역 배열만" 담고 있는지 문법으로 검사하는 순수 유틸.
|
||||
*
|
||||
* 언어팩의 PHP 파일은 활성화되면 `LanguagePackTranslator` 가 `require` 하므로 그 내용이
|
||||
* 그대로 실행된다. 위험 함수 이름을 나열해 막는 방식은 목록에 없는 함수(`file_put_contents`,
|
||||
* `fopen`, `call_user_func`, 백틱 등)로 언제든 우회되므로, 반대로 **허용된 문법만 통과**시킨다.
|
||||
*
|
||||
* 허용 문법:
|
||||
*
|
||||
* 파일 := "<?php" declare? "return" 값 ";" 닫는태그? EOF
|
||||
* declare := declare(strict_types=1);
|
||||
* 값 := 배열 | 스칼라
|
||||
* 배열 := "[" 항목들? "]" | array( 항목들? )
|
||||
* 항목 := 값 ("=>" 값)?
|
||||
* 스칼라 := 문자열리터럴 ("." 문자열리터럴)* | 부호? 숫자 | true | false | null
|
||||
* (공백·주석은 어디서나 허용)
|
||||
*
|
||||
* 토큰 집합만 검사해서는 안 된다 — "허용 토큰 목록에 없으면 거부" 로 구현하면
|
||||
* `<?php return [('system')('id')];` 가 통과한다. 여기 쓰인 토큰은 문자열 리터럴과
|
||||
* 괄호뿐이기 때문이다. 따라서 토큰의 **등장 위치와 순서를 강제하는 상태기계**로 구현하고,
|
||||
* `(` 는 `array` 바로 뒤와 `declare` 프리픽스에서만 허용한다.
|
||||
*
|
||||
* 블록리스트보다 나은 점은 빠뜨림이 원리적으로 불가능하다는 것이다. 함수 호출은 이름과
|
||||
* 무관하게 전부 거부되고, 변수·가변변수·백틱·heredoc 보간·`?>` 뒤 인라인 코드·속성·`new`·
|
||||
* `match` 도 전부 문법에 없다. PHP 에 새 위험 함수가 추가되어도 목록을 갱신할 필요가 없다.
|
||||
*
|
||||
* `nikic/php-parser` 를 쓰지 않는다 — 개발 의존성이라 배포본에서 사라질 수 있다.
|
||||
* `token_get_all()` 은 별도 확장 없이 항상 쓸 수 있다.
|
||||
*/
|
||||
class LanguagePackPhpArrayValidator
|
||||
{
|
||||
/** 검사 대상 파일 크기 상한 (파서 자원 소모 방지) */
|
||||
public const MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** 배열 중첩 깊이 상한 (파서 자원 소모 방지) */
|
||||
public const MAX_DEPTH = 30;
|
||||
|
||||
/** @var array<int, array{type: int|string, text: string, line: int}> 공백·주석을 제거한 토큰 목록 */
|
||||
private array $tokens = [];
|
||||
|
||||
/** @var int 현재 토큰 위치 */
|
||||
private int $position = 0;
|
||||
|
||||
/**
|
||||
* 소스가 번역 배열만 담고 있는지 판정합니다.
|
||||
*
|
||||
* @param string $source PHP 소스 문자열
|
||||
* @return bool 안전하면 true
|
||||
*/
|
||||
public static function isSafe(string $source): bool
|
||||
{
|
||||
return self::inspect($source) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스를 검사해 위반 지점을 돌려줍니다.
|
||||
*
|
||||
* @param string $source PHP 소스 문자열
|
||||
* @return array{line: int, reason: string}|null 안전하면 null
|
||||
*/
|
||||
public static function inspect(string $source): ?array
|
||||
{
|
||||
if (strlen($source) > self::MAX_BYTES) {
|
||||
return ['line' => 0, 'reason' => 'file_too_large'];
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = token_get_all($source, TOKEN_PARSE);
|
||||
} catch (ParseError) {
|
||||
return ['line' => 0, 'reason' => 'parse_error'];
|
||||
}
|
||||
|
||||
return (new self)->run($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일을 읽어 검사합니다.
|
||||
*
|
||||
* @param string $path 절대 경로
|
||||
* @return array{line: int, reason: string}|null 안전하면 null
|
||||
*/
|
||||
public static function inspectFile(string $path): ?array
|
||||
{
|
||||
// 크기 상한은 읽기 **전에** 본다 — 내용을 먼저 메모리에 올리면 압축률 높은 ZIP 으로
|
||||
// 반입한 거대 파일이 2MB 컷에 걸리기 전에 메모리를 소모한다.
|
||||
$size = @filesize($path);
|
||||
|
||||
if ($size !== false && $size > self::MAX_BYTES) {
|
||||
return ['line' => 0, 'reason' => 'file_too_large'];
|
||||
}
|
||||
|
||||
$source = @file_get_contents($path, false, null, 0, self::MAX_BYTES + 1);
|
||||
|
||||
if ($source === false) {
|
||||
return ['line' => 0, 'reason' => 'unreadable'];
|
||||
}
|
||||
|
||||
return self::inspect($source);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 목록을 문법에 따라 검사합니다.
|
||||
*
|
||||
* @param array<int, array{0: int, 1: string, 2: int}|string> $raw token_get_all 결과
|
||||
* @return array{line: int, reason: string}|null 안전하면 null
|
||||
*/
|
||||
private function run(array $raw): ?array
|
||||
{
|
||||
$line = 1;
|
||||
|
||||
foreach ($raw as $token) {
|
||||
if (is_array($token)) {
|
||||
$line = $token[2];
|
||||
|
||||
if (in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tokens[] = ['type' => $token[0], 'text' => $token[1], 'line' => $token[2]];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tokens[] = ['type' => $token, 'text' => $token, 'line' => $line];
|
||||
}
|
||||
|
||||
if (! $this->expectType(T_OPEN_TAG)) {
|
||||
return $this->violation('missing_open_tag');
|
||||
}
|
||||
|
||||
if ($this->peekType() === T_DECLARE && ! $this->consumeStrictTypesDeclare()) {
|
||||
return $this->violation('unsupported_declare');
|
||||
}
|
||||
|
||||
if (! $this->expectType(T_RETURN)) {
|
||||
return $this->violation('missing_return');
|
||||
}
|
||||
|
||||
if (! $this->consumeValue(0)) {
|
||||
return $this->violation('not_a_literal_value');
|
||||
}
|
||||
|
||||
if (! $this->expectText(';')) {
|
||||
return $this->violation('missing_semicolon');
|
||||
}
|
||||
|
||||
if ($this->peekType() === T_CLOSE_TAG) {
|
||||
$this->position++;
|
||||
|
||||
/*
|
||||
* PHP 는 닫는 태그 직후 개행 하나를 삼킨다. 그 이상 남은 것은 인라인 출력이므로
|
||||
* 공백만 허용한다 (닫는 태그 `?>` 뒤의 임의 코드·두 번째 여는 태그를 여기서 막는다).
|
||||
* 한 줄 주석에 닫는 태그를 쓰면 그 지점에서 PHP 모드가 끝나므로 블록 주석을 쓴다.
|
||||
*/
|
||||
if ($this->peekType() === T_INLINE_HTML) {
|
||||
if (trim($this->tokens[$this->position]['text']) !== '') {
|
||||
return $this->violation('code_after_close_tag');
|
||||
}
|
||||
|
||||
$this->position++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->position !== count($this->tokens)) {
|
||||
return $this->violation('trailing_tokens');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `declare(strict_types=1);` 프리픽스를 소비합니다.
|
||||
*
|
||||
* @return bool 정확히 이 형태였으면 true
|
||||
*/
|
||||
private function consumeStrictTypesDeclare(): bool
|
||||
{
|
||||
$this->position++; // T_DECLARE
|
||||
|
||||
if (! $this->expectText('(')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$directive = $this->tokens[$this->position] ?? null;
|
||||
|
||||
if ($directive === null || $directive['type'] !== T_STRING || strtolower($directive['text']) !== 'strict_types') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->position++;
|
||||
|
||||
return $this->expectText('=')
|
||||
&& $this->expectType(T_LNUMBER)
|
||||
&& $this->expectText(')')
|
||||
&& $this->expectText(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* 값(배열 또는 스칼라) 하나를 소비합니다.
|
||||
*
|
||||
* @param int $depth 현재 중첩 깊이
|
||||
* @return bool 소비했으면 true
|
||||
*/
|
||||
private function consumeValue(int $depth): bool
|
||||
{
|
||||
if ($depth > self::MAX_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = $this->tokens[$this->position] ?? null;
|
||||
|
||||
if ($token === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($token['type'] === '[') {
|
||||
$this->position++;
|
||||
|
||||
return $this->consumeArrayItems(']', $depth + 1);
|
||||
}
|
||||
|
||||
// `(` 는 `array` 바로 뒤에서만 허용한다 — 그 외 위치의 괄호는 호출·그룹화이므로 거부.
|
||||
if ($token['type'] === T_ARRAY) {
|
||||
$this->position++;
|
||||
|
||||
return $this->expectText('(') && $this->consumeArrayItems(')', $depth + 1);
|
||||
}
|
||||
|
||||
return $this->consumeScalar();
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열 항목들과 닫는 기호를 소비합니다.
|
||||
*
|
||||
* @param string $closer 닫는 기호 (`]` 또는 `)`)
|
||||
* @param int $depth 현재 중첩 깊이
|
||||
* @return bool 소비했으면 true
|
||||
*/
|
||||
private function consumeArrayItems(string $closer, int $depth): bool
|
||||
{
|
||||
if ($depth > self::MAX_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if ($this->peekText() === $closer) {
|
||||
$this->position++;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->consumeValue($depth)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->peekType() === T_DOUBLE_ARROW) {
|
||||
$this->position++;
|
||||
|
||||
if (! $this->consumeValue($depth)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->peekText() === ',') {
|
||||
$this->position++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->peekText() === $closer) {
|
||||
$this->position++;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스칼라 하나를 소비합니다.
|
||||
*
|
||||
* @return bool 소비했으면 true
|
||||
*/
|
||||
private function consumeScalar(): bool
|
||||
{
|
||||
$token = $this->tokens[$this->position] ?? null;
|
||||
|
||||
if ($token === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($token['type'] === T_CONSTANT_ENCAPSED_STRING) {
|
||||
$this->position++;
|
||||
|
||||
// 문자열 리터럴 연결만 허용한다 (변수 보간은 T_ENCAPSED_AND_WHITESPACE 라 여기 오지 않는다).
|
||||
while ($this->peekText() === '.') {
|
||||
$this->position++;
|
||||
|
||||
if (! $this->expectType(T_CONSTANT_ENCAPSED_STRING)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($token['type'] === '-' || $token['type'] === '+') {
|
||||
$this->position++;
|
||||
|
||||
return $this->expectType(T_LNUMBER) || $this->expectType(T_DNUMBER);
|
||||
}
|
||||
|
||||
if ($token['type'] === T_LNUMBER || $token['type'] === T_DNUMBER) {
|
||||
$this->position++;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($token['type'] === T_STRING && in_array(strtolower($token['text']), ['true', 'false', 'null'], true)) {
|
||||
$this->position++;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 토큰이 지정한 타입이면 소비합니다.
|
||||
*
|
||||
* @param int $type 토큰 타입
|
||||
* @return bool 소비했으면 true
|
||||
*/
|
||||
private function expectType(int $type): bool
|
||||
{
|
||||
if (($this->tokens[$this->position]['type'] ?? null) !== $type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->position++;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 토큰이 지정한 기호면 소비합니다.
|
||||
*
|
||||
* @param string $text 기호
|
||||
* @return bool 소비했으면 true
|
||||
*/
|
||||
private function expectText(string $text): bool
|
||||
{
|
||||
if (($this->tokens[$this->position]['type'] ?? null) !== $text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->position++;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 토큰의 타입을 봅니다 (소비하지 않음).
|
||||
*
|
||||
* @return int|string|null 토큰 타입
|
||||
*/
|
||||
private function peekType(): int|string|null
|
||||
{
|
||||
return $this->tokens[$this->position]['type'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 토큰이 기호일 때 그 기호를 봅니다 (소비하지 않음).
|
||||
*
|
||||
* @return string|null 기호, 기호가 아니면 null
|
||||
*/
|
||||
private function peekText(): ?string
|
||||
{
|
||||
$type = $this->peekType();
|
||||
|
||||
return is_string($type) ? $type : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 위치를 위반 지점으로 보고합니다.
|
||||
*
|
||||
* @param string $reason 위반 사유 코드
|
||||
* @return array{line: int, reason: string} 위반 정보
|
||||
*/
|
||||
private function violation(string $reason): array
|
||||
{
|
||||
$token = $this->tokens[$this->position] ?? $this->tokens[count($this->tokens) - 1] ?? null;
|
||||
|
||||
return ['line' => $token['line'] ?? 0, 'reason' => $reason];
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePack\LanguagePackBaseLocales;
|
||||
use App\Services\LanguagePack\LanguagePackManifestValidator;
|
||||
use App\Services\LanguagePack\LanguagePackPhpArrayValidator;
|
||||
use App\Services\LanguagePack\LanguagePackRegistry;
|
||||
use App\Support\OutboundUrlValidator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@@ -875,34 +876,37 @@ class LanguagePackService
|
||||
File::deleteDirectory($extractPath);
|
||||
}
|
||||
|
||||
$pack = DB::transaction(function () use ($existing, $manifest, $sourceType, $sourceUrl, $autoActivate, $installedBy) {
|
||||
// 재설치(update path) 시 자기 자신을 슬롯 충돌로 오인하지 않도록 $existing->id 를 제외.
|
||||
// 이전엔 재설치 시 자기가 active 였더라도 $shouldActivate 가 false 가 되어
|
||||
// status 가 active → installed 로 강등 → 의존하는 확장 언어팩이 'core_locale_missing'
|
||||
// 으로 차단되는 회귀가 발생.
|
||||
$shouldActivate = $autoActivate
|
||||
&& ! $this->repository->findActiveForSlot(
|
||||
$manifest['scope'],
|
||||
$manifest['target_identifier'] ?? null,
|
||||
$manifest['locale'],
|
||||
$existing?->id
|
||||
);
|
||||
|
||||
$pack = DB::transaction(function () use ($existing, $manifest, $sourceType, $sourceUrl, $installedBy) {
|
||||
$data = $this->buildPackData($manifest, $sourceType, $sourceUrl, $installedBy);
|
||||
|
||||
// 재설치(update path) 시 자기 자신을 슬롯 충돌로 오인하지 않도록 $existing->id 를 제외.
|
||||
// 이전엔 재설치 시 자기가 active 였더라도 $shouldActivate 가 false 가 되어
|
||||
// status 가 active → installed 로 강등 → 의존하는 확장 언어팩이 'core_locale_missing'
|
||||
// 으로 차단되는 회귀가 발생.
|
||||
$shouldActivate = $autoActivate
|
||||
&& ! $this->repository->findActiveForSlot(
|
||||
$manifest['scope'],
|
||||
$manifest['target_identifier'] ?? null,
|
||||
$manifest['locale'],
|
||||
$existing?->id
|
||||
);
|
||||
// 설치 트랜잭션은 항상 installed 로만 기록한다. 활성화는 아래에서 activate() 를
|
||||
// 경유해야 의존성 검사·대상 확장 존재 확인·슬롯 충돌 검사가 반드시 수행되고,
|
||||
// activated 훅도 한 곳에서만 발화한다.
|
||||
//
|
||||
// 이 규칙의 부수 효과로, 이미 active 인 팩을 auto_activate 없이 덮어쓰면
|
||||
// active → installed 로 강등된다. 겉보기엔 버그지만 의도된 안전장치다 —
|
||||
// 활성 팩만 require 경로에 배선되므로, 방금 외부에서 반입된 내용이
|
||||
// 활성화 권한자의 동의 없이 그 자리를 이어받는 일이 없어야 한다.
|
||||
// (설치 권한만 가진 사용자가 활성 팩을 덮어쓰는 방식으로 활성화 권한 게이트를
|
||||
// 우회하는 경로를 닫는다. 정상 운영자는 auto_activate 로 활성 상태를 유지하며,
|
||||
// 그 플래그는 RequiresActivationPermission 이 지킨다.)
|
||||
$data['status'] = LanguagePackStatus::Installed->value;
|
||||
|
||||
$data['status'] = $shouldActivate
|
||||
? LanguagePackStatus::Active->value
|
||||
: LanguagePackStatus::Installed->value;
|
||||
|
||||
if ($shouldActivate) {
|
||||
$data['activated_at'] = now();
|
||||
}
|
||||
|
||||
$pack = $existing
|
||||
return $existing
|
||||
? $this->repository->update($existing, $data)
|
||||
: $this->repository->create($data);
|
||||
|
||||
return $pack;
|
||||
});
|
||||
|
||||
// 캐시 전 계층 무효화 (registry + translator + template-lang)
|
||||
@@ -913,8 +917,9 @@ class LanguagePackService
|
||||
$pack
|
||||
);
|
||||
|
||||
if ($pack->status === LanguagePackStatus::Active->value) {
|
||||
HookManager::doAction('core.language_packs.activated', $pack);
|
||||
if ($shouldActivate) {
|
||||
// activate() 내부에서 상태 기록·캐시 무효화·activated 훅 발화를 모두 담당한다.
|
||||
$pack = $this->activate($pack, false);
|
||||
}
|
||||
|
||||
return $pack;
|
||||
@@ -1145,7 +1150,20 @@ class LanguagePackService
|
||||
}
|
||||
|
||||
/**
|
||||
* 보안 규칙 검사 — backend/ 외의 PHP 파일 차단, eval/include 패턴 차단.
|
||||
* 보안 규칙 검사 — 허용된 파일 형식·위치·내용만 통과시킵니다.
|
||||
*
|
||||
* 언어팩의 PHP 파일은 활성화되면 `LanguagePackTranslator` 가 `require` 하므로 그 내용이
|
||||
* 그대로 실행된다. 과거에는 위험 함수 11개를 정규식으로 나열해 막았으나 목록에 없는
|
||||
* 함수(`file_put_contents` `fopen` `call_user_func` `assert` 백틱 등)로 전부 우회됐다.
|
||||
* 따라서 판정을 뒤집어, 아래 네 가지를 모두 만족하는 것만 통과시킨다.
|
||||
*
|
||||
* 1. 확장자 화이트리스트 — `.json` / `.php` / `.md` (대소문자 무관)
|
||||
* 2. PHP 파일 위치 강제 — `backend/{manifest locale}/{group}.php` 또는 `backend/{group}.php`
|
||||
* 3. 심볼릭 링크 거부 — 패키지 밖 파일을 끌어들이는 통로 차단
|
||||
* 4. PHP 내용은 "번역 배열만" (LanguagePackPhpArrayValidator 문법 검사)
|
||||
*
|
||||
* 번들 여부와 무관하게 같은 경로로 검사한다 — 예외를 두면 "동봉된 것으로 위장하면 통과"
|
||||
* 라는 틈이 생기고, 앞으로 언어팩을 만들 때 기준이 둘로 갈린다.
|
||||
*
|
||||
* @param string $packageRoot 패키지 루트 디렉토리
|
||||
* @param array<string, mixed> $manifest manifest 데이터
|
||||
@@ -1154,41 +1172,111 @@ class LanguagePackService
|
||||
*/
|
||||
private function assertSecurityRules(string $packageRoot, array $manifest): void
|
||||
{
|
||||
$finder = function (string $directory) use (&$finder) {
|
||||
if (! File::isDirectory($directory)) {
|
||||
return [];
|
||||
}
|
||||
$files = [];
|
||||
foreach (File::allFiles($directory) as $file) {
|
||||
$files[] = $file->getRealPath();
|
||||
$locale = (string) ($manifest['locale'] ?? '');
|
||||
|
||||
// `backend/{locale}/{group}.php` 와 평면형 `backend/{group}.php` 만 허용.
|
||||
$phpLocationPattern = '#^backend/(?:'.preg_quote($locale, '#').'/)?[A-Za-z0-9_-]+\.php$#';
|
||||
|
||||
foreach ($this->collectPackageEntries($packageRoot) as $relative => $absolute) {
|
||||
if (is_link($absolute)) {
|
||||
throw new LanguagePackOperationException('language_packs.errors.symlink_not_allowed', [
|
||||
'file' => $relative,
|
||||
]);
|
||||
}
|
||||
|
||||
return $files;
|
||||
};
|
||||
|
||||
$allFiles = $finder($packageRoot);
|
||||
$backendDir = realpath($packageRoot.DIRECTORY_SEPARATOR.'backend') ?: null;
|
||||
|
||||
foreach ($allFiles as $absolute) {
|
||||
if (! str_ends_with($absolute, '.php')) {
|
||||
if (is_dir($absolute)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $backendDir || ! str_starts_with($absolute, $backendDir)) {
|
||||
throw new LanguagePackOperationException('language_packs.errors.php_outside_backend', [
|
||||
'file' => str_replace($packageRoot, '', $absolute),
|
||||
]);
|
||||
$extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
|
||||
|
||||
if ($extension === 'php') {
|
||||
if ($locale === '' || preg_match($phpLocationPattern, $relative) !== 1) {
|
||||
throw new LanguagePackOperationException('language_packs.errors.php_outside_backend', [
|
||||
'file' => $relative,
|
||||
]);
|
||||
}
|
||||
|
||||
$violation = LanguagePackPhpArrayValidator::inspectFile($absolute);
|
||||
|
||||
if ($violation !== null) {
|
||||
throw new LanguagePackOperationException('language_packs.errors.php_file_not_literal_array', [
|
||||
'file' => $relative,
|
||||
'line' => $violation['line'],
|
||||
]);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = File::get($absolute);
|
||||
if (preg_match('/\b(eval|include|include_once|require|require_once|exec|shell_exec|passthru|system|popen|proc_open)\s*\(/i', $contents)) {
|
||||
throw new LanguagePackOperationException('language_packs.errors.unsafe_php_pattern', [
|
||||
'file' => str_replace($packageRoot, '', $absolute),
|
||||
// 압축 도구가 끼워 넣는 OS 메타데이터는 형식 검사에서 제외한다.
|
||||
// PHP 는 위 분기에서 이미 위치 규칙에 걸리므로 이 예외로 새는 실행 경로가 없다.
|
||||
if ($this->isOsMetadataEntry($relative)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! in_array($extension, ['json', 'md'], true)) {
|
||||
throw new LanguagePackOperationException('language_packs.errors.unsupported_file_type', [
|
||||
'file' => $relative,
|
||||
'extension' => $extension === '' ? '(none)' : $extension,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 패키지 안의 모든 엔트리(디렉토리 포함)를 상대경로 ⇒ 절대경로로 수집합니다.
|
||||
*
|
||||
* 심볼릭 링크를 따라 들어가지 않는다 — 링크는 호출부가 거부 대상으로 판정하며,
|
||||
* 따라 들어가면 순환 링크로 무한 재귀에 빠질 수 있다.
|
||||
*
|
||||
* @param string $packageRoot 패키지 루트 디렉토리
|
||||
* @param string $prefix 현재 상대경로 접두사
|
||||
* @return array<string, string> 상대경로(슬래시 구분) ⇒ 절대경로
|
||||
*/
|
||||
private function collectPackageEntries(string $packageRoot, string $prefix = ''): array
|
||||
{
|
||||
$base = $prefix === '' ? $packageRoot : $packageRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $prefix);
|
||||
|
||||
if (! is_dir($base)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
|
||||
foreach (scandir($base) ?: [] as $name) {
|
||||
if ($name === '.' || $name === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = $prefix === '' ? $name : $prefix.'/'.$name;
|
||||
$absolute = $base.DIRECTORY_SEPARATOR.$name;
|
||||
|
||||
$entries[$relative] = $absolute;
|
||||
|
||||
if (is_dir($absolute) && ! is_link($absolute)) {
|
||||
$entries += $this->collectPackageEntries($packageRoot, $relative);
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 압축 도구가 생성하는 OS 메타데이터 엔트리인지 판정합니다.
|
||||
*
|
||||
* @param string $relative 패키지 루트 기준 상대경로
|
||||
* @return bool 메타데이터면 true
|
||||
*/
|
||||
private function isOsMetadataEntry(string $relative): bool
|
||||
{
|
||||
if (str_starts_with($relative, '__MACOSX/') || $relative === '__MACOSX') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(basename($relative), ['.DS_Store', 'Thumbs.db', '.gitkeep', '.gitignore'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 언어팩 설치 디렉토리(`lang-packs/`, `lang-packs/_pending/`) 의 쓰기 권한을 사전 검증합니다.
|
||||
*
|
||||
|
||||
@@ -239,8 +239,13 @@ class ScheduleService
|
||||
'next_run_at' => $schedule->next_run_at,
|
||||
]);
|
||||
|
||||
// 거부 사유는 error 레벨 한 줄만 보는 알림 파이프라인에서도 보여야 한다 —
|
||||
// 별도 warning 로그에만 남기면 그 경로에서는 "왜 막혔는지" 가 유실된다.
|
||||
Log::error('Schedule execution failed', [
|
||||
'schedule_id' => $schedule->id,
|
||||
'type' => $schedule->type->value,
|
||||
'command' => $schedule->command,
|
||||
'reason' => $e instanceof ScheduleExecutionException ? $e->reasonCode : null,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
@@ -262,11 +267,23 @@ class ScheduleService
|
||||
{
|
||||
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
|
||||
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
|
||||
if (! ScheduleCommandValidator::isArtisanCommandAllowed($schedule->command)) {
|
||||
throw ScheduleExecutionException::artisanNotAllowed();
|
||||
$verdict = ScheduleCommandValidator::inspectArtisanCommand($schedule->command);
|
||||
|
||||
if (! $verdict['allowed']) {
|
||||
// 업그레이드 점검 스텝을 두지 않으므로, 거부 사유는 이 로그가 유일한 운영 진단 통로다.
|
||||
Log::warning('Schedule artisan command rejected', [
|
||||
'schedule_id' => $schedule->id,
|
||||
'command' => $schedule->command,
|
||||
'reason' => $verdict['reason'],
|
||||
]);
|
||||
|
||||
throw ScheduleExecutionException::artisanNotAllowed($verdict['reason']);
|
||||
}
|
||||
|
||||
Artisan::call($schedule->command, [], new BufferedOutput);
|
||||
// 문자열이 아닌 (명령명, 인자배열) 로 넘겨 StringInput 재파싱을 경유하지 않는다 —
|
||||
// parameters 가 비어 있으면 Laravel 이 문자열 전체를 다시 파싱해
|
||||
// 검증한 이름과 다른 명령이 실행될 수 있다.
|
||||
Artisan::call($verdict['name'], $verdict['parameters'], new BufferedOutput);
|
||||
|
||||
return [
|
||||
'output' => Artisan::output(),
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Symfony\Component\Console\Command\Command as SymfonyCommand;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 스케줄 Shell/Artisan command 가 실행 가능한 안전 명령인지 판정하는 순수 유틸.
|
||||
*
|
||||
@@ -14,13 +19,34 @@ namespace App\Support;
|
||||
* 허용되더라도 호출부는 `tokenizeShellCommand()` 로 얻은 인자 배열로 실행해야 한다
|
||||
* (`/bin/sh -c` 를 경유하지 않으므로 파이프·`;`·`$()` 가 무력화된다).
|
||||
*
|
||||
* 2. Artisan — 기본 허용, 차단목록만 거부. `tinker` 처럼 임의 PHP 코드를 실행하거나
|
||||
* `db:wipe` 처럼 파괴적인 명령을 스케줄로 실행하지 못하게 막는다.
|
||||
* 2. Artisan — 기본 차단, 허용목록만 통과. `config/schedule_security.php` 의 허용목록에
|
||||
* 명령명이 있고 그 항목이 선언한 옵션만 쓰였을 때 통과한다. 설치된 확장이 소유한 명령은
|
||||
* 게이트가 켜져 있으면 자동으로 허용된다. 차단목록은 최종 거부권으로 가장 먼저 평가된다.
|
||||
*
|
||||
* 명령 문자열은 엄격 파서로만 해석한다 — 따옴표·백슬래시·단축 옵션이 있으면 거부하고,
|
||||
* 호출부는 `resolveArtisanCommand()` 가 돌려준 (명령명, 인자배열) 로 실행해야 한다.
|
||||
* 문자열째 `Artisan::call()` 에 넘기면 Symfony 가 따옴표를 다시 해석해
|
||||
* 검증한 이름과 실행되는 이름이 갈린다(`"tinker" --execute=…`).
|
||||
*
|
||||
* 예외를 던지지 않고 bool/배열만 반환한다 — 차단 시 어떤 응답/예외를 낼지는 호출부가 결정한다.
|
||||
*/
|
||||
class ScheduleCommandValidator
|
||||
{
|
||||
/** 파싱 불가 — 따옴표·백슬래시·제어문자·단축 옵션 등 정규 형태가 아님 */
|
||||
public const ARTISAN_REASON_MALFORMED = 'malformed';
|
||||
|
||||
/** 차단목록(최종 거부권)에 걸림 */
|
||||
public const ARTISAN_REASON_DENIED = 'denied';
|
||||
|
||||
/** 허용목록에도 없고 확장 소유 명령도 아님 */
|
||||
public const ARTISAN_REASON_NOT_ALLOWED = 'not_allowlisted';
|
||||
|
||||
/** 명령은 허용되나 선언되지 않은 옵션이 쓰임 */
|
||||
public const ARTISAN_REASON_OPTION = 'option_denied';
|
||||
|
||||
/** 허용 개수를 넘는 위치 인자가 쓰임 */
|
||||
public const ARTISAN_REASON_ARGUMENT = 'argument_denied';
|
||||
|
||||
/** 셸 해석을 유발하는 메타문자 — 하나라도 있으면 인자 배열 실행이 불가하므로 거부 */
|
||||
private const SHELL_METACHARACTERS = [
|
||||
'|', '&', ';', '$', '`', '>', '<', '(', ')', '{', '}',
|
||||
@@ -74,44 +100,386 @@ class ScheduleCommandValidator
|
||||
}
|
||||
|
||||
/**
|
||||
* Artisan command 문자열에서 명령명(첫 토큰)을 추출합니다.
|
||||
* Artisan command 문자열에서 명령명을 추출합니다.
|
||||
*
|
||||
* 엄격 파서를 경유하므로, Symfony 가 다르게 해석할 수 있는 형태(따옴표·백슬래시·
|
||||
* 선행 옵션)는 명령명을 돌려주지 않고 null 이 된다 — 여기서 반환된 이름은
|
||||
* 실제 실행되는 이름과 반드시 같다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 artisan command 문자열
|
||||
* @return string|null 명령명, 빈 문자열이면 null
|
||||
* @return string|null 명령명, 정규 형태가 아니면 null
|
||||
*/
|
||||
public static function extractArtisanCommandName(string $command): ?string
|
||||
{
|
||||
$trimmed = trim($command);
|
||||
$parsed = self::parseArtisanCommand($command);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', $trimmed);
|
||||
|
||||
return ($tokens === false || $tokens === []) ? null : $tokens[0];
|
||||
return $parsed === null ? null : $parsed['name'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Artisan command 가 차단목록에 걸리지 않는지 판정합니다.
|
||||
* Artisan command 를 실행 가능한지 판정하고 사유·실행 계획을 함께 돌려줍니다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 artisan command 문자열
|
||||
* @return array{allowed: bool, reason: string|null, name: string|null, parameters: array<string, mixed>}
|
||||
*/
|
||||
public static function inspectArtisanCommand(string $command): array
|
||||
{
|
||||
$parsed = self::parseArtisanCommand($command);
|
||||
|
||||
if ($parsed === null) {
|
||||
return self::artisanVerdict(false, self::ARTISAN_REASON_MALFORMED);
|
||||
}
|
||||
|
||||
$name = $parsed['name'];
|
||||
|
||||
// ① 최종 거부권 — 허용목록·확장 자동 허용보다 먼저 평가한다.
|
||||
// 확장이 코어 명령 이름을 가로채 등록하는 경우도 여기서 걸린다.
|
||||
if (self::isArtisanCommandDenied($name)) {
|
||||
return self::artisanVerdict(false, self::ARTISAN_REASON_DENIED, $name);
|
||||
}
|
||||
|
||||
$allowlist = (array) config('schedule_security.artisan.allowlist', []);
|
||||
$spec = self::findArtisanAllowlistSpec($allowlist, $name);
|
||||
|
||||
if ($spec !== null) {
|
||||
return self::verifyArtisanUsage(
|
||||
$name,
|
||||
$parsed,
|
||||
static fn (string $option): bool => in_array($option, $spec['options'], true),
|
||||
$spec['max_arguments'],
|
||||
);
|
||||
}
|
||||
|
||||
// ③ 확장 소유 명령 — 자기 정의(getDefinition)에 있는 옵션만 허용한다.
|
||||
$definition = self::resolveExtensionCommandDefinition($name);
|
||||
|
||||
if ($definition === null) {
|
||||
return self::artisanVerdict(false, self::ARTISAN_REASON_NOT_ALLOWED, $name);
|
||||
}
|
||||
|
||||
return self::verifyArtisanUsage(
|
||||
$name,
|
||||
$parsed,
|
||||
static fn (string $option): bool => $definition->hasOption($option),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 허용 시 (명령명, 인자배열) 실행 계획을 돌려줍니다.
|
||||
*
|
||||
* 호출부는 반드시 이 계획으로 `Artisan::call($name, $parameters, ...)` 를 호출해야 한다.
|
||||
* 문자열째 넘기면 Symfony 가 재파싱해 검증한 이름과 다른 명령이 실행될 수 있다.
|
||||
*
|
||||
* 코어의 두 호출부(FormRequest Rule · 실행부)는 거부 사유 코드가 필요해
|
||||
* `inspectArtisanCommand()` 를 직접 쓴다. 이 메서드는 사유가 필요 없는 호출부
|
||||
* (확장의 자체 스케줄러 등)를 위한 형태이며, 판정은 전적으로 위임하므로
|
||||
* 두 경로의 결과가 갈릴 여지가 없다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 artisan command 문자열
|
||||
* @return array{name: string, parameters: array<string, mixed>}|null 허용되지 않으면 null
|
||||
*/
|
||||
public static function resolveArtisanCommand(string $command): ?array
|
||||
{
|
||||
$verdict = self::inspectArtisanCommand($command);
|
||||
|
||||
if (! $verdict['allowed'] || $verdict['name'] === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['name' => $verdict['name'], 'parameters' => $verdict['parameters']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Artisan command 를 실행해도 되는지 판정합니다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 artisan command 문자열
|
||||
* @return bool 실행을 허용하면 true
|
||||
*/
|
||||
public static function isArtisanCommandAllowed(string $command): bool
|
||||
{
|
||||
$name = self::extractArtisanCommandName($command);
|
||||
return self::inspectArtisanCommand($command)['allowed'];
|
||||
}
|
||||
|
||||
if ($name === null) {
|
||||
return false;
|
||||
/**
|
||||
* Artisan command 문자열을 엄격 규칙으로 파싱합니다.
|
||||
*
|
||||
* 규칙(순서대로, 하나라도 걸리면 null):
|
||||
* 1. trim 후 빈 문자열
|
||||
* 2. 제어문자 포함
|
||||
* 3. 따옴표(`"` `'`)·백슬래시 포함 — Symfony 가 재해석하는 형태를 원천 배제
|
||||
* 4. 첫 토큰이 명령명 형태가 아님 (`-` 로 시작 불가)
|
||||
* 5. 나머지 토큰이 `--이름` / `--이름=값` 롱옵션이 아니거나 옵션 이름이 중복
|
||||
*
|
||||
* @param string $command artisan command 문자열
|
||||
* @return array{name: string, options: array<string, mixed>, arguments: array<int, string>}|null
|
||||
*/
|
||||
private static function parseArtisanCommand(string $command): ?array
|
||||
{
|
||||
$command = trim($command);
|
||||
|
||||
if ($command === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $command) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 따옴표·백슬래시가 있으면 Symfony 의 재해석 여지가 생긴다 (`"tinker"` → `tinker`).
|
||||
if (preg_match('/["\'\\\\]/', $command) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', $command);
|
||||
|
||||
if ($tokens === false || $tokens === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = array_shift($tokens);
|
||||
|
||||
if (preg_match('/^[A-Za-z][A-Za-z0-9_.-]*(?::[A-Za-z0-9_.-]+)*$/', $name) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$options = [];
|
||||
$arguments = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if ($token === '--' || $token === '-') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($token, '--')) {
|
||||
$body = substr($token, 2);
|
||||
$separator = strpos($body, '=');
|
||||
|
||||
$optionName = $separator === false ? $body : substr($body, 0, $separator);
|
||||
$optionValue = $separator === false ? true : substr($body, $separator + 1);
|
||||
|
||||
if (preg_match('/^[A-Za-z][A-Za-z0-9-]*$/', $optionName) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 중복 옵션은 어느 값이 이기는지가 파서에 좌우되므로 거부한다.
|
||||
if (array_key_exists($optionName, $options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$options[$optionName] = $optionValue;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 단축 옵션(`-v`)은 이름이 정의에 따라 달라져 화이트리스트 대조가 불가능하다.
|
||||
if (str_starts_with($token, '-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$arguments[] = $token;
|
||||
}
|
||||
|
||||
return ['name' => $name, 'options' => $options, 'arguments' => $arguments];
|
||||
}
|
||||
|
||||
/**
|
||||
* 파싱된 명령의 옵션·위치 인자가 허용 범위 안인지 확인합니다.
|
||||
*
|
||||
* @param string $name 명령명
|
||||
* @param array{name: string, options: array<string, mixed>, arguments: array<int, string>} $parsed 파싱 결과
|
||||
* @param callable(string): bool $optionAllowed 옵션 허용 판정
|
||||
* @param int $maxArguments 허용 위치 인자 개수
|
||||
* @return array{allowed: bool, reason: string|null, name: string|null, parameters: array<string, mixed>}
|
||||
*/
|
||||
private static function verifyArtisanUsage(string $name, array $parsed, callable $optionAllowed, int $maxArguments): array
|
||||
{
|
||||
foreach (array_keys($parsed['options']) as $option) {
|
||||
if (! $optionAllowed((string) $option)) {
|
||||
return self::artisanVerdict(false, self::ARTISAN_REASON_OPTION, $name);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($parsed['arguments']) > $maxArguments) {
|
||||
return self::artisanVerdict(false, self::ARTISAN_REASON_ARGUMENT, $name);
|
||||
}
|
||||
|
||||
$parameters = [];
|
||||
|
||||
foreach ($parsed['options'] as $option => $value) {
|
||||
$parameters['--'.$option] = $value;
|
||||
}
|
||||
|
||||
// 위치 인자는 이름이 있어야 ArrayInput 으로 넘길 수 있다. 허용 개수가 0 인 현재
|
||||
// 설정에서는 도달하지 않지만, 상한을 넓히면 이름을 해석할 수 없는 인자는 거부한다.
|
||||
if ($parsed['arguments'] !== []) {
|
||||
$names = self::resolveArtisanArgumentNames($name, count($parsed['arguments']));
|
||||
|
||||
if ($names === null) {
|
||||
return self::artisanVerdict(false, self::ARTISAN_REASON_ARGUMENT, $name);
|
||||
}
|
||||
|
||||
foreach ($parsed['arguments'] as $index => $value) {
|
||||
$parameters[$names[$index]] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return self::artisanVerdict(true, null, $name, $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* 명령명이 차단목록(정확 일치 또는 접두사)에 걸리는지 판정합니다.
|
||||
*
|
||||
* @param string $name 명령명
|
||||
* @return bool 차단 대상이면 true
|
||||
*/
|
||||
private static function isArtisanCommandDenied(string $name): bool
|
||||
{
|
||||
$normalized = strtolower($name);
|
||||
|
||||
$denylist = array_map(
|
||||
static fn ($denied): string => strtolower(trim((string) $denied)),
|
||||
(array) config('schedule_security.artisan.denylist', []),
|
||||
);
|
||||
|
||||
return ! in_array(strtolower($name), $denylist, true);
|
||||
if (in_array($normalized, $denylist, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ((array) config('schedule_security.artisan.denylist_prefixes', []) as $prefix) {
|
||||
$prefix = strtolower(trim((string) $prefix));
|
||||
|
||||
if ($prefix !== '' && str_starts_with($normalized, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용목록에서 명령 항목을 찾아 정규화합니다 (대소문자 무관).
|
||||
*
|
||||
* @param array<string, mixed> $allowlist 허용목록 설정
|
||||
* @param string $name 명령명
|
||||
* @return array{options: array<int, string>, max_arguments: int}|null 미등재면 null
|
||||
*/
|
||||
private static function findArtisanAllowlistSpec(array $allowlist, string $name): ?array
|
||||
{
|
||||
$normalized = strtolower($name);
|
||||
|
||||
foreach ($allowlist as $key => $spec) {
|
||||
if (strtolower(trim((string) $key)) !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$spec = (array) $spec;
|
||||
|
||||
return [
|
||||
'options' => array_map('strval', (array) ($spec['options'] ?? [])),
|
||||
'max_arguments' => (int) ($spec['max_arguments'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장이 소유한 명령이면 그 정의를 돌려줍니다.
|
||||
*
|
||||
* 이름이 아니라 등록된 명령 인스턴스의 클래스 네임스페이스로 소유자를 판정한다 —
|
||||
* Symfony 가 실제 실행에 쓰는 레지스트리이므로 이름만으로 위조할 수 없다.
|
||||
*
|
||||
* @param string $name 명령명
|
||||
* @return InputDefinition|null 확장 소유가 아니면 null
|
||||
*/
|
||||
private static function resolveExtensionCommandDefinition(string $name): ?InputDefinition
|
||||
{
|
||||
if (! (bool) config('schedule_security.artisan.allow_extension_commands', true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$namespaces = array_filter(array_map(
|
||||
static fn ($namespace): string => trim((string) $namespace),
|
||||
(array) config('schedule_security.artisan.extension_namespaces', []),
|
||||
));
|
||||
|
||||
if ($namespaces === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$command = self::findRegisteredCommand($name);
|
||||
|
||||
if ($command === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$class = get_class($command);
|
||||
|
||||
foreach ($namespaces as $namespace) {
|
||||
if (str_starts_with($class, $namespace)) {
|
||||
return $command->getDefinition();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 artisan 명령 인스턴스를 찾습니다.
|
||||
*
|
||||
* 콘솔 부트스트랩 비용이 있으므로 허용목록에서 찾지 못했을 때만 호출한다.
|
||||
* 조회 자체가 실패하면 "허용되지 않음" 으로 취급한다(fail-closed).
|
||||
*
|
||||
* @param string $name 명령명
|
||||
* @return SymfonyCommand|null 없으면 null
|
||||
*/
|
||||
private static function findRegisteredCommand(string $name): ?SymfonyCommand
|
||||
{
|
||||
try {
|
||||
$commands = Artisan::all();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $commands[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치 인자에 대응하는 인자 이름을 순서대로 해석합니다.
|
||||
*
|
||||
* @param string $name 명령명
|
||||
* @param int $count 위치 인자 개수
|
||||
* @return array<int, string>|null 해석 불가하면 null
|
||||
*/
|
||||
private static function resolveArtisanArgumentNames(string $name, int $count): ?array
|
||||
{
|
||||
$command = self::findRegisteredCommand($name);
|
||||
|
||||
if ($command === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$names = array_values(array_filter(
|
||||
array_keys($command->getDefinition()->getArguments()),
|
||||
static fn (string $argument): bool => $argument !== 'command',
|
||||
));
|
||||
|
||||
return count($names) >= $count ? $names : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 판정 결과 배열을 만듭니다.
|
||||
*
|
||||
* @param bool $allowed 허용 여부
|
||||
* @param string|null $reason 거부 사유 코드
|
||||
* @param string|null $name 명령명
|
||||
* @param array<string, mixed> $parameters 실행 인자 배열
|
||||
* @return array{allowed: bool, reason: string|null, name: string|null, parameters: array<string, mixed>}
|
||||
*/
|
||||
private static function artisanVerdict(bool $allowed, ?string $reason = null, ?string $name = null, array $parameters = []): array
|
||||
{
|
||||
return ['allowed' => $allowed, 'reason' => $reason, 'name' => $name, 'parameters' => $parameters];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,17 +16,114 @@ return [
|
||||
],
|
||||
|
||||
'artisan' => [
|
||||
// 파괴적·코드 실행형 artisan 명령 차단목록 (첫 토큰 기준).
|
||||
// 실행을 허용하는 artisan 명령 목록 (허용목록 — 여기 없으면 거부).
|
||||
//
|
||||
// 차단목록 방식은 등록 명령 238개 중 9개만 막아 나머지를 무조건 허용했고,
|
||||
// `migrate:refresh` 가 빠져 있는 등 "빠뜨림" 이 구조적으로 반복됐다.
|
||||
// 코드 생성·스키마 변경·비밀값 접근이 없는 유지보수 명령만 등재한다.
|
||||
//
|
||||
// 각 항목의 `options` 는 허용 롱옵션 이름(`--` 없이)이며, 선언되지 않은 옵션은 거부한다.
|
||||
// 실제 시그니처(`getDefinition()`)를 확인해 채운 값이다 — 경로·클래스·파일을 지정하거나
|
||||
// 안전장치를 무력화하는 옵션(`--path` `--model` `--force` `--daemon` 등)은 제외했다.
|
||||
// `max_arguments` 는 허용 위치 인자 개수이며 생략 시 0 이다.
|
||||
'allowlist' => [
|
||||
// 캐시·컴파일 산출물 정리
|
||||
'cache:clear' => ['options' => [], 'max_arguments' => 0],
|
||||
'config:clear' => ['options' => []],
|
||||
'route:clear' => ['options' => []],
|
||||
'view:clear' => ['options' => []],
|
||||
'event:clear' => ['options' => []],
|
||||
'optimize:clear' => ['options' => []],
|
||||
'hooks:clear' => ['options' => []],
|
||||
'hooks:cache' => ['options' => []],
|
||||
|
||||
// 큐
|
||||
'queue:restart' => ['options' => []],
|
||||
'queue:prune-failed' => ['options' => ['hours']],
|
||||
'queue:prune-batches' => ['options' => ['hours', 'unfinished', 'cancelled']],
|
||||
'queue:work' => ['options' => [
|
||||
'queue', 'once', 'stop-when-empty', 'stop-when-empty-for',
|
||||
'delay', 'backoff', 'max-jobs', 'max-time',
|
||||
'memory', 'sleep', 'rest', 'timeout', 'tries',
|
||||
]],
|
||||
|
||||
// 만료 데이터 정리
|
||||
'auth:clear-resets' => ['options' => []],
|
||||
'sanctum:prune-expired' => ['options' => ['hours']],
|
||||
'model:prune' => ['options' => ['chunk', 'pretend']],
|
||||
'notification:cleanup' => ['options' => []],
|
||||
'layout-previews:cleanup' => ['options' => []],
|
||||
'ext-bundles:cleanup' => ['options' => []],
|
||||
|
||||
// SEO
|
||||
'seo:warmup' => ['options' => ['layout']],
|
||||
'seo:clear' => ['options' => ['layout']],
|
||||
'seo:generate-sitemap' => ['options' => ['sync', 'rebuild', 'mode']],
|
||||
|
||||
// 기타 유지보수
|
||||
'geoip:update' => ['options' => ['dry-run']],
|
||||
'search:index' => ['options' => ['filter']],
|
||||
'inspire' => ['options' => []],
|
||||
],
|
||||
|
||||
// 설치된 확장이 소유한 명령을 자동으로 허용할지 여부.
|
||||
// 확장 설치는 서버에 코드를 배치하는 행위로 이미 스케줄 권한보다 높은 신뢰를 요구하고,
|
||||
// 그 확장의 getSchedules() 명령은 이미 무인 실행되고 있다. 필요 시 끌 수 있다.
|
||||
'allow_extension_commands' => true,
|
||||
|
||||
// 확장 소유 판정 기준 — 명령 인스턴스의 클래스 네임스페이스 접두사.
|
||||
// Symfony 가 실제 실행에 쓰는 레지스트리를 보므로 이름만으로는 위조할 수 없다.
|
||||
'extension_namespaces' => ['Modules\\', 'Plugins\\'],
|
||||
|
||||
// 최종 거부권. 허용목록·확장 자동 허용보다 먼저 평가되어,
|
||||
// 확장이 코어 명령 이름을 가로채 등록하는 경우도 막는다.
|
||||
'denylist' => [
|
||||
// 임의 코드 실행
|
||||
'tinker',
|
||||
'invoke-serialized-closure',
|
||||
// 스키마·데이터 파괴
|
||||
'db',
|
||||
'db:wipe',
|
||||
'db:seed',
|
||||
'schema:dump',
|
||||
'migrate',
|
||||
'migrate:fresh',
|
||||
'migrate:reset',
|
||||
'migrate:rollback',
|
||||
'schedule:run',
|
||||
'schedule:work',
|
||||
'migrate:refresh',
|
||||
'migrate:install',
|
||||
// 비밀값·환경
|
||||
'env',
|
||||
'env:encrypt',
|
||||
'env:decrypt',
|
||||
'key:generate',
|
||||
// 서비스 상태 조작
|
||||
'schedule:run',
|
||||
'schedule:work',
|
||||
'serve',
|
||||
'down',
|
||||
'up',
|
||||
'test',
|
||||
'clear-compiled',
|
||||
// 파일 배치·코어/확장 변경
|
||||
'stub:publish',
|
||||
'vendor:publish',
|
||||
'package:discover',
|
||||
'core:update',
|
||||
'core:build',
|
||||
'core:execute-upgrade-steps',
|
||||
'core:execute-bundled-updates',
|
||||
'extension:composer-install',
|
||||
'module:install',
|
||||
'plugin:install',
|
||||
'template:install',
|
||||
'module:seed',
|
||||
'plugin:seed',
|
||||
'hotfix:rollback-stale-files',
|
||||
'playwright:issue-token',
|
||||
],
|
||||
|
||||
// 접두사 단위 거부권. `make:*` 41개를 일일이 나열하지 않는다.
|
||||
'denylist_prefixes' => ['make:'],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -319,7 +319,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| identifier | body | string | 예 | max 200 | 대상 확장/리소스의 식별자 |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부. 이 값을 참으로 보내려면 `core.language_packs.manage` 권한이 함께 필요하며, 없으면 이 항목만 422 로 거부됩니다 — 네 설치 경로(파일·URL·GitHub·번들) 모두 동일한 경계를 적용합니다. 관리자 화면의 번들 재설치는 활성화 권한이 없는 운영자에게 이 값을 보내지 않으므로 재설치 자체는 그대로 진행되며, 다만 대상 언어팩이 `installed` 로 내려가 다시 켜려면 관리 권한이 필요합니다 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -354,7 +354,7 @@ Content-Type: application/json
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** `lang-packs/_bundled/{identifier}` 디렉토리의 번들 소스에서 언어팩을 설치(또는 재설치)합니다. `core.language_packs.install` 권한이 필요합니다. `auto_activate` 가 true면 설치 후 곧바로 활성화합니다. 코어/확장에 선탑재된 번들 언어팩을 DB에 등록할 때 사용합니다.
|
||||
**설명** `lang-packs/_bundled/{identifier}` 디렉토리의 번들 소스에서 언어팩을 설치(또는 재설치)합니다. `core.language_packs.install` 권한이 필요합니다. 파일 형식·PHP 내용 제약은 ZIP 업로드와 동일합니다. `auto_activate` 가 true면 설치 후 곧바로 활성화하며, 이때는 `core.language_packs.manage` 권한이 함께 필요합니다. 활성 언어팩을 재설치하면서 이 값을 보내지 않으면 대상이 `installed` 로 내려갑니다 — 설치 트랜잭션이 항상 `installed` 로만 기록하고 활성화는 별도 단계로 분리되어 있기 때문입니다. 코어/확장에 선탑재된 번들 언어팩을 DB에 등록할 때 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/install-from-file
|
||||
@@ -368,7 +368,7 @@ Content-Type: application/json
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| file | body | file | 예 | max 10240 | 업로드 파일 |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부. 외부 소스(파일·URL·GitHub)에서 설치할 때 이 값을 참으로 보내려면 `core.language_packs.manage` 권한이 함께 필요하며, 없으면 이 항목만 422 로 거부됩니다 — 항목을 빼면 설치는 그대로 진행되고 상태는 `installed` 로 남습니다. 설치(install)와 활성화(manage)를 별도 권한으로 두는 정책을 따르기 위함입니다 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -409,7 +409,7 @@ Content-Disposition: form-data; name="auto_activate"
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 업로드된 ZIP 파일에서 언어팩을 설치합니다. `core.language_packs.install` 권한이 필요합니다. manifest 검증에 실패하면 422로 응답하며, `auto_activate` 가 true면 설치 후 즉시 활성화합니다. 관리자가 로컬 ZIP 파일을 직접 업로드해 언어팩을 추가하는 화면에 사용합니다.
|
||||
**설명** 업로드된 ZIP 파일에서 언어팩을 설치합니다. `core.language_packs.install` 권한이 필요합니다. manifest 검증에 실패하면 422로 응답합니다. 패키지에 담을 수 있는 파일은 `.json`/`.php`/`.md` 뿐이며, PHP 파일은 `backend/{언어코드}/{그룹}.php` 위치에 번역 배열만 담고 있어야 합니다 — 그 밖의 코드(함수 호출·변수·닫는 태그 뒤 코드 등)가 있으면 위반 줄 번호와 함께 거부됩니다. 심볼릭 링크도 거부됩니다. `auto_activate` 가 true면 설치 후 즉시 활성화하며, 이때는 `core.language_packs.manage` 권한이 함께 필요합니다. 관리자가 로컬 ZIP 파일을 직접 업로드해 언어팩을 추가하는 화면에 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/install-from-github
|
||||
@@ -423,7 +423,7 @@ Content-Disposition: form-data; name="auto_activate"
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| github_url | body | string | 예 | — | GitHub 저장소 URL |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부. 외부 소스(파일·URL·GitHub)에서 설치할 때 이 값을 참으로 보내려면 `core.language_packs.manage` 권한이 함께 필요하며, 없으면 이 항목만 422 로 거부됩니다 — 항목을 빼면 설치는 그대로 진행되고 상태는 `installed` 로 남습니다. 설치(install)와 활성화(manage)를 별도 권한으로 두는 정책을 따르기 위함입니다 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -458,7 +458,7 @@ Content-Type: application/json
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** GitHub 저장소 URL에서 언어팩을 다운로드해 설치합니다. `core.language_packs.install` 권한이 필요합니다. 외부 GitHub 호출을 동반하며 manifest 검증 실패 시 422로 응답합니다. `auto_activate` 가 true면 설치 후 즉시 활성화합니다. GitHub로 배포되는 언어팩을 URL 만으로 설치할 때 사용하며, 이후 check-updates/update 로 갱신을 추적할 수 있습니다.
|
||||
**설명** GitHub 저장소 URL에서 언어팩을 다운로드해 설치합니다. `core.language_packs.install` 권한이 필요합니다. 외부 GitHub 호출을 동반하며 manifest 검증 실패 시 422로 응답합니다. 파일 형식·PHP 내용 제약은 ZIP 업로드와 동일합니다. `auto_activate` 가 true면 설치 후 즉시 활성화하며, 이때는 `core.language_packs.manage` 권한이 함께 필요합니다. GitHub로 배포되는 언어팩을 URL 만으로 설치할 때 사용하며, 이후 check-updates/update 로 갱신을 추적할 수 있습니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/install-from-url
|
||||
@@ -473,7 +473,7 @@ Content-Type: application/json
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| url | body | string | 예 | max 500 | 언어팩 ZIP 다운로드 URL. `https` 만 허용하며, 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)와 userinfo(`https://a@b/`) 위장 주소는 422 로 거부됩니다 — 서버가 대신 내려받는 요청이므로 내부망 접근을 막기 위함(SSRF). 원격 코드를 가져오는 경로라 `security.allow_internal_outbound_urls` 설정을 켜도 내부 주소는 계속 차단됩니다 |
|
||||
| checksum | body | string | 아니오 | — | 무결성 검증 체크섬 (SHA-256) |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부. 외부 소스(파일·URL·GitHub)에서 설치할 때 이 값을 참으로 보내려면 `core.language_packs.manage` 권한이 함께 필요하며, 없으면 이 항목만 422 로 거부됩니다 — 항목을 빼면 설치는 그대로 진행되고 상태는 `installed` 로 남습니다. 설치(install)와 활성화(manage)를 별도 권한으로 두는 정책을 따르기 위함입니다 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ HTTP/1.1 200
|
||||
| name | body | string | 예 | max 255 | 대상의 이름/명칭 |
|
||||
| description | body | string | 아니오 | max 1000 | 설명 |
|
||||
| type | body | string | 예 | `artisan`, `shell`, `url` | 작업 유형: artisan(Artisan 커맨드 실행), shell(쉘 명령 실행), url(URL 호출) |
|
||||
| command | body | string | 예 | max 2000 | 실행할 명령 (`type` 에 따라 Artisan 커맨드 / 쉘 명령 / 호출할 URL). `type=url` 이면 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)는 거부되어 422 로 응답합니다 — 서버가 내부망으로 요청을 보내는 것을 막기 위함(SSRF). 사내 엔드포인트를 호출해야 하면 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요. 이 설정을 켜도 userinfo(`https://a@b/`) 위장과 http/https 이외 scheme 은 계속 거부됩니다.<br>`type=shell` 이면 기본적으로 모든 명령이 거부되어 422 로 응답합니다 — 스케줄 권한만 위임받은 계정이 서버에서 임의 OS 명령을 실행하는 것을 막기 위함입니다. 실행하려면 서버의 `.env` 에서 `SCHEDULE_SHELL_ENABLED=true` 로 게이트를 열고 `SCHEDULE_SHELL_ALLOWED_BINARIES` 에 허용할 실행 파일명을 등록해야 합니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 허용된 실행 파일이라도 셸 메타문자(`|`, `;`, `$`, 백틱, 리다이렉션, 따옴표 등)가 포함되면 거부되며, 실행 시 셸을 경유하지 않습니다. 복합 명령이 필요하면 래퍼 스크립트를 만들어 화이트리스트에 등록하세요.<br>`type=artisan` 이면 `tinker`·`db:wipe`·`migrate:fresh`·`migrate:reset`·`migrate:rollback`·`schedule:run`·`schedule:work`·`env:decrypt`·`key:generate` 같은 코드 실행형·파괴적 명령이 거부되어 422 로 응답합니다 |
|
||||
| command | body | string | 예 | max 2000 | 실행할 명령 (`type` 에 따라 Artisan 커맨드 / 쉘 명령 / 호출할 URL). `type=url` 이면 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)는 거부되어 422 로 응답합니다 — 서버가 내부망으로 요청을 보내는 것을 막기 위함(SSRF). 사내 엔드포인트를 호출해야 하면 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요. 이 설정을 켜도 userinfo(`https://a@b/`) 위장과 http/https 이외 scheme 은 계속 거부됩니다.<br>`type=shell` 이면 기본적으로 모든 명령이 거부되어 422 로 응답합니다 — 스케줄 권한만 위임받은 계정이 서버에서 임의 OS 명령을 실행하는 것을 막기 위함입니다. 실행하려면 서버의 `.env` 에서 `SCHEDULE_SHELL_ENABLED=true` 로 게이트를 열고 `SCHEDULE_SHELL_ALLOWED_BINARIES` 에 허용할 실행 파일명을 등록해야 합니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 허용된 실행 파일이라도 셸 메타문자(`|`, `;`, `$`, 백틱, 리다이렉션, 따옴표 등)가 포함되면 거부되며, 실행 시 셸을 경유하지 않습니다. 복합 명령이 필요하면 래퍼 스크립트를 만들어 화이트리스트에 등록하세요.<br>`type=artisan` 이면 서버가 허용한 유지보수 명령만 등록할 수 있고 그 밖의 명령은 거부되어 422 로 응답합니다 — 캐시·컴파일 산출물 정리(`cache:clear` 등), 큐 처리(`queue:work`·`queue:restart` 등), 만료 데이터 정리, SEO 관련 명령이 여기 해당합니다. `tinker` 처럼 임의 코드를 실행하거나 `migrate`·`db:wipe`·`make:*` 처럼 스키마를 바꾸거나 코드를 생성하는 명령은 허용 목록에 오르지 않습니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 설치된 모듈·플러그인이 제공하는 명령은 자동으로 허용됩니다.<br>허용된 명령이라도 그 명령이 선언하지 않은 옵션, 위치 인자, 단축 옵션(`-v`)이 붙으면 거부됩니다. 형식은 `명령명 --옵션[=값]` 만 허용하며 따옴표·역슬래시가 포함된 입력은 거부됩니다 — 저장할 때 해석한 명령과 실제로 실행되는 명령이 달라지지 않도록 하기 위함입니다. 거부 사유(허용 목록 밖 / 형식 오류 / 허용되지 않은 옵션 / 추가 인자 불가)는 응답 메시지로 구분되어 안내됩니다 |
|
||||
| expression | body | string | 예 | max 100 | 실행 시각을 정의하는 Cron 표현식 (예: `0 3 * * *`, 다음 실행 시각 next_run_at 계산의 기준) |
|
||||
| frequency | body | string | 예 | `everyMinute`, `hourly`, `daily`, `weekly`, `monthly`, `custom` | 실행 주기 |
|
||||
| without_overlapping | body | boolean | 아니오 | — | 중복 실행 방지 여부 |
|
||||
@@ -680,7 +680,7 @@ HTTP/1.1 200
|
||||
| name | body | string | 예 | max 255 | 대상의 이름/명칭 |
|
||||
| description | body | string | 아니오 | max 1000 | 설명 |
|
||||
| type | body | string | 예 | `artisan`, `shell`, `url` | 작업 유형: artisan(Artisan 커맨드 실행), shell(쉘 명령 실행), url(URL 호출) |
|
||||
| command | body | string | 예 | max 2000 | 실행할 명령 (`type` 에 따라 Artisan 커맨드 / 쉘 명령 / 호출할 URL). `type=url` 이면 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)는 거부되어 422 로 응답합니다 — 서버가 내부망으로 요청을 보내는 것을 막기 위함(SSRF). 사내 엔드포인트를 호출해야 하면 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요. 이 설정을 켜도 userinfo(`https://a@b/`) 위장과 http/https 이외 scheme 은 계속 거부됩니다.<br>`type=shell` 이면 기본적으로 모든 명령이 거부되어 422 로 응답합니다 — 스케줄 권한만 위임받은 계정이 서버에서 임의 OS 명령을 실행하는 것을 막기 위함입니다. 실행하려면 서버의 `.env` 에서 `SCHEDULE_SHELL_ENABLED=true` 로 게이트를 열고 `SCHEDULE_SHELL_ALLOWED_BINARIES` 에 허용할 실행 파일명을 등록해야 합니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 허용된 실행 파일이라도 셸 메타문자(`|`, `;`, `$`, 백틱, 리다이렉션, 따옴표 등)가 포함되면 거부되며, 실행 시 셸을 경유하지 않습니다. 복합 명령이 필요하면 래퍼 스크립트를 만들어 화이트리스트에 등록하세요.<br>`type=artisan` 이면 `tinker`·`db:wipe`·`migrate:fresh`·`migrate:reset`·`migrate:rollback`·`schedule:run`·`schedule:work`·`env:decrypt`·`key:generate` 같은 코드 실행형·파괴적 명령이 거부되어 422 로 응답합니다 |
|
||||
| command | body | string | 예 | max 2000 | 실행할 명령 (`type` 에 따라 Artisan 커맨드 / 쉘 명령 / 호출할 URL). `type=url` 이면 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)는 거부되어 422 로 응답합니다 — 서버가 내부망으로 요청을 보내는 것을 막기 위함(SSRF). 사내 엔드포인트를 호출해야 하면 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요. 이 설정을 켜도 userinfo(`https://a@b/`) 위장과 http/https 이외 scheme 은 계속 거부됩니다.<br>`type=shell` 이면 기본적으로 모든 명령이 거부되어 422 로 응답합니다 — 스케줄 권한만 위임받은 계정이 서버에서 임의 OS 명령을 실행하는 것을 막기 위함입니다. 실행하려면 서버의 `.env` 에서 `SCHEDULE_SHELL_ENABLED=true` 로 게이트를 열고 `SCHEDULE_SHELL_ALLOWED_BINARIES` 에 허용할 실행 파일명을 등록해야 합니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 허용된 실행 파일이라도 셸 메타문자(`|`, `;`, `$`, 백틱, 리다이렉션, 따옴표 등)가 포함되면 거부되며, 실행 시 셸을 경유하지 않습니다. 복합 명령이 필요하면 래퍼 스크립트를 만들어 화이트리스트에 등록하세요.<br>`type=artisan` 이면 서버가 허용한 유지보수 명령만 등록할 수 있고 그 밖의 명령은 거부되어 422 로 응답합니다 — 캐시·컴파일 산출물 정리(`cache:clear` 등), 큐 처리(`queue:work`·`queue:restart` 등), 만료 데이터 정리, SEO 관련 명령이 여기 해당합니다. `tinker` 처럼 임의 코드를 실행하거나 `migrate`·`db:wipe`·`make:*` 처럼 스키마를 바꾸거나 코드를 생성하는 명령은 허용 목록에 오르지 않습니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 설치된 모듈·플러그인이 제공하는 명령은 자동으로 허용됩니다.<br>허용된 명령이라도 그 명령이 선언하지 않은 옵션, 위치 인자, 단축 옵션(`-v`)이 붙으면 거부됩니다. 형식은 `명령명 --옵션[=값]` 만 허용하며 따옴표·역슬래시가 포함된 입력은 거부됩니다 — 저장할 때 해석한 명령과 실제로 실행되는 명령이 달라지지 않도록 하기 위함입니다. 거부 사유(허용 목록 밖 / 형식 오류 / 허용되지 않은 옵션 / 추가 인자 불가)는 응답 메시지로 구분되어 안내됩니다 |
|
||||
| expression | body | string | 예 | max 100 | 실행 시각을 정의하는 Cron 표현식 (예: `0 3 * * *`, 다음 실행 시각 next_run_at 계산의 기준) |
|
||||
| frequency | body | string | 예 | `everyMinute`, `hourly`, `daily`, `weekly`, `monthly`, `custom` | 실행 주기 |
|
||||
| without_overlapping | body | boolean | 아니오 | — | 중복 실행 방지 여부 |
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.10] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- 예약 작업에 등록할 수 없는 Artisan 명령·옵션을 안내하는 문구 일본어 번역 추가 — 왜 등록이 거부됐는지(허용 목록 밖 / 형식 오류 / 허용되지 않은 옵션 / 추가 인자 불가)가 사유별로 표시됩니다.
|
||||
- 언어팩 설치 시 거부 사유를 안내하는 문구 일본어 번역 추가 — 허용되지 않는 파일 형식, 번역 배열이 아닌 PHP 파일(줄 번호 포함), 심볼릭 링크, 그리고 설치 직후 활성화에 필요한 권한 안내입니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- PHP 파일 위치 안내 문구를 실제 허용 위치(`backend/{언어코드}/{그룹}.php`)를 알려주도록 다듬었습니다.
|
||||
|
||||
### Removed
|
||||
|
||||
- 더 이상 표시되지 않는 코드 패턴 경고 문구를 제거했습니다 — 같은 상황에서 어느 줄이 문제인지 알려주는 안내로 대체됐습니다.
|
||||
|
||||
## [1.0.9] - 2026-08-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -24,8 +24,11 @@ return [
|
||||
'errors' => [
|
||||
'manifest_not_found' => 'ZIP内に language-pack.json ファイルが見つかりません。',
|
||||
'manifest_invalid_json' => 'language-pack.json の JSON 形式が正しくありません。',
|
||||
'php_outside_backend' => 'backend/ ディレクトリ外に PHP ファイルが含まれています (:file)。',
|
||||
'unsafe_php_pattern' => 'PHP ファイルに許可されていないコードパターンが含まれています (:file)。',
|
||||
'php_outside_backend' => 'PHP ファイルは backend/{言語コード}/{グループ}.php の場所にのみ配置できます (:file)。',
|
||||
'php_file_not_literal_array' => 'PHP ファイルには翻訳配列のみを含めることができます。:file ファイルの :line 行目に配列ではないコードがあります。',
|
||||
'unsupported_file_type' => '言語パックに含めることができないファイル形式です (:file — :extension)。json、php、md ファイルのみ含めることができます。',
|
||||
'symlink_not_allowed' => '言語パックにシンボリックリンクを含めることはできません (:file)。',
|
||||
'auto_activate_requires_manage' => 'インストール後すぐに有効化するには言語パック管理権限が必要です。この項目を外せばインストールはそのまま進みます。',
|
||||
'core_locale_missing' => 'コア言語パック(:locale)を先に有効化する必要があります。',
|
||||
'target_not_installed' => '対象 :scope (:target) がインストールされていません。',
|
||||
'target_inactive' => '対象 :scope (:target) は無効化されています。先に有効化してください。',
|
||||
|
||||
@@ -480,6 +480,10 @@ return [
|
||||
'schedule_command' => [
|
||||
'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみ使用でき、パイプ(|)·セミコロン(;)などの特殊文字は使用できません。',
|
||||
'artisan_denied' => 'この Artisan コマンドはセキュリティ上の理由からスケジュール実行できません。',
|
||||
'artisan_not_allowlisted' => 'スケジュール実行が許可された Artisan コマンドではありません。キャッシュ削除·キュー処理などのメンテナンスコマンドのみ登録できます。',
|
||||
'artisan_malformed' => 'Artisan コマンドの形式が正しくありません。引用符·バックスラッシュ·短縮オプション(-v)は使用できず、"コマンド名 --オプション[=値]" の形式のみ登録できます。',
|
||||
'artisan_option_denied' => 'この Artisan コマンドに許可されていないオプションが含まれています。',
|
||||
'artisan_argument_denied' => 'この Artisan コマンドには追加の引数を付けることができません。',
|
||||
],
|
||||
'component' => [
|
||||
'template_id_required' => 'コンポーネント検証には template_id が必要です。',
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 core Japanese language pack (bundled)",
|
||||
"ja": "G7 コア 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"license": "MIT",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
|
||||
@@ -25,8 +25,11 @@ return [
|
||||
'errors' => [
|
||||
'manifest_not_found' => 'language-pack.json file not found in archive.',
|
||||
'manifest_invalid_json' => 'language-pack.json contains invalid JSON.',
|
||||
'php_outside_backend' => 'PHP file detected outside the backend/ directory (:file).',
|
||||
'unsafe_php_pattern' => 'Disallowed code pattern detected in PHP file (:file).',
|
||||
'php_outside_backend' => 'PHP files are only allowed at backend/{locale}/{group}.php (:file).',
|
||||
'php_file_not_literal_array' => 'PHP files may only contain a translation array. Line :line of :file contains code that is not part of an array.',
|
||||
'unsupported_file_type' => 'This file type cannot be included in a language pack (:file — :extension). Only json, php and md files are allowed.',
|
||||
'symlink_not_allowed' => 'Language packs cannot contain symbolic links (:file).',
|
||||
'auto_activate_requires_manage' => 'Activating the pack right after installation requires the language pack management permission. Installation proceeds normally without this option.',
|
||||
'core_locale_missing' => 'A core language pack for ":locale" must be active first.',
|
||||
'target_not_installed' => 'Target :scope (":target") is not installed.',
|
||||
'target_inactive' => 'Target :scope (":target") is inactive. Activate it first.',
|
||||
|
||||
@@ -514,6 +514,10 @@ return [
|
||||
'schedule_command' => [
|
||||
'shell_not_allowed' => 'This shell command is not allowed. Only executables registered on the server may be used, and special characters such as pipes (|) or semicolons (;) are not permitted.',
|
||||
'artisan_denied' => 'This Artisan command cannot be run as a schedule for security reasons.',
|
||||
'artisan_not_allowlisted' => 'This Artisan command is not allowed to run on a schedule. Only maintenance commands such as cache clearing and queue processing can be registered.',
|
||||
'artisan_malformed' => 'The Artisan command format is invalid. Quotes, backslashes, and short options (-v) are not allowed; use "command --option[=value]" only.',
|
||||
'artisan_option_denied' => 'This Artisan command contains an option that is not allowed.',
|
||||
'artisan_argument_denied' => 'This Artisan command does not accept additional arguments.',
|
||||
],
|
||||
|
||||
// Component existence validation messages
|
||||
|
||||
@@ -25,8 +25,11 @@ return [
|
||||
'errors' => [
|
||||
'manifest_not_found' => 'ZIP 안에서 language-pack.json 파일을 찾을 수 없습니다.',
|
||||
'manifest_invalid_json' => 'language-pack.json 의 JSON 형식이 올바르지 않습니다.',
|
||||
'php_outside_backend' => 'backend/ 디렉토리 외에 PHP 파일이 포함되어 있습니다 (:file).',
|
||||
'unsafe_php_pattern' => 'PHP 파일에 허용되지 않는 코드 패턴이 포함되어 있습니다 (:file).',
|
||||
'php_outside_backend' => 'PHP 파일은 backend/{언어코드}/{그룹}.php 위치에만 둘 수 있습니다 (:file).',
|
||||
'php_file_not_literal_array' => 'PHP 파일에는 번역 배열만 담을 수 있습니다. :file 파일 :line 번째 줄에 배열이 아닌 코드가 있습니다.',
|
||||
'unsupported_file_type' => '언어팩에 넣을 수 없는 파일 형식입니다 (:file — :extension). json, php, md 파일만 포함할 수 있습니다.',
|
||||
'symlink_not_allowed' => '언어팩에는 심볼릭 링크를 포함할 수 없습니다 (:file).',
|
||||
'auto_activate_requires_manage' => '설치 후 바로 켜기는 언어팩 관리 권한이 필요합니다. 이 항목을 빼면 설치는 그대로 진행됩니다.',
|
||||
'core_locale_missing' => '코어 언어팩(:locale)이 먼저 활성화되어야 합니다.',
|
||||
'target_not_installed' => '대상 :scope (:target)이 설치되어 있지 않습니다.',
|
||||
'target_inactive' => '대상 :scope (:target)이 비활성 상태입니다. 먼저 활성화해야 합니다.',
|
||||
|
||||
@@ -513,6 +513,10 @@ return [
|
||||
'schedule_command' => [
|
||||
'shell_not_allowed' => '허용되지 않은 쉘 명령입니다. 서버에 등록된 실행 파일만 사용할 수 있으며, 파이프(|)·세미콜론(;) 등 특수문자는 쓸 수 없습니다.',
|
||||
'artisan_denied' => '이 Artisan 명령은 보안상 스케줄로 실행할 수 없습니다.',
|
||||
'artisan_not_allowlisted' => '예약 실행이 허용된 Artisan 명령이 아닙니다. 캐시 정리·큐 처리 등 유지보수 명령만 등록할 수 있습니다.',
|
||||
'artisan_malformed' => 'Artisan 명령 형식이 올바르지 않습니다. 따옴표·역슬래시·단축 옵션(-v)은 쓸 수 없고 "명령명 --옵션[=값]" 형태만 등록할 수 있습니다.',
|
||||
'artisan_option_denied' => '이 Artisan 명령에 허용되지 않은 옵션이 포함되어 있습니다.',
|
||||
'artisan_argument_denied' => '이 Artisan 명령에는 추가 인자를 붙일 수 없습니다.',
|
||||
],
|
||||
|
||||
// 컴포넌트 존재 여부 검증 메시지
|
||||
|
||||
@@ -13,6 +13,10 @@ use App\Support\PrivilegedDatabaseAccounts;
|
||||
* - POST ?action=test-db : 데이터베이스 연결 테스트
|
||||
*/
|
||||
|
||||
// 실행 바이너리 경로 허용 형태 정책 — 인스톨러 API 와 설치 워커가 같은 규칙을 공유한다.
|
||||
// 한쪽만 고치면 다른 쪽이 우회로가 되므로 의존성 없는 공용 파일로 두고 양쪽에서 로드한다.
|
||||
require_once __DIR__.'/../includes/binary-path-policy.php';
|
||||
|
||||
/**
|
||||
* 검증 API 클래스
|
||||
*/
|
||||
@@ -874,11 +878,7 @@ class ValidationApi
|
||||
*/
|
||||
private function isInstallerSafePathArg(string $path): bool
|
||||
{
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path);
|
||||
return installer_binary_path_shape_ok($path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -892,21 +892,7 @@ class ValidationApi
|
||||
*/
|
||||
private function splitPhpComposerTokens(string $path): ?array
|
||||
{
|
||||
if (! str_contains($path, ' ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', trim($path), 2);
|
||||
if (! is_array($tokens) || count($tokens) !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$php, $composer] = $tokens;
|
||||
if ($php === '' || $composer === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['php' => $php, 'composer' => $composer];
|
||||
return installer_resolve_php_composer_pair($path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -921,11 +907,12 @@ class ValidationApi
|
||||
return ['valid' => false, 'version' => null, 'message' => lang('error_php_path_empty')];
|
||||
}
|
||||
|
||||
// 'php' 기본값이 아니면 셸 메타문자 차단. 파일 존재/실행 가능 검사는
|
||||
// open_basedir 같은 PHP 런타임 제약 환경의 false negative 를 피하기 위해
|
||||
// 생략하고, exec 결과로 최종 판정한다.
|
||||
if ($path !== 'php' && ! $this->isInstallerSafePathArg($path)) {
|
||||
return ['valid' => false, 'version' => null, 'message' => lang('error_php_exec_failed', ['path' => $path])];
|
||||
// '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';
|
||||
@@ -974,15 +961,13 @@ class ValidationApi
|
||||
// 두 토큰으로 분해 후 각 토큰별 메타문자 차단 + 각각 escapeshellarg 적용한다.
|
||||
// 옛 raw shell 전달(escape 없는 분기) 은 복원하지 않음.
|
||||
if ($effectivePath !== 'composer' && str_contains($effectivePath, ' ')) {
|
||||
// 자리별 규칙(PHP 자리=형태만, Composer 자리=이름 형태까지)은 공용 정책이 담당한다.
|
||||
$tokens = $this->splitPhpComposerTokens($effectivePath);
|
||||
if ($tokens === null
|
||||
|| ! $this->isInstallerSafePathArg($tokens['php'])
|
||||
|| ! $this->isInstallerSafePathArg($tokens['composer'])
|
||||
) {
|
||||
if ($tokens === null) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'version' => null,
|
||||
'message' => lang('error_composer_exec_failed', ['path' => $effectivePath]),
|
||||
'message' => lang('error_composer_binary_path_not_allowed', ['path' => $effectivePath]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1011,24 +996,24 @@ class ValidationApi
|
||||
return ['valid' => false, 'version' => null, 'message' => lang('error_composer_version_parse_failed')];
|
||||
}
|
||||
|
||||
// 단일 토큰 — 시스템 기본('composer') 가 아니면 셸 메타문자 차단.
|
||||
// 단일 토큰 — 시스템 기본('composer') 가 아니면 Composer 자리 규칙을 적용한다.
|
||||
// 파일 존재/실행 가능 검사는 open_basedir 환경의 false negative 회피를 위해 생략.
|
||||
if ($effectivePath !== 'composer' && ! $this->isInstallerSafePathArg($effectivePath)) {
|
||||
if ($effectivePath !== 'composer' && ! installer_is_composer_binary_path($effectivePath)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'version' => null,
|
||||
'message' => lang('error_composer_exec_failed', ['path' => $effectivePath]),
|
||||
'message' => lang('error_composer_binary_path_not_allowed', ['path' => $effectivePath]),
|
||||
];
|
||||
}
|
||||
|
||||
// .phar 파일이면 PHP 바이너리와 결합
|
||||
if (str_ends_with($effectivePath, '.phar')) {
|
||||
// phpPath 도 동일한 가드 — 'php' 기본값이 아니면 메타문자 없는 단일 토큰이어야 함
|
||||
if ($phpPath !== 'php' && ! $this->isInstallerSafePathArg($phpPath)) {
|
||||
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_exec_failed', ['path' => $phpPath]),
|
||||
'message' => lang('error_php_binary_path_not_allowed', ['path' => $phpPath]),
|
||||
];
|
||||
}
|
||||
$command = escapeshellarg($phpPath).' '.escapeshellarg($effectivePath).' --version 2>&1';
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 인스톨러가 실행할 바이너리 경로의 허용 형태를 정의하는 공용 정책.
|
||||
*
|
||||
* 인스톨러는 운영자가 입력한 PHP/Composer 경로를 `exec()` 로 실행한다. `escapeshellarg()`
|
||||
* 는 "이 문자열이 하나의 인자" 임만 보장할 뿐, 그 인자가 실행 파일인지 코드 실행 플래그인지
|
||||
* 인터프리터가 실행할 스크립트인지는 보장하지 않는다. 실제로 Composer 칸에
|
||||
* `PHP경로 <임의스크립트>` 를 넣으면 그 스크립트가 그대로 실행된다(명령 끝의 `--version`
|
||||
* 은 스크립트 인자로 넘어갈 뿐이다).
|
||||
*
|
||||
* 따라서 셸 메타문자 차단 위에 **인자 자리를 닫는 순수 문자열 규칙**을 얹는다.
|
||||
*
|
||||
* 1. 하이픈 선두 토큰 거부 (양쪽 토큰) — `-r` `-c` `--define` 등 코드 실행 플래그 차단
|
||||
* 2. Composer 자리(둘째 토큰·단일 토큰)만 이름 형태 제한 — composer 계열 또는 `.phar`
|
||||
* 3. PHP 자리는 이름을 제한하지 않음 — 래퍼 스크립트·버전 붙은 이름·커스텀 이름 허용
|
||||
* 4. 상대경로·`..` 세그먼트 거부 — CWD 에 의존한 실행 차단
|
||||
*
|
||||
* 파일 시스템 stat(`is_file`/`is_executable`)은 호출하지 않는다. 시놀로지 DSM 등
|
||||
* `open_basedir` 가 시스템 binary 영역을 차단하는 환경에서 정상 절대경로가 false negative
|
||||
* 로 거부되는 회귀가 있었고(#361), 그 회귀를 구조적으로 재발 불가하게 두기 위함이다.
|
||||
* 실제 실행 가능 여부는 exec 결과로 최종 판정한다.
|
||||
*
|
||||
* 프레임워크·설정에 의존하지 않는다 — 인스톨러 API(라이브러리 모드)와 설치 워커
|
||||
* (`task-runner.php`) 양쪽에서 단독으로 `require_once` 할 수 있어야 한다.
|
||||
*/
|
||||
if (! function_exists('installer_binary_path_shape_ok')) {
|
||||
/**
|
||||
* 실행 경로 토큰이 허용 형태인지 판정합니다 (자리 공통 규칙).
|
||||
*
|
||||
* @param string $token 단일 경로 토큰
|
||||
* @return bool 허용 형태면 true
|
||||
*/
|
||||
function installer_binary_path_shape_ok(string $token): bool
|
||||
{
|
||||
if ($token === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 셸 메타문자·제어문자 차단. 백슬래시는 Windows 경로 구분자이므로 허용한다.
|
||||
if (preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 하이픈 선두는 실행 파일 경로가 아니라 옵션이다 — 이번 취약점을 닫는 핵심 규칙.
|
||||
if ($token[0] === '-') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// `#` 이후를 주석으로 흘려 `.phar` 접미사를 위장하는 트릭 차단.
|
||||
if (str_contains($token, '#')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// `..` 세그먼트 차단 (CWD 상대 이동).
|
||||
if (preg_match('#(^|[/\\\\])\.\.([/\\\\]|$)#', $token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// bare 명령(PATH 탐색)이 아니면 절대경로여야 한다.
|
||||
if ($token === 'php' || $token === 'composer') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return installer_binary_path_is_absolute($token);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('installer_binary_path_is_absolute')) {
|
||||
/**
|
||||
* 절대경로인지 판정합니다 (POSIX / Windows 드라이브 / UNC).
|
||||
*
|
||||
* @param string $token 경로 토큰
|
||||
* @return bool 절대경로면 true
|
||||
*/
|
||||
function installer_binary_path_is_absolute(string $token): bool
|
||||
{
|
||||
if ($token[0] === '/') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('#^[A-Za-z]:[\\\\/]#', $token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($token, '\\\\');
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('installer_is_composer_binary_path')) {
|
||||
/**
|
||||
* Composer 자리에 올 수 있는 경로인지 판정합니다.
|
||||
*
|
||||
* 이 자리는 인터프리터가 실행하는 스크립트가 되므로 이름 형태를 제한한다.
|
||||
* 제한이 없으면 `PHP경로 /tmp/올려둔파일` 형태가 그대로 남는다.
|
||||
*
|
||||
* @param string $token 경로 토큰
|
||||
* @return bool Composer 계열이면 true
|
||||
*/
|
||||
function installer_is_composer_binary_path(string $token): bool
|
||||
{
|
||||
if (! installer_binary_path_shape_ok($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('installer_resolve_php_composer_pair')) {
|
||||
/**
|
||||
* 공백 분리 입력을 (PHP 인터프리터, Composer 바이너리) 두 토큰으로 해석합니다.
|
||||
*
|
||||
* 멀티 PHP 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP)에서 특정 PHP 로
|
||||
* Composer 를 실행하려는 운영 의도를 계속 지원한다. 자리별 규칙을 모두 만족할 때만
|
||||
* 해석에 성공한다.
|
||||
*
|
||||
* 공백이 포함된 디렉토리(`C:\Program Files\...`)는 토큰 분리 휴리스틱과 충돌해
|
||||
* 지원하지 않는다 — `#361` 이 known_limitation 으로 명시한 기존 한계이며 이번 정책이
|
||||
* 새로 만드는 제약이 아니다.
|
||||
*
|
||||
* @param string $raw 공백으로 구분된 원본 입력
|
||||
* @return array{php: string, composer: string}|null 해석 실패 시 null
|
||||
*/
|
||||
function installer_resolve_php_composer_pair(string $raw): ?array
|
||||
{
|
||||
$raw = trim($raw);
|
||||
|
||||
if (! str_contains($raw, ' ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', $raw, 2);
|
||||
|
||||
if (! is_array($tokens) || count($tokens) !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$php, $composer] = $tokens;
|
||||
|
||||
// PHP 자리는 이름을 검사하지 않는다 — 인자가 고정되어 있어 코드 실행이 되지 않고,
|
||||
// 설치 환경마다 실행 파일 이름이 다르다(래퍼 스크립트·버전 접미사·커스텀 이름).
|
||||
if (! installer_binary_path_shape_ok($php)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! installer_is_composer_binary_path($composer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['php' => $php, 'composer' => $composer];
|
||||
}
|
||||
}
|
||||
@@ -195,10 +195,30 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
|
||||
$errors['admin_password_confirm'] = lang('error_password_mismatch');
|
||||
}
|
||||
|
||||
// PHP CLI / Composer 경로 처리
|
||||
// PHP CLI / Composer 경로 처리 — 저장 시점에 허용 형태를 강제한다.
|
||||
// 이 값들은 설치 워커에서 실제 명령의 실행 바이너리가 되므로, 형태 위반 값이
|
||||
// 애초에 .env / 설치 상태에 기록되지 않게 여기서 막는다.
|
||||
require_once __DIR__.'/binary-path-policy.php';
|
||||
|
||||
$phpBinary = trim($formData['php_binary'] ?? 'php');
|
||||
$formData['php_binary'] = $phpBinary !== '' ? $phpBinary : 'php';
|
||||
$formData['composer_binary'] = trim($formData['composer_binary'] ?? '');
|
||||
$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');
|
||||
|
||||
@@ -130,12 +130,27 @@ if (! function_exists('checkAbortStatusSSE')) {
|
||||
// Task 함수 정의 (install-worker.php에서 이관됨 — 동작 동일)
|
||||
// ============================================================================
|
||||
|
||||
// 실행 바이너리 경로 허용 형태 정책 — 인스톨러 API 와 동일 규칙을 공유한다.
|
||||
require_once __DIR__.'/binary-path-policy.php';
|
||||
|
||||
if (! function_exists('getPhpBinary')) {
|
||||
/**
|
||||
* 설치 상태에 저장된 PHP 실행 경로를 돌려준다.
|
||||
*
|
||||
* 이 값은 실제 명령의 실행 바이너리가 되므로(설치 워커의 artisan 호출 등),
|
||||
* 형태 규칙을 통과하지 못하면 시스템 기본값으로 폴백한다 — 설치 흐름은 유지하되
|
||||
* 사용자 입력이 인자 자리로 흘러가지 않도록 한다.
|
||||
*/
|
||||
function getPhpBinary(): string
|
||||
{
|
||||
$state = getInstallationState();
|
||||
$phpBinary = (string) ($state['config']['php_binary'] ?? '');
|
||||
|
||||
return $state['config']['php_binary'] ?? 'php' ?: 'php';
|
||||
if ($phpBinary === '' || $phpBinary === 'php') {
|
||||
return 'php';
|
||||
}
|
||||
|
||||
return installer_binary_path_shape_ok($phpBinary) ? $phpBinary : 'php';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,23 +158,12 @@ if (! function_exists('isInstallerExecutablePath')) {
|
||||
/**
|
||||
* 인스톨러가 exec 에 전달하기 안전한 단일 토큰 경로인지 검증한다.
|
||||
*
|
||||
* - 빈 문자열은 호출자가 시스템 기본값을 쓰겠다는 신호이므로 별도 처리.
|
||||
* - 공백/세미콜론/백틱/`$` 등 셸 메타문자가 포함된 입력은 거부.
|
||||
* - 파일 존재/실행 가능 검사는 open_basedir 같은 PHP 런타임 제약 환경의
|
||||
* false negative 를 피하기 위해 생략. 실제 실행 가능 여부는 exec 결과로 판정.
|
||||
* 판정은 공용 정책(binary-path-policy.php)이 소유한다 — 인스톨러 API 와 설치 워커가
|
||||
* 서로 다른 규칙을 쓰면 한쪽이 다른 쪽의 우회로가 된다.
|
||||
*/
|
||||
function isInstallerExecutablePath(string $path): bool
|
||||
{
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
// 셸 메타문자 + 제어문자 차단. 백슬래시는 Windows 경로 구분자이므로 차단 대상 아님 —
|
||||
// 셸 인젝션 차단은 호출자의 escapeshellarg 가 담당.
|
||||
if (preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return installer_binary_path_shape_ok($path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,28 +172,13 @@ if (! function_exists('splitInstallerPhpComposerTokens')) {
|
||||
* 공백 분리 입력을 "PHP 인터프리터 절대경로 + Composer 바이너리 절대경로" 두 토큰으로 분해.
|
||||
*
|
||||
* 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP) 의
|
||||
* 운영 의도를 지원한다. 두 토큰 모두 isInstallerExecutablePath 통과해야
|
||||
* 정상 입력으로 간주.
|
||||
* 운영 의도를 지원한다. 자리별 규칙은 공용 정책이 담당한다.
|
||||
*
|
||||
* @return array{php: string, composer: string}|null 분해 실패 시 null
|
||||
*/
|
||||
function splitInstallerPhpComposerTokens(string $path): ?array
|
||||
{
|
||||
if (! str_contains($path, ' ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', trim($path), 2);
|
||||
if (! is_array($tokens) || count($tokens) !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$php, $composer] = $tokens;
|
||||
if ($php === '' || $composer === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['php' => $php, 'composer' => $composer];
|
||||
return installer_resolve_php_composer_pair($path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,10 +196,7 @@ if (! function_exists('getComposerCommand')) {
|
||||
// 멀티 PHP 환경에서 특정 PHP 인터프리터로 composer 를 실행하려는 운영 의도 지원.
|
||||
if (str_contains($composerBinary, ' ')) {
|
||||
$tokens = splitInstallerPhpComposerTokens($composerBinary);
|
||||
if ($tokens === null
|
||||
|| ! isInstallerExecutablePath($tokens['php'])
|
||||
|| ! isInstallerExecutablePath($tokens['composer'])
|
||||
) {
|
||||
if ($tokens === null) {
|
||||
return 'composer';
|
||||
}
|
||||
|
||||
@@ -218,12 +204,12 @@ if (! function_exists('getComposerCommand')) {
|
||||
}
|
||||
|
||||
// 검증 실패 시 시스템 기본 'composer' 로 폴백 — 설치 흐름은 유지하되
|
||||
// 사용자 입력이 셸 명령으로 흘러가지 않도록 차단.
|
||||
if (! isInstallerExecutablePath($composerBinary)) {
|
||||
// 사용자 입력이 셸 명령이나 인자 자리로 흘러가지 않도록 차단.
|
||||
if (! installer_is_composer_binary_path($composerBinary)) {
|
||||
return 'composer';
|
||||
}
|
||||
|
||||
if (str_ends_with($composerBinary, '.phar')) {
|
||||
if (str_ends_with(strtolower($composerBinary), '.phar')) {
|
||||
$phpBinary = getPhpBinary();
|
||||
$phpArg = ($phpBinary !== 'php' && isInstallerExecutablePath($phpBinary))
|
||||
? escapeshellarg($phpBinary)
|
||||
@@ -249,21 +235,18 @@ if (! function_exists('getComposerCommandForDisplay')) {
|
||||
// 공백 분리 입력 — 토큰 검증 통과 시 사람 친화적 표기로 그대로 노출.
|
||||
if (str_contains($composerBinary, ' ')) {
|
||||
$tokens = splitInstallerPhpComposerTokens($composerBinary);
|
||||
if ($tokens === null
|
||||
|| ! isInstallerExecutablePath($tokens['php'])
|
||||
|| ! isInstallerExecutablePath($tokens['composer'])
|
||||
) {
|
||||
if ($tokens === null) {
|
||||
return 'composer';
|
||||
}
|
||||
|
||||
return $tokens['php'].' '.$tokens['composer'];
|
||||
}
|
||||
|
||||
if (! isInstallerExecutablePath($composerBinary)) {
|
||||
if (! installer_is_composer_binary_path($composerBinary)) {
|
||||
return 'composer';
|
||||
}
|
||||
|
||||
if (str_ends_with($composerBinary, '.phar')) {
|
||||
if (str_ends_with(strtolower($composerBinary), '.phar')) {
|
||||
return getPhpBinary().' '.$composerBinary;
|
||||
}
|
||||
|
||||
|
||||
@@ -877,6 +877,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_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.',
|
||||
|
||||
@@ -877,6 +877,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_version_too_low' => ':path — PHP :version (최소 :min 필요)',
|
||||
'error_php_version_parse_failed' => 'PHP 버전을 파싱할 수 없습니다.',
|
||||
'error_php_cli_not_verified' => 'PHP CLI 경로가 확인되지 않았습니다. "버전 확인" 버튼을 클릭해주세요.',
|
||||
|
||||
+17
-2
@@ -106,7 +106,7 @@ describe('admin language pack list — 드리프트(files_missing) 배선', () =
|
||||
expect(setsSelected.params.languagePackReinstallMode).toBe(false);
|
||||
});
|
||||
|
||||
it('설치 모달이 재설치 모드에 따라 auto_activate 를 보낸다', () => {
|
||||
it('설치 모달이 재설치 모드와 활성화 권한을 함께 보고 auto_activate 를 보낸다', () => {
|
||||
const modal = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.resolve(__dirname, '../../layouts/partials/admin_language_pack_list/_modal_install_bundled.json'),
|
||||
@@ -119,7 +119,22 @@ describe('admin language pack list — 드리프트(files_missing) 배선', () =
|
||||
(n) => n.handler === 'apiCall' && String(n.target).includes('install-from-bundled'),
|
||||
);
|
||||
expect(apiCall).not.toBeNull();
|
||||
expect(apiCall.params.body.auto_activate).toBe('{{_global.languagePackReinstallMode === true}}');
|
||||
|
||||
const expression = apiCall.params.body.auto_activate;
|
||||
|
||||
// 재설치는 "복구" 이므로 활성 상태를 유지해야 한다 → 재설치 모드가 조건에 남아 있어야 한다.
|
||||
expect(expression).toContain('_global.languagePackReinstallMode === true');
|
||||
|
||||
// 서버가 auto_activate=true 에 활성화 권한(core.language_packs.manage)을 요구하므로,
|
||||
// 권한 없는 운영자에게 이 값을 실어 보내면 422 로 재설치 자체가 막힌다 —
|
||||
// 화면에는 이 플래그를 끄는 수단이 없어 막다른 길이 된다.
|
||||
// 권한이 없으면 보내지 않아야 재설치는 되고 팩만 installed 로 내려간다.
|
||||
expect(expression).toContain("selectedBundledLanguagePack?.abilities?.can_activate === true");
|
||||
|
||||
// 두 조건은 AND 여야 한다 (하나라도 OR 이면 권한 없는 계정이 true 를 보낸다).
|
||||
expect(expression).toBe(
|
||||
'{{_global.languagePackReinstallMode === true && _global.selectedBundledLanguagePack?.abilities?.can_activate === true}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@
|
||||
"method": "POST",
|
||||
"body": {
|
||||
"identifier": "{{_global.selectedBundledLanguagePack?.identifier}}",
|
||||
"auto_activate": "{{_global.languagePackReinstallMode === true}}"
|
||||
"auto_activate": "{{_global.languagePackReinstallMode === true && _global.selectedBundledLanguagePack?.abilities?.can_activate === true}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
|
||||
@@ -8,9 +8,14 @@ use App\Models\LanguagePack;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* LanguagePackController Feature 테스트.
|
||||
@@ -39,7 +44,7 @@ class LanguagePackControllerTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(\Database\Seeders\RolePermissionSeeder::class);
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$this->reader = $this->makeUserWithPermissions(['core.language_packs.read']);
|
||||
$this->manager = $this->makeUserWithPermissions([
|
||||
@@ -144,8 +149,8 @@ class LanguagePackControllerTest extends TestCase
|
||||
// 임시 번들 디렉토리 — 본 테스트의 슬롯 충돌만 회피.
|
||||
$bundledIdentifier = 'test-feature-acme-zz-'.uniqid();
|
||||
$bundledPath = base_path('lang-packs/_bundled/'.$bundledIdentifier);
|
||||
\Illuminate\Support\Facades\File::ensureDirectoryExists($bundledPath);
|
||||
\Illuminate\Support\Facades\File::put(
|
||||
File::ensureDirectoryExists($bundledPath);
|
||||
File::put(
|
||||
$bundledPath.'/language-pack.json',
|
||||
json_encode([
|
||||
'identifier' => $bundledIdentifier,
|
||||
@@ -176,7 +181,7 @@ class LanguagePackControllerTest extends TestCase
|
||||
$this->assertNull($first['id']);
|
||||
$this->assertTrue($first['abilities']['can_install'] ?? false);
|
||||
} finally {
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($bundledPath);
|
||||
File::deleteDirectory($bundledPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,8 +193,8 @@ class LanguagePackControllerTest extends TestCase
|
||||
$locale = 'yy';
|
||||
$bundledIdentifier = $vendor.'-core-'.$locale;
|
||||
$bundledPath = base_path('lang-packs/_bundled/'.$bundledIdentifier);
|
||||
\Illuminate\Support\Facades\File::ensureDirectoryExists($bundledPath);
|
||||
\Illuminate\Support\Facades\File::put(
|
||||
File::ensureDirectoryExists($bundledPath);
|
||||
File::put(
|
||||
$bundledPath.'/language-pack.json',
|
||||
json_encode([
|
||||
'identifier' => $bundledIdentifier,
|
||||
@@ -227,8 +232,8 @@ class LanguagePackControllerTest extends TestCase
|
||||
'status' => LanguagePackStatus::Active->value,
|
||||
]);
|
||||
} finally {
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($bundledPath);
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($installedDir);
|
||||
File::deleteDirectory($bundledPath);
|
||||
File::deleteDirectory($installedDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +264,7 @@ class LanguagePackControllerTest extends TestCase
|
||||
|
||||
$bigContent = str_repeat('x', 11 * 1024 * 1024);
|
||||
$response = $this->postJson('/api/admin/language-packs/install-from-file', [
|
||||
'file' => \Illuminate\Http\UploadedFile::fake()->createWithContent('big.zip', $bigContent),
|
||||
'file' => UploadedFile::fake()->createWithContent('big.zip', $bigContent),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
@@ -562,4 +567,326 @@ class LanguagePackControllerTest extends TestCase
|
||||
->assertJsonCount(1, 'data.data')
|
||||
->assertJsonPath('data.data.0.vendor', 'foobar');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 활성화 권한 경계 (KVE-2026-1678)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* install 권한만 가진 계정은 `auto_activate` 로 활성화까지 수행할 수 없다.
|
||||
*
|
||||
* 설치(install)와 활성화(manage)는 원래 별도 권한인데, 설치 요청의 `auto_activate`
|
||||
* 플래그가 그 경계를 우회하고 있었다. 활성 팩만 fallback 경로에 배선되어 `require`
|
||||
* 되므로 이 경계가 임의 PHP 실행의 마지막 관문이다.
|
||||
*
|
||||
* 외부 소스 3종(파일·URL·GitHub) 모두 같은 경계를 가져야 한다 — 한 곳만 막으면
|
||||
* 나머지가 우회로가 된다.
|
||||
*
|
||||
* @param string $endpoint 설치 엔드포인트
|
||||
* @param array<string, mixed> $payload `auto_activate` 를 제외한 요청 본문
|
||||
*
|
||||
* @scenario vector=install_from_untrusted_source, actor_permission=install_only
|
||||
*
|
||||
* @effects install_only_user_cannot_auto_activate
|
||||
*/
|
||||
#[DataProvider('externalInstallEndpointProvider')]
|
||||
public function test_install_only_user_cannot_auto_activate(string $endpoint, array $payload): void
|
||||
{
|
||||
Sanctum::actingAs($this->makeUserWithPermissions(['core.language_packs.install']));
|
||||
|
||||
$response = $this->postJson($endpoint, $payload + ['auto_activate' => true]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('auto_activate');
|
||||
}
|
||||
|
||||
/**
|
||||
* `auto_activate` 를 보내지 않으면 install 권한만으로도 설치 요청이 통과한다.
|
||||
*
|
||||
* 게이트가 설치 자체를 막으면 안 된다 — 거부되는 것은 그 항목 하나뿐이어야 한다.
|
||||
* (설치 자체는 뒤이어 소스 검증에서 실패하므로 `auto_activate` 검증 오류가
|
||||
* 없다는 것만 단언한다.)
|
||||
*
|
||||
* @param string $endpoint 설치 엔드포인트
|
||||
* @param array<string, mixed> $payload 요청 본문
|
||||
*
|
||||
* @scenario vector=install_from_untrusted_source, actor_permission=install_only
|
||||
*
|
||||
* @effects install_without_auto_activate_is_not_blocked
|
||||
*/
|
||||
#[DataProvider('externalInstallEndpointProvider')]
|
||||
public function test_install_without_auto_activate_is_not_blocked_for_install_only_user(string $endpoint, array $payload): void
|
||||
{
|
||||
Sanctum::actingAs($this->makeUserWithPermissions(['core.language_packs.install']));
|
||||
|
||||
$response = $this->postJson($endpoint, $payload);
|
||||
|
||||
$this->assertArrayNotHasKey(
|
||||
'auto_activate',
|
||||
(array) ($response->json('errors') ?? []),
|
||||
"auto_activate 를 보내지 않았는데 활성화 권한 게이트가 걸렸다: {$endpoint}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* manage 권한을 가진 계정의 `auto_activate` 는 권한 게이트에 걸리지 않는다.
|
||||
*
|
||||
* @param string $endpoint 설치 엔드포인트
|
||||
* @param array<string, mixed> $payload 요청 본문
|
||||
*
|
||||
* @scenario vector=install_from_untrusted_source, actor_permission=install_and_manage
|
||||
*
|
||||
* @effects manage_user_can_auto_activate
|
||||
*/
|
||||
#[DataProvider('externalInstallEndpointProvider')]
|
||||
public function test_manage_user_is_not_blocked_by_the_activation_gate(string $endpoint, array $payload): void
|
||||
{
|
||||
Sanctum::actingAs($this->manager);
|
||||
|
||||
$response = $this->postJson($endpoint, $payload + ['auto_activate' => true]);
|
||||
|
||||
$this->assertArrayNotHasKey(
|
||||
'auto_activate',
|
||||
(array) ($response->json('errors') ?? []),
|
||||
"활성화 권한 보유자가 게이트에 걸렸다: {$endpoint}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화 권한만 가진 계정은 설치 자체를 할 수 없다.
|
||||
*
|
||||
* 활성화 게이트를 `auto_activate` 필드에 건 결과로 "manage 만 있으면 설치가 된다" 는
|
||||
* 반대 방향의 구멍이 생기지 않았음을 고정한다. 설치 권한은 라우트 미들웨어가 소유하므로
|
||||
* 필드 단위 Rule 에 도달하기 전에 403 으로 끊겨야 한다 — 여기서 422(검증 단계 도달)가
|
||||
* 나오면 설치 권한 경계가 무너진 것이다.
|
||||
*
|
||||
* @param string $endpoint 설치 엔드포인트
|
||||
* @param array<string, mixed> $payload 요청 본문
|
||||
*
|
||||
* @scenario vector=install_from_untrusted_source, actor_permission=manage_only
|
||||
*
|
||||
* @effects manage_only_user_cannot_install_at_all
|
||||
*/
|
||||
#[DataProvider('externalInstallEndpointProvider')]
|
||||
public function test_manage_only_user_cannot_install(string $endpoint, array $payload): void
|
||||
{
|
||||
Sanctum::actingAs($this->makeUserWithPermissions(['core.language_packs.manage']));
|
||||
|
||||
$response = $this->postJson($endpoint, $payload + ['auto_activate' => true]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 업로드 경로에도 동일한 활성화 권한 경계가 적용된다.
|
||||
*
|
||||
* 보고서의 주 공격 벡터가 이 경로다 — 업로드한 PHP 를 곧바로 활성화해
|
||||
* `require` 경로에 배선하는 것이 체인의 마지막 단계였다.
|
||||
*
|
||||
* @scenario vector=install_from_untrusted_source, actor_permission=install_only
|
||||
*
|
||||
* @effects install_only_user_cannot_auto_activate
|
||||
*/
|
||||
public function test_install_from_file_blocks_auto_activate_for_install_only_user(): void
|
||||
{
|
||||
Sanctum::actingAs($this->makeUserWithPermissions(['core.language_packs.install']));
|
||||
|
||||
$response = $this->postJson('/api/admin/language-packs/install-from-file', [
|
||||
'file' => UploadedFile::fake()->create('pack.zip', 8, 'application/zip'),
|
||||
'auto_activate' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('auto_activate');
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들 설치도 외부 소스와 동일하게 활성화 권한 게이트를 적용한다 (PO 결정, 2026-08-04).
|
||||
*
|
||||
* 설치(install)와 활성화(manage)를 별도 권한으로 두는 정책을 네 경로 전부에 같은 강도로
|
||||
* 적용한다 — 어느 경로로 들어오느냐가 권한 경계를 바꾸면 그 경로가 우회로가 된다.
|
||||
*
|
||||
* 검토 단계에서는 "번들은 저장소 동봉 신뢰 자산이라 임의 PHP 반입 경로가 아니다" 를 근거로
|
||||
* 면제했으나, PO 가 네 경로 일관 적용을 선택했다. 그 결과 설치 권한만 가진 운영자가 활성
|
||||
* 팩을 재설치하면 그 팩이 `installed` 로 내려가고, 다시 켜려면 관리 권한이 필요하다.
|
||||
*
|
||||
* @scenario vector=install_from_bundled, actor_permission=install_only
|
||||
*
|
||||
* @effects install_only_user_cannot_auto_activate
|
||||
*/
|
||||
public function test_bundled_install_blocks_auto_activate_for_install_only_user(): void
|
||||
{
|
||||
Sanctum::actingAs($this->makeUserWithPermissions(['core.language_packs.install']));
|
||||
|
||||
$response = $this->postJson('/api/admin/language-packs/install-from-bundled', [
|
||||
'identifier' => 'g7-core-nonexistent',
|
||||
'auto_activate' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('auto_activate');
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들 설치에서 `auto_activate` 를 보내지 않으면 게이트가 발동하지 않는다.
|
||||
*
|
||||
* 게이트를 `authorize()` 가 아니라 필드 단위 Rule 로 건 이유를 고정한다 — 설치 권한만
|
||||
* 가진 운영자의 평범한 번들 설치까지 403 이 되면 안 된다.
|
||||
*
|
||||
* 존재하지 않는 identifier 라 설치 자체는 실패하지만, 그 실패가 `auto_activate` 검증
|
||||
* 오류가 아니어야 한다는 것이 이 테스트의 단언 대상이다.
|
||||
*
|
||||
* @scenario vector=install_from_bundled, actor_permission=install_only
|
||||
*
|
||||
* @effects install_without_auto_activate_is_not_blocked
|
||||
*/
|
||||
public function test_bundled_install_without_auto_activate_is_not_blocked(): void
|
||||
{
|
||||
Sanctum::actingAs($this->makeUserWithPermissions(['core.language_packs.install']));
|
||||
|
||||
$response = $this->postJson('/api/admin/language-packs/install-from-bundled', [
|
||||
'identifier' => 'g7-core-nonexistent',
|
||||
]);
|
||||
|
||||
$this->assertArrayNotHasKey(
|
||||
'auto_activate',
|
||||
(array) ($response->json('errors') ?? []),
|
||||
'auto_activate 를 보내지도 않은 요청에 활성화 권한 게이트가 발동했다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리 권한을 함께 가진 계정은 번들 재설치에서 `auto_activate` 로 막히지 않는다.
|
||||
*
|
||||
* 게이트가 권한 유무로 갈리는지(무조건 거부가 아닌지) 확인한다.
|
||||
*
|
||||
* @scenario vector=install_from_bundled, actor_permission=install_and_manage
|
||||
*
|
||||
* @effects manage_user_can_auto_activate
|
||||
*/
|
||||
public function test_bundled_install_allows_auto_activate_for_manage_user(): void
|
||||
{
|
||||
Sanctum::actingAs($this->makeUserWithPermissions([
|
||||
'core.language_packs.install',
|
||||
'core.language_packs.manage',
|
||||
]));
|
||||
|
||||
$response = $this->postJson('/api/admin/language-packs/install-from-bundled', [
|
||||
'identifier' => 'g7-core-nonexistent',
|
||||
'auto_activate' => true,
|
||||
]);
|
||||
|
||||
$this->assertArrayNotHasKey(
|
||||
'auto_activate',
|
||||
(array) ($response->json('errors') ?? []),
|
||||
'관리 권한 보유자에게도 활성화 게이트가 걸렸다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* install 권한만 가진 계정은 활성 팩을 덮어쓰는 방식으로도 활성화 게이트를 우회할 수 없다.
|
||||
*
|
||||
* `auto_activate` 에 게이트를 걸면 남는 우회로는 "이미 활성인 팩과 같은 identifier 로
|
||||
* 덮어쓰기" 다. 덮어쓴 내용이 활성 상태를 그대로 물려받는다면 게이트를 우회해
|
||||
* 임의 PHP 가 `require` 경로에 배선된다.
|
||||
*
|
||||
* 설치 트랜잭션이 항상 `installed` 로만 기록하므로, 덮어쓴 팩은 active → installed 로
|
||||
* 강등된다. 겉보기엔 재설치 부작용이지만 이 체인의 마지막 관문을 지키는 안전장치다.
|
||||
*
|
||||
* 이 강등 자체는 이번 수정 이전에도 성립하던 동작이다(예전 코드도 `auto_activate` 가
|
||||
* 없으면 `installed` 로 기록했다). 그런데 활성화 게이트가 이 성질에 의존하게 됐으므로,
|
||||
* "재설치하면 활성 상태가 풀린다" 를 불편으로 보고 되돌리는 순간 게이트가 통째로
|
||||
* 무력화된다. 그래서 테스트로 고정한다.
|
||||
*
|
||||
* @scenario vector=install_from_untrusted_source, actor_permission=install_only
|
||||
*
|
||||
* @effects overwriting_an_active_pack_does_not_inherit_active_status
|
||||
*/
|
||||
public function test_install_only_user_cannot_reactivate_by_overwriting_active_pack(): void
|
||||
{
|
||||
$identifier = 'ovrwrite-core-ja';
|
||||
|
||||
try {
|
||||
// 1) 활성화 권한 보유자가 정상 설치 + 활성화
|
||||
Sanctum::actingAs($this->manager);
|
||||
$this->postJson('/api/admin/language-packs/install-from-file', [
|
||||
'file' => new UploadedFile(
|
||||
$this->makeLanguagePackZip($identifier, '1.0.0', "<?php\nreturn ['greeting' => 'こんにちは'];\n"),
|
||||
'pack.zip', 'application/zip', null, true
|
||||
),
|
||||
'auto_activate' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
$pack = LanguagePack::query()->where('identifier', $identifier)->firstOrFail();
|
||||
$this->assertSame(LanguagePackStatus::Active->value, $pack->status, '전제 조건 — 덮어쓰기 대상은 활성 팩이어야 한다');
|
||||
|
||||
// 2) install 권한만 가진 계정이 같은 identifier 로 덮어쓰기 (auto_activate 미전송)
|
||||
Sanctum::actingAs($this->makeUserWithPermissions(['core.language_packs.install']));
|
||||
$this->postJson('/api/admin/language-packs/install-from-file', [
|
||||
'file' => new UploadedFile(
|
||||
$this->makeLanguagePackZip($identifier, '1.0.1', "<?php\nreturn ['greeting' => 'overwritten'];\n"),
|
||||
'pack.zip', 'application/zip', null, true
|
||||
),
|
||||
])->assertSuccessful();
|
||||
|
||||
$pack->refresh();
|
||||
$this->assertSame('1.0.1', $pack->version, '전제 조건 — 덮어쓰기가 실제로 수행되어야 한다');
|
||||
$this->assertSame(
|
||||
LanguagePackStatus::Installed->value,
|
||||
$pack->status,
|
||||
'덮어쓴 팩이 활성 상태를 물려받았다 — install 권한만으로 활성화 게이트가 우회된다'
|
||||
);
|
||||
} finally {
|
||||
File::deleteDirectory(base_path('lang-packs/'.$identifier));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 ja 언어팩 ZIP 을 임시로 만듭니다.
|
||||
*
|
||||
* @param string $identifier 언어팩 식별자
|
||||
* @param string $version 버전
|
||||
* @param string $translationPhp `backend/ja/messages.php` 로 담을 내용
|
||||
* @return string 생성된 ZIP 절대 경로
|
||||
*/
|
||||
private function makeLanguagePackZip(string $identifier, string $version, string $translationPhp): string
|
||||
{
|
||||
$zipPath = tempnam(sys_get_temp_dir(), 'lp_ctrl_').'.zip';
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('language-pack.json', json_encode([
|
||||
'identifier' => $identifier,
|
||||
'namespace' => 'ovrwrite',
|
||||
'vendor' => 'ovrwrite',
|
||||
'name' => ['ko' => $identifier, 'en' => $identifier],
|
||||
'version' => $version,
|
||||
'scope' => LanguagePackScope::Core->value,
|
||||
'target_identifier' => null,
|
||||
'locale' => 'ja',
|
||||
'locale_native_name' => '日本語',
|
||||
'g7_version' => '>=7.0.0-beta.4',
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
$zip->addFromString('backend/ja/messages.php', $translationPhp);
|
||||
$zip->close();
|
||||
|
||||
return $zipPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 소스 설치 엔드포인트 목록 (유효성만 통과하는 최소 본문).
|
||||
*
|
||||
* @return array<string, array{string, array<string, mixed>}>
|
||||
*/
|
||||
public static function externalInstallEndpointProvider(): array
|
||||
{
|
||||
return [
|
||||
'GitHub' => ['/api/admin/language-packs/install-from-github', [
|
||||
'github_url' => 'https://github.com/gnuboard/g7-lang-nonexistent',
|
||||
]],
|
||||
'URL' => ['/api/admin/language-packs/install-from-url', [
|
||||
'url' => 'https://example.com/pack.zip',
|
||||
]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ use App\Models\Role;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\User;
|
||||
use App\Services\ScheduleService;
|
||||
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
@@ -67,6 +69,10 @@ class ScheduleCommandGuardTest extends TestCase
|
||||
* 저권한 계정의 차단목록 artisan 스케줄 생성은 422 로 거부된다.
|
||||
*
|
||||
* @param string $command 차단목록 artisan command
|
||||
*
|
||||
* @scenario command_class=denylisted, enforcement_point=store
|
||||
*
|
||||
* @effects denylist_blocks_destructive_and_code_executing_commands
|
||||
*/
|
||||
#[DataProvider('deniedArtisanCommandProvider')]
|
||||
public function test_store_rejects_denylisted_artisan_command(string $command): void
|
||||
@@ -105,7 +111,11 @@ class ScheduleCommandGuardTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상 artisan 스케줄 생성은 201 로 허용된다 (기본 허용 정책).
|
||||
* 허용목록에 있는 정상 artisan 스케줄 생성은 201 로 허용된다.
|
||||
*
|
||||
* @scenario command_class=core_allowlisted, enforcement_point=store
|
||||
*
|
||||
* @effects allowlisted_maintenance_commands_still_run
|
||||
*/
|
||||
public function test_store_allows_normal_artisan_command(): void
|
||||
{
|
||||
@@ -169,6 +179,10 @@ class ScheduleCommandGuardTest extends TestCase
|
||||
|
||||
/**
|
||||
* DB 에 직접 심어진 차단목록 artisan 스케줄은 실행 시점에 차단된다.
|
||||
*
|
||||
* @scenario command_class=denylisted, enforcement_point=run
|
||||
*
|
||||
* @effects run_blocks_denylisted_artisan_schedule
|
||||
*/
|
||||
public function test_run_blocks_denylisted_artisan_schedule(): void
|
||||
{
|
||||
@@ -211,10 +225,313 @@ class ScheduleCommandGuardTest extends TestCase
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증기가 본 명령명과 실제 실행되는 명령명이 갈리는 입력은 실행 시점에 차단된다.
|
||||
*
|
||||
* KVE-2026-1679 추가 발견분: 검증기는 `preg_split` 첫 토큰을 명령명으로 보지만,
|
||||
* `Artisan::call($command, [], ...)` 는 parameters 가 비어 있을 때 문자열 전체를
|
||||
* StringInput 으로 재파싱한다. Symfony 가 따옴표·백슬래시를 해석해 차단목록에 있는
|
||||
* `tinker` 가 그대로 실행되므로, 저장·실행 이중 검증이 둘 다 우회된다.
|
||||
*
|
||||
* 실행되었다면 임의 PHP 가 평가되어 마커 파일이 생성되므로, 마커 부재로
|
||||
* 위험 sink 미도달을 증명한다.
|
||||
*
|
||||
* @scenario command_class=parser_divergent, enforcement_point=run
|
||||
*
|
||||
* @effects run_blocks_parser_divergent_artisan_schedule, parser_divergent_payload_never_reaches_artisan
|
||||
*/
|
||||
public function test_run_blocks_parser_divergent_artisan_schedule(): void
|
||||
{
|
||||
$marker = $this->parserDivergenceMarkerPath();
|
||||
|
||||
if (is_file($marker)) {
|
||||
unlink($marker);
|
||||
}
|
||||
|
||||
$schedule = Schedule::create([
|
||||
'name' => 'DB 직접 주입 — 파서 분기',
|
||||
'type' => ScheduleType::Artisan,
|
||||
// 검증기는 `"tinker"` 를 명령명으로 보지만 Symfony 는 `tinker` 로 해석한다.
|
||||
// --execute 값은 마커 경로를 chr() 로 조립해 따옴표 없이 구성한다.
|
||||
'command' => '"tinker" --execute=file_put_contents(sys_get_temp_dir().chr(47).chr(103).chr(55).chr(112).chr(53).chr(50).chr(55),1);',
|
||||
'expression' => '* * * * *',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->assertScheduleRunIsBlocked($schedule);
|
||||
|
||||
$this->assertFileDoesNotExist(
|
||||
$marker,
|
||||
'파서 분기 페이로드가 실행되어 임의 PHP 가 평가됨 — 위험 sink 에 도달했다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용목록에 없는 artisan 명령은 무해하더라도 저장 시점에 거부된다.
|
||||
*
|
||||
* 차단목록 방식에서 허용목록 방식으로 전환했음을 증명하는 대표 테스트다.
|
||||
* `route:list` `about` 은 파괴적이지 않지만 예약 실행 대상이 아니므로 통과하지 않는다.
|
||||
*
|
||||
* @param string $command 허용목록 밖 artisan command
|
||||
*
|
||||
* @scenario command_class=core_not_allowlisted, enforcement_point=store
|
||||
*
|
||||
* @effects store_rejects_artisan_command_outside_allowlist
|
||||
*/
|
||||
#[DataProvider('notAllowlistedArtisanCommandProvider')]
|
||||
public function test_store_rejects_artisan_command_outside_allowlist(string $command): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
|
||||
'name' => '허용목록 밖 명령',
|
||||
'type' => ScheduleType::Artisan->value,
|
||||
'command' => $command,
|
||||
'expression' => '* * * * *',
|
||||
'frequency' => 'everyMinute',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('command');
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증기와 실행부의 해석이 갈리는 입력은 저장 시점에 거부된다.
|
||||
*
|
||||
* @param string $command 파서 분기를 유발하는 command
|
||||
* @param string $symfonyName Symfony 가 실제로 해석하는 명령명
|
||||
*
|
||||
* @scenario command_class=parser_divergent, enforcement_point=store
|
||||
*
|
||||
* @effects store_rejects_parser_divergent_artisan_command
|
||||
*/
|
||||
#[DataProvider('parserDivergentArtisanCommandProvider')]
|
||||
public function test_store_rejects_parser_divergent_artisan_command(string $command, string $symfonyName): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
|
||||
'name' => '파서 분기 위장',
|
||||
'type' => ScheduleType::Artisan->value,
|
||||
'command' => $command,
|
||||
'expression' => '* * * * *',
|
||||
'frequency' => 'everyMinute',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422, "Symfony 가 '{$symfonyName}' 로 해석하는 입력이 저장됨: {$command}");
|
||||
$response->assertJsonValidationErrors('command');
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용목록에 있는 명령이라도 선언되지 않은 옵션이 붙으면 저장 시점에 거부된다.
|
||||
*
|
||||
* 명령명만 막으면 허용된 명령의 위험 옵션이 그대로 남는다.
|
||||
*
|
||||
* @param string $command 미허용 옵션이 붙은 command
|
||||
*
|
||||
* @scenario command_class=core_allowlisted, enforcement_point=store
|
||||
*
|
||||
* @effects store_rejects_allowlisted_command_with_denied_option
|
||||
*/
|
||||
#[DataProvider('deniedOptionArtisanCommandProvider')]
|
||||
public function test_store_rejects_allowlisted_command_with_denied_option(string $command): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
|
||||
'name' => '옵션 우회 시도',
|
||||
'type' => ScheduleType::Artisan->value,
|
||||
'command' => $command,
|
||||
'expression' => '* * * * *',
|
||||
'frequency' => 'everyMinute',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('command');
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용목록이 선언한 옵션은 그대로 저장된다 (정상 운영 경로 비파괴).
|
||||
*
|
||||
* @param string $command 허용 옵션이 붙은 command
|
||||
*
|
||||
* @scenario command_class=core_allowlisted, enforcement_point=store
|
||||
*
|
||||
* @effects store_allows_allowlisted_command_with_allowed_option
|
||||
*/
|
||||
#[DataProvider('allowedOptionArtisanCommandProvider')]
|
||||
public function test_store_allows_allowlisted_command_with_allowed_option(string $command): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
|
||||
'name' => '정상 유지보수 스케줄',
|
||||
'type' => ScheduleType::Artisan->value,
|
||||
'command' => $command,
|
||||
'expression' => '* * * * *',
|
||||
'frequency' => 'everyMinute',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201, "허용되어야 하는 명령이 거부됨: {$command}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 artisan 스케줄의 command 만 바꾸는 update 요청도 저장된 타입으로 폴백 검증된다.
|
||||
*
|
||||
* @scenario command_class=code_generating, enforcement_point=update
|
||||
*
|
||||
* @effects update_falls_back_to_the_stored_type_when_the_request_omits_it
|
||||
*/
|
||||
public function test_update_rejects_artisan_command_outside_allowlist_without_type_in_request(): void
|
||||
{
|
||||
$schedule = Schedule::create([
|
||||
'name' => '기존 artisan 스케줄',
|
||||
'type' => ScheduleType::Artisan,
|
||||
'command' => 'cache:clear',
|
||||
'expression' => '* * * * *',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->authRequest()->putJson(
|
||||
route('api.admin.schedules.update', ['schedule' => $schedule->id]),
|
||||
['command' => 'make:migration evil --path=/tmp/g7 --realpath']
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('command');
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 에 직접 심어진 허용목록 밖 artisan 스케줄은 실행 시점에 차단된다.
|
||||
*
|
||||
* @scenario command_class=core_not_allowlisted, enforcement_point=run
|
||||
*
|
||||
* @effects run_blocks_artisan_schedule_outside_allowlist
|
||||
*/
|
||||
public function test_run_blocks_artisan_schedule_outside_allowlist(): void
|
||||
{
|
||||
$schedule = Schedule::create([
|
||||
'name' => 'DB 직접 주입 — 허용목록 밖',
|
||||
'type' => ScheduleType::Artisan,
|
||||
'command' => 'route:list',
|
||||
'expression' => '* * * * *',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->assertScheduleRunIsBlocked($schedule);
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용된 artisan 스케줄은 문자열이 아니라 파싱된 (명령명, 인자배열) 로 실행된다.
|
||||
*
|
||||
* 인자 배열을 넘기면 Laravel 이 StringInput 대신 ArrayInput 분기를 타므로,
|
||||
* 저장된 문자열이 실행 직전에 재해석되는 지점 자체가 사라진다.
|
||||
* shell 쪽 `Process::assertRan` 인자배열 단언과 대칭이다.
|
||||
*
|
||||
* @scenario command_class=core_allowlisted, enforcement_point=run
|
||||
*
|
||||
* @effects run_dispatches_artisan_command_as_parsed_parameters
|
||||
*/
|
||||
public function test_run_dispatches_artisan_command_as_parsed_parameters(): void
|
||||
{
|
||||
$captured = [];
|
||||
|
||||
// Artisan::fake() 는 파라미터 단언을 제공하지 않으므로 Kernel 을 대역으로 세워
|
||||
// call() 에 넘어간 인자를 그대로 잡는다.
|
||||
$kernel = \Mockery::mock(ConsoleKernel::class)->shouldIgnoreMissing();
|
||||
$kernel->shouldReceive('call')
|
||||
->once()
|
||||
->andReturnUsing(function ($command, $parameters = [], $output = null) use (&$captured) {
|
||||
$captured = ['command' => $command, 'parameters' => $parameters];
|
||||
|
||||
return 0;
|
||||
});
|
||||
$kernel->shouldReceive('output')->andReturn('');
|
||||
|
||||
// 파사드가 이미 해석해 둔 인스턴스까지 교체해야 서비스가 대역을 본다.
|
||||
Artisan::swap($kernel);
|
||||
|
||||
$schedule = Schedule::create([
|
||||
'name' => '큐 처리',
|
||||
'type' => ScheduleType::Artisan,
|
||||
'command' => 'queue:work --stop-when-empty --max-time=300',
|
||||
'expression' => '* * * * *',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
app(ScheduleService::class)->runSchedule($schedule);
|
||||
|
||||
$this->assertSame('queue:work', $captured['command']);
|
||||
$this->assertSame(
|
||||
['--stop-when-empty' => true, '--max-time' => '300'],
|
||||
$captured['parameters'],
|
||||
'실행부가 문자열을 그대로 넘겨 재파싱 지점이 남아 있다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 형식이 어긋난 artisan command 는 저장 시점에 거부된다.
|
||||
*
|
||||
* 따옴표·백슬래시·단축 옵션·중복 옵션은 Symfony 가 다르게 해석할 여지를 만들므로
|
||||
* 명령명이 무엇이든 형태 단계에서 거부한다.
|
||||
*
|
||||
* @param string $command 형식이 어긋난 command
|
||||
*
|
||||
* @scenario command_class=malformed, enforcement_point=store
|
||||
*
|
||||
* @effects store_rejects_malformed_artisan_command
|
||||
*/
|
||||
#[DataProvider('malformedArtisanCommandProvider')]
|
||||
public function test_store_rejects_malformed_artisan_command(string $command): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
|
||||
'name' => '형식 오류',
|
||||
'type' => ScheduleType::Artisan->value,
|
||||
'command' => $command,
|
||||
'expression' => '* * * * *',
|
||||
'frequency' => 'everyMinute',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('command');
|
||||
}
|
||||
|
||||
/**
|
||||
* 저권한 운영자는 보고서의 2단계 RCE 체인(마이그레이션 파일 생성 → 실행)을 등록할 수 없다.
|
||||
*
|
||||
* KVE-2026-1679 보고서 주 시나리오. 1단계 `make:migration ... --path=` 로 공격자 제어
|
||||
* 경로에 PHP 파일을 만들고, 2단계 `migrate --path=` 로 그 파일을 실행하는 체인이다.
|
||||
* 두 단계 모두 저장 시점에 거부되어야 한다.
|
||||
*
|
||||
* @param string $command 체인 단계별 command
|
||||
*
|
||||
* @scenario command_class=code_generating, enforcement_point=store
|
||||
*
|
||||
* @effects low_privilege_operator_cannot_stage_migration_rce_chain
|
||||
*/
|
||||
#[DataProvider('migrationRceChainProvider')]
|
||||
public function test_low_privilege_operator_cannot_stage_migration_rce_chain(string $command): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
|
||||
'name' => '마이그레이션 체인',
|
||||
'type' => ScheduleType::Artisan->value,
|
||||
'command' => $command,
|
||||
'expression' => '* * * * *',
|
||||
'frequency' => 'everyMinute',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('command');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Helpers
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 파서 분기 페이로드가 실행되었을 때 생성되는 마커 파일 경로.
|
||||
*
|
||||
* command 문자열에는 따옴표를 넣을 수 없으므로(Symfony 가 해석해버린다) 페이로드는
|
||||
* 같은 경로를 `chr()` 연결로 조립한다. 두 값이 어긋나면 증명이 성립하지 않으므로
|
||||
* 여기서 한 번만 정의한다.
|
||||
*/
|
||||
private function parserDivergenceMarkerPath(): string
|
||||
{
|
||||
return sys_get_temp_dir().chr(47).chr(103).chr(55).chr(112).chr(53).chr(50).chr(55);
|
||||
}
|
||||
|
||||
/**
|
||||
* runSchedule 이 차단되어 실패 이력으로 기록되는지 단언합니다.
|
||||
*
|
||||
@@ -325,4 +642,94 @@ class ScheduleCommandGuardTest extends TestCase
|
||||
'migrate:fresh 파괴적' => ['migrate:fresh'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 무해하지만 허용목록에 없어 거부되어야 하는 artisan command 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function notAllowlistedArtisanCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'route:list — 정보 노출' => ['route:list'],
|
||||
'about — 환경 정보 노출' => ['about'],
|
||||
'queue:failed' => ['queue:failed'],
|
||||
'storage:link' => ['storage:link'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 보고서의 2단계 마이그레이션 RCE 체인.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function migrationRceChainProvider(): array
|
||||
{
|
||||
return [
|
||||
'1단계 — 임의 경로에 PHP 파일 생성' => ['make:migration evil --create=x --path=/tmp/g7 --realpath'],
|
||||
'2단계 — 생성한 파일 실행' => ['migrate --path=/tmp/g7 --realpath --force'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증기와 Symfony 의 해석이 갈리는 command 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function parserDivergentArtisanCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'큰따옴표 위장' => ['"tinker" --execute=1', 'tinker'],
|
||||
'작은따옴표 위장' => ["'tinker' --execute=1", 'tinker'],
|
||||
'백슬래시 위장' => ['tin\\ker --execute=1', 'tinker'],
|
||||
'선행 옵션으로 명령명 밀어내기' => ['--env=local tinker', 'tinker'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용목록에 있으나 옵션·인자가 거부되어야 하는 command 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function deniedOptionArtisanCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'미허용 옵션' => ['cache:clear --tags=a'],
|
||||
'워커 상주' => ['queue:work --daemon'],
|
||||
'안전장치 무력화' => ['queue:work --force'],
|
||||
'경로 지정' => ['model:prune --path=/tmp/g7'],
|
||||
'단축 옵션' => ['cache:clear -v'],
|
||||
'위치 인자' => ['cache:clear redis'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 형식이 어긋난 command 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function malformedArtisanCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'단축 옵션' => ['cache:clear -v'],
|
||||
'중복 옵션' => ['queue:work --tries=1 --tries=9'],
|
||||
'옵션 구분자 단독' => ['cache:clear --'],
|
||||
'백슬래시' => ['cache\\:clear'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용목록이 선언한 옵션이 붙은 정상 command 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function allowedOptionArtisanCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'옵션 없음' => ['optimize:clear'],
|
||||
'값 있는 옵션' => ['queue:prune-batches --hours=48'],
|
||||
'옵션 2개' => ['queue:work --stop-when-empty --max-time=300'],
|
||||
'SEO 레이아웃 지정' => ['seo:warmup --layout=home'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\G7Testing\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 스케줄 Artisan 허용목록의 "확장 소유 명령" 계층을 검증하기 위한 테스트 전용 명령.
|
||||
*
|
||||
* 확장 소유 판정은 등록된 명령 인스턴스의 클래스 네임스페이스(`Modules\` / `Plugins\`)로
|
||||
* 이뤄지므로, 판정 대상이 되려면 클래스가 그 네임스페이스에 있어야 한다. 설치된 확장에서
|
||||
* 아무 명령이나 골라 쓰면 확장 구성에 따라 테스트가 조용히 skip 되어 계층 자체가
|
||||
* 검증되지 않으므로, 이 픽스처를 런타임에 등록해 결과를 고정한다.
|
||||
*
|
||||
* `Tests\` PSR-4 네임스페이스가 아니므로 오토로드되지 않는다 — 사용처에서 require 한다.
|
||||
*/
|
||||
class FakeExtensionScheduleCommand extends Command
|
||||
{
|
||||
/** @var string 명령 시그니처 ({확장}:{작업} 패턴을 따르지 않는 이름도 허용되는지 함께 확인) */
|
||||
protected $signature = 'g7-testing-fake-extension-command
|
||||
{--scope= : 확장 명령이 자기 정의로 선언한 옵션}';
|
||||
|
||||
/** @var string 명령 설명 */
|
||||
protected $description = '스케줄 허용목록의 확장 계층 검증용 테스트 명령';
|
||||
|
||||
/**
|
||||
* 실행되지 않는 테스트 픽스처입니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Installer;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 인스톨러 실행 바이너리 경로 정책 테스트 (KVE-2026-1677).
|
||||
*
|
||||
* 정책 파일은 의존성이 없어야 한다 — 인스톨러 API(라이브러리 모드)와 설치 워커 양쪽에서
|
||||
* 단독으로 `require_once` 되므로, 이 테스트도 BASE_PATH·lang 스텁 없이 파일만 로드한다.
|
||||
* 로드에 다른 것이 필요해지면 이 테스트가 먼저 깨진다.
|
||||
*
|
||||
* 판정 축:
|
||||
* - 인자 자리 차단 (하이픈 선두 · 인터프리터+임의 스크립트)
|
||||
* - 자리별 비대칭 (PHP 자리는 이름 무제한 / Composer 자리는 이름 형태 제한)
|
||||
* - stat 미사용 (존재하지 않는 절대경로도 형태만 맞으면 통과 — open_basedir 회귀 가드)
|
||||
*/
|
||||
class BinaryPathPolicyTest extends TestCase
|
||||
{
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/binary-path-policy.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* 형태 규칙을 통과해야 하는 토큰.
|
||||
*
|
||||
* @param string $token 경로 토큰
|
||||
* @param string $reason 통과 사유 (실패 메시지용)
|
||||
*
|
||||
* @scenario vector=validatePhpPath, payload_class=normal, expected_outcome=accept_normal
|
||||
*
|
||||
* @effects legitimate_paths_still_reach_exec
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('shapeAllowedProvider')]
|
||||
public function it_accepts_tokens_with_a_valid_shape(string $token, string $reason): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
installer_binary_path_shape_ok($token),
|
||||
"통과해야 하는 경로가 거부됨 ({$reason}): {$token}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 형태 규칙에 걸려야 하는 토큰.
|
||||
*
|
||||
* @param string $token 경로 토큰
|
||||
* @param string $reason 거부 사유 (실패 메시지용)
|
||||
*
|
||||
* @scenario vector=validatePhpPath, payload_class=argument_injection_dash_r, expected_outcome=return_invalid_message
|
||||
*
|
||||
* @effects leading_hyphen_token_is_rejected
|
||||
* @effects php_slot_rejects_leading_hyphen
|
||||
* @effects relative_and_traversal_paths_are_rejected
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('shapeRejectedProvider')]
|
||||
public function it_rejects_tokens_with_an_invalid_shape(string $token, string $reason): void
|
||||
{
|
||||
$this->assertFalse(
|
||||
installer_binary_path_shape_ok($token),
|
||||
"거부해야 하는 경로가 통과함 ({$reason}): {$token}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer 자리는 이름 형태까지 제한한다.
|
||||
*
|
||||
* @param string $token 경로 토큰
|
||||
* @param bool $expected 기대 판정
|
||||
* @param string $reason 사유 (실패 메시지용)
|
||||
*
|
||||
* @scenario vector=validateComposerPath, payload_class=interpreter_plus_script, expected_outcome=return_invalid_message
|
||||
*
|
||||
* @effects getComposerCommand_rejects_interpreter_plus_arbitrary_script
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('composerSlotProvider')]
|
||||
public function it_restricts_the_composer_slot_by_name(string $token, bool $expected, string $reason): void
|
||||
{
|
||||
$this->assertSame(
|
||||
$expected,
|
||||
installer_is_composer_binary_path($token),
|
||||
"Composer 자리 판정이 기대와 다름 ({$reason}): {$token}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP 자리는 이름을 제한하지 않는다.
|
||||
*
|
||||
* 래퍼 스크립트·버전이 붙은 이름·커스텀 이름을 모두 허용해야 한다. 이 자리는
|
||||
* 인자가 `--version` 으로 고정된 실행 파일 자리라 이름 제한이 이번 공격과 무관하고,
|
||||
* 제한하면 설치 환경 다양성을 해친다.
|
||||
*
|
||||
* @param string $token PHP 실행 경로
|
||||
*
|
||||
* @scenario vector=validatePhpPath, payload_class=wrapper_script_name, expected_outcome=accept_normal
|
||||
*
|
||||
* @effects legitimate_paths_still_reach_exec
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('phpSlotNameProvider')]
|
||||
public function it_does_not_restrict_the_php_slot_by_name(string $token): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
installer_binary_path_shape_ok($token),
|
||||
"PHP 자리 이름 제한이 생겨 정상 환경이 거부됨: {$token}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2토큰 입력은 자리별 규칙을 모두 만족할 때만 해석된다.
|
||||
*
|
||||
* @param string $raw 원본 입력
|
||||
* @param array{php: string, composer: string}|null $expected 기대 해석 결과
|
||||
* @param string $reason 사유 (실패 메시지용)
|
||||
*
|
||||
* @scenario vector=validateComposerPath, payload_class=argument_injection_dash_r, expected_outcome=return_invalid_message
|
||||
*
|
||||
* @effects argument_injection_never_reaches_exec
|
||||
* @effects getComposerCommand_keeps_multi_php_pair_support
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('pairProvider')]
|
||||
public function it_resolves_only_well_formed_php_composer_pairs(string $raw, ?array $expected, string $reason): void
|
||||
{
|
||||
$this->assertSame(
|
||||
$expected,
|
||||
installer_resolve_php_composer_pair($raw),
|
||||
"2토큰 해석이 기대와 다름 ({$reason}): {$raw}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 시스템 stat 에 의존하지 않는다 (open_basedir 회귀 가드).
|
||||
*
|
||||
* `#361` 은 `is_file()`/`is_executable()` 가드가 시놀로지 DSM 등에서 정상 절대경로를
|
||||
* false negative 로 거부하는 회귀를 되돌린 이력이다. 존재하지 않는 절대경로가
|
||||
* 형태 검사만으로 통과해야 그 회귀가 구조적으로 재발하지 않는다.
|
||||
*
|
||||
* @scenario vector=getPhpBinary, payload_class=normal, expected_outcome=accept_normal
|
||||
*
|
||||
* @effects getPhpBinary_passes_through_legitimate_paths
|
||||
*/
|
||||
#[Test]
|
||||
public function it_does_not_depend_on_filesystem_stat(): void
|
||||
{
|
||||
$missing = '/usr/local/php99/bin/php';
|
||||
|
||||
$this->assertFileDoesNotExist($missing);
|
||||
$this->assertTrue(installer_binary_path_shape_ok($missing));
|
||||
$this->assertTrue(installer_is_composer_binary_path('/usr/local/php99/bin/composer'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 통과해야 하는 토큰 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function shapeAllowedProvider(): array
|
||||
{
|
||||
return [
|
||||
'bare php' => ['php', '시스템 기본'],
|
||||
'bare composer' => ['composer', '시스템 기본'],
|
||||
'POSIX 절대경로' => ['/usr/bin/php', '일반 설치'],
|
||||
'버전 붙은 이름' => ['/usr/bin/php8.3', '멀티 PHP'],
|
||||
'Plesk 경로' => ['/opt/plesk/php/8.2/bin/php', '패널 환경'],
|
||||
'Windows 드라이브' => ['C:\\php\\php.exe', 'Windows'],
|
||||
'Windows 슬래시' => ['C:/php/php.exe', 'Windows 정방향 슬래시'],
|
||||
'UNC 경로' => ['\\\\server\\share\\php.exe', '네트워크 공유'],
|
||||
'점 포함 디렉토리' => ['/usr/local/php.d/bin/php', '단일 점은 상위 참조가 아님'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 거부해야 하는 토큰 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function shapeRejectedProvider(): array
|
||||
{
|
||||
return [
|
||||
'빈 문자열' => ['', '입력 없음'],
|
||||
'하이픈 선두 (-r)' => ['-rphpinfo();', '코드 실행 플래그 — 이번 취약점'],
|
||||
'하이픈 선두 (-d)' => ['-dauto_prepend_file=/tmp/x', 'ini 주입'],
|
||||
'롱옵션' => ['--define=x', '옵션'],
|
||||
'상대경로' => ['bin/composer', 'CWD 의존'],
|
||||
'단순 이름' => ['evilbinary', 'bare 허용은 php/composer 뿐'],
|
||||
'부모 참조' => ['/usr/local/../../tmp/composer', '.. 세그먼트'],
|
||||
'부모 참조 (윈도우 구분자)' => ['C:\\php\\..\\..\\tmp\\evil.exe', '.. 세그먼트'],
|
||||
'해시 주석' => ['/usr/bin/php#.phar', '접미사 위장'],
|
||||
'공백 포함' => ['/usr/bin/my php', '토큰 분리 불가'],
|
||||
'세미콜론' => ['/usr/bin/php;id', '셸 메타문자'],
|
||||
'백틱' => ['/usr/bin/`id`', '셸 메타문자'],
|
||||
'달러 괄호' => ['/usr/bin/$(id)', '셸 메타문자'],
|
||||
'따옴표' => ['/usr/bin/"php"', '셸 메타문자'],
|
||||
'개행' => ["/usr/bin/php\nid", '제어문자'],
|
||||
'NUL 바이트' => ["/usr/bin/php\x00", '제어문자'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer 자리 판정 목록.
|
||||
*
|
||||
* @return array<string, array{string, bool, string}>
|
||||
*/
|
||||
public static function composerSlotProvider(): array
|
||||
{
|
||||
return [
|
||||
'composer' => ['composer', true, 'bare 기본'],
|
||||
'절대경로 composer' => ['/usr/local/bin/composer', true, '일반 설치'],
|
||||
'composer2' => ['/usr/local/bin/composer2', true, '버전 붙은 이름'],
|
||||
'composer.phar' => ['/opt/composer.phar', true, 'phar 아카이브'],
|
||||
'임의 이름 phar' => ['/opt/tools/mytool.phar', true, 'phar 는 이름 무관 허용'],
|
||||
'composer.bat' => ['C:\\laragon\\bin\\composer\\composer.bat', true, 'Windows 배치'],
|
||||
'composer.exe' => ['C:\\tools\\composer.exe', true, 'Windows 실행 파일'],
|
||||
'임의 스크립트' => ['/tmp/evil.py', false, '인터프리터가 실행할 임의 스크립트 — 이번 취약점'],
|
||||
'임의 PHP 스크립트' => ['/tmp/evil.php', false, '임의 PHP 스크립트'],
|
||||
'셸 스크립트' => ['/tmp/evil.sh', false, '임의 스크립트'],
|
||||
'composer 접두 위장' => ['/tmp/composer-evil.sh', false, 'basename 완전 일치가 아님'],
|
||||
'하이픈 선두' => ['-rphpinfo();', false, '옵션'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP 자리에 허용되어야 하는 이름 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function phpSlotNameProvider(): array
|
||||
{
|
||||
return [
|
||||
'래퍼 스크립트' => ['/usr/local/bin/php-8.2-wrapper.sh'],
|
||||
'커스텀 이름' => ['/opt/mycompany/run-php'],
|
||||
'버전 접미사' => ['/usr/bin/php8.3'],
|
||||
'DSM 경로' => ['/usr/local/php84/bin/php'],
|
||||
'Windows exe' => ['C:\\php\\php.exe'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 2토큰 해석 목록.
|
||||
*
|
||||
* @return array<string, array{string, array{php: string, composer: string}|null, string}>
|
||||
*/
|
||||
public static function pairProvider(): array
|
||||
{
|
||||
return [
|
||||
'멀티 PHP 정상' => [
|
||||
'/usr/local/php84/bin/php /usr/local/bin/composer.phar',
|
||||
['php' => '/usr/local/php84/bin/php', 'composer' => '/usr/local/bin/composer.phar'],
|
||||
'운영 패턴',
|
||||
],
|
||||
'래퍼 + composer' => [
|
||||
'/usr/local/bin/php-8.2-wrapper.sh /usr/local/bin/composer',
|
||||
['php' => '/usr/local/bin/php-8.2-wrapper.sh', 'composer' => '/usr/local/bin/composer'],
|
||||
'PHP 자리 이름 무제한',
|
||||
],
|
||||
'Windows 2토큰' => [
|
||||
'C:\\php\\php.exe D:\\composer\\composer.phar',
|
||||
['php' => 'C:\\php\\php.exe', 'composer' => 'D:\\composer\\composer.phar'],
|
||||
'Windows',
|
||||
],
|
||||
'인자 주입 (-r)' => ['/usr/bin/php -rphpinfo();', null, '둘째 토큰이 코드 실행 플래그'],
|
||||
'인터프리터 + 스크립트' => ['/usr/bin/python3 /tmp/evil.py', null, '둘째 토큰이 임의 스크립트'],
|
||||
'PHP 자리 옵션' => ['-rphpinfo(); /usr/bin/composer', null, '첫 토큰이 옵션'],
|
||||
'공백 없음' => ['/usr/bin/composer', null, '2토큰 입력이 아님'],
|
||||
'공백 포함 디렉토리' => [
|
||||
'C:\\Program Files\\PHP\\php.exe C:\\composer\\composer.phar',
|
||||
null,
|
||||
'#361 이 명시한 기존 known_limitation — 악화가 아님',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Unit\Installer;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use ValidationApi;
|
||||
@@ -30,7 +31,7 @@ class ComposerPathValidationTest extends TestCase
|
||||
|
||||
// ValidationApi 는 글로벌 네임스페이스이므로 \lang() 으로 조회됨 →
|
||||
// 글로벌 스코프에 정의된 스텁이 필요.
|
||||
require_once __DIR__ . '/stubs/lang_stub.php';
|
||||
require_once __DIR__.'/stubs/lang_stub.php';
|
||||
|
||||
if (! defined('MIN_PHP_VERSION')) {
|
||||
define('MIN_PHP_VERSION', '8.2.0');
|
||||
@@ -48,14 +49,14 @@ class ComposerPathValidationTest extends TestCase
|
||||
define('BASE_PATH', $projectRoot);
|
||||
}
|
||||
if (! defined('STATE_PATH')) {
|
||||
define('STATE_PATH', BASE_PATH . '/storage/installer-state.json');
|
||||
define('STATE_PATH', BASE_PATH.'/storage/installer-state.json');
|
||||
}
|
||||
|
||||
require_once $projectRoot . '/public/install/api/check-configuration.php';
|
||||
require_once $projectRoot.'/public/install/api/check-configuration.php';
|
||||
// applyInstallerComposerEnvVars() 는 functions.php 에 정의. stat 가드 완화 회복
|
||||
// 이후 시스템 기본 'composer' 또는 정상 절대경로 입력이 exec 단계까지 도달하면서
|
||||
// 이 함수를 호출하므로 사전 로드 필요.
|
||||
require_once $projectRoot . '/public/install/includes/functions.php';
|
||||
require_once $projectRoot.'/public/install/includes/functions.php';
|
||||
self::$loaded = true;
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ class ComposerPathValidationTest extends TestCase
|
||||
*/
|
||||
private function invoke(string $method, array $args): array
|
||||
{
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
$ref = new ReflectionClass($api);
|
||||
$m = $ref->getMethod($method);
|
||||
|
||||
@@ -74,8 +75,8 @@ class ComposerPathValidationTest extends TestCase
|
||||
|
||||
public function test_composer_path_with_shell_injection_is_rejected_before_exec(): void
|
||||
{
|
||||
$marker = sys_get_temp_dir() . '/g7_kve_test_' . bin2hex(random_bytes(4));
|
||||
$payload = 'sh -c "id > ' . $marker . '"';
|
||||
$marker = sys_get_temp_dir().'/g7_kve_test_'.bin2hex(random_bytes(4));
|
||||
$payload = 'sh -c "id > '.$marker.'"';
|
||||
|
||||
$result = $this->invoke('validateComposerPath', [$payload, 'php']);
|
||||
|
||||
@@ -86,8 +87,8 @@ class ComposerPathValidationTest extends TestCase
|
||||
|
||||
public function test_composer_path_with_semicolon_injection_is_rejected(): void
|
||||
{
|
||||
$marker = sys_get_temp_dir() . '/g7_kve_test_' . bin2hex(random_bytes(4));
|
||||
$payload = '/bin/sh; touch ' . $marker;
|
||||
$marker = sys_get_temp_dir().'/g7_kve_test_'.bin2hex(random_bytes(4));
|
||||
$payload = '/bin/sh; touch '.$marker;
|
||||
|
||||
$result = $this->invoke('validateComposerPath', [$payload, 'php']);
|
||||
|
||||
@@ -112,14 +113,14 @@ class ComposerPathValidationTest extends TestCase
|
||||
|
||||
public function test_phar_path_with_invalid_php_path_is_rejected_before_exec(): void
|
||||
{
|
||||
$marker = sys_get_temp_dir() . '/g7_kve_test_' . bin2hex(random_bytes(4));
|
||||
$marker = sys_get_temp_dir().'/g7_kve_test_'.bin2hex(random_bytes(4));
|
||||
|
||||
$tmpPhar = tempnam(sys_get_temp_dir(), 'g7-fake-composer-') . '.phar';
|
||||
$tmpPhar = tempnam(sys_get_temp_dir(), 'g7-fake-composer-').'.phar';
|
||||
file_put_contents($tmpPhar, '');
|
||||
chmod($tmpPhar, 0755);
|
||||
|
||||
try {
|
||||
$payload = 'sh -c "id > ' . $marker . '"';
|
||||
$payload = 'sh -c "id > '.$marker.'"';
|
||||
$result = $this->invoke('validateComposerPath', [$tmpPhar, $payload]);
|
||||
|
||||
$this->assertFalse($result['valid']);
|
||||
@@ -131,8 +132,8 @@ class ComposerPathValidationTest extends TestCase
|
||||
|
||||
public function test_php_path_with_shell_injection_is_rejected_before_exec(): void
|
||||
{
|
||||
$marker = sys_get_temp_dir() . '/g7_kve_test_' . bin2hex(random_bytes(4));
|
||||
$payload = 'sh -c "id > ' . $marker . '"';
|
||||
$marker = sys_get_temp_dir().'/g7_kve_test_'.bin2hex(random_bytes(4));
|
||||
$payload = 'sh -c "id > '.$marker.'"';
|
||||
|
||||
$result = $this->invoke('validatePhpPath', [$payload]);
|
||||
|
||||
@@ -161,4 +162,171 @@ class ComposerPathValidationTest extends TestCase
|
||||
// shell injection 메시지 패턴이 들어가지 않는지 확인.
|
||||
$this->assertIsString($result['message']);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 인자 주입 (KVE-2026-1677)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 둘째 토큰이 임의 스크립트면 exec 에 도달하지 않는다.
|
||||
*
|
||||
* KVE-2026-1677 의 실제 실행 경로다. escapeshellarg 는 "이 문자열이 하나의 인자" 임만
|
||||
* 보장할 뿐, 그 인자가 실행 파일인지 인터프리터가 실행할 스크립트인지는 보장하지 않는다.
|
||||
* 기존 문자 블랙리스트는 `-` `(` `)` `.` `/` 를 모두 허용해 인자 자리를 열어 두었다.
|
||||
*
|
||||
* 실측 근거: 명령 끝에 붙는 ` --version` 은 스크립트 인자로 넘어갈 뿐이라
|
||||
* `php <스크립트> --version` 은 그 스크립트를 그대로 실행한다. 실행되면 마커가
|
||||
* 만들어지므로 마커 부재가 곧 미도달의 증거다.
|
||||
*
|
||||
* @scenario vector=validateComposerPath, payload_class=interpreter_plus_script
|
||||
*
|
||||
* @effects argument_injection_never_reaches_exec
|
||||
*/
|
||||
public function test_interpreter_with_arbitrary_script_never_reaches_exec(): void
|
||||
{
|
||||
$marker = $this->argumentInjectionMarkerPath();
|
||||
@unlink($marker);
|
||||
|
||||
// 실행 파일 자리에는 이 환경에 실제로 존재하는 PHP 를 쓴다 — 검증을 통과하면
|
||||
// 실제로 실행되므로 "환경에 없어서 실패" 로 초록이 되는 가짜 통과를 배제한다.
|
||||
$script = sys_get_temp_dir().DIRECTORY_SEPARATOR.'g7i527_evil.php';
|
||||
file_put_contents(
|
||||
$script,
|
||||
'<?php file_put_contents('.var_export($marker, true).", '1');"
|
||||
);
|
||||
|
||||
try {
|
||||
$result = $this->invoke('validateComposerPath', [PHP_BINARY.' '.$script, 'php']);
|
||||
|
||||
$this->assertFalse($result['valid'], '인자 자리에 임의 스크립트를 넣은 입력이 유효로 판정됨');
|
||||
$this->assertFileDoesNotExist($marker, '페이로드가 exec 에 도달해 임의 스크립트가 실행됨');
|
||||
} finally {
|
||||
@unlink($marker);
|
||||
@unlink($script);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 둘째 토큰이 코드 실행 플래그면 형태 검사에서 거부된다.
|
||||
*
|
||||
* 이 호출 지점은 명령 끝에 ` --version` 을 붙이는데, PHP CLI 는 `--version` 을 만나면
|
||||
* 버전만 출력하고 종료하므로 `-r` 코드가 실행되지 않는다(실측 확인). 즉 여기서는
|
||||
* 실행까지 가지 않지만, ` --version` 을 붙이지 않는 호출 지점(설치 워커의 실제 명령
|
||||
* 실행)에서는 그대로 실행된다. 따라서 자리 자체를 닫아 두 지점을 같은 규칙으로 막는다.
|
||||
*
|
||||
* @scenario vector=validateComposerPath, payload_class=argument_injection_dash_r
|
||||
*
|
||||
* @effects leading_hyphen_token_is_rejected
|
||||
*/
|
||||
public function test_leading_hyphen_token_is_rejected_in_the_argument_slot(): void
|
||||
{
|
||||
$result = $this->invoke('validateComposerPath', [PHP_BINARY.' -rphpinfo();', 'php']);
|
||||
|
||||
$this->assertFalse($result['valid'], '하이픈 선두 인자가 유효로 판정됨');
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP 자리(첫 토큰·단일 토큰)에도 하이픈 선두 토큰은 올 수 없다.
|
||||
*
|
||||
* @scenario vector=validatePhpPath, payload_class=argument_injection_dash_r
|
||||
*
|
||||
* @effects php_slot_rejects_leading_hyphen
|
||||
*/
|
||||
public function test_php_path_rejects_leading_hyphen_token(): void
|
||||
{
|
||||
$result = $this->invoke('validatePhpPath', ['-rphpinfo();']);
|
||||
|
||||
$this->assertFalse($result['valid']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상대경로·`..` 세그먼트는 거부된다 (CWD 의존 실행 차단).
|
||||
*
|
||||
* @param string $payload 거부되어야 하는 경로
|
||||
* @param string $reason 거부 사유 (실패 메시지용)
|
||||
*
|
||||
* @scenario vector=validateComposerPath, payload_class=relative_path
|
||||
*
|
||||
* @effects relative_and_traversal_paths_are_rejected
|
||||
*/
|
||||
#[DataProvider('rejectedShapeProvider')]
|
||||
public function test_rejects_paths_outside_the_allowed_shape(string $payload, string $reason): void
|
||||
{
|
||||
$result = $this->invoke('validateComposerPath', [$payload, 'php']);
|
||||
|
||||
$this->assertFalse($result['valid'], "거부되어야 하는 입력이 통과함 ({$reason}): {$payload}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상 입력은 계속 통과한다 (stat 미사용 — open_basedir 환경 회귀 가드).
|
||||
*
|
||||
* 존재하지 않는 절대경로도 사전 거부되지 않고 exec 까지 도달해야 한다. `#361` 이
|
||||
* `is_file()`/`is_executable()` 가드로 시놀로지 DSM 등에서 정상 경로를 false negative
|
||||
* 로 거부한 회귀를 되돌린 이력이 있다.
|
||||
*
|
||||
* @param string $payload 통과해야 하는 경로
|
||||
* @param string $reason 통과 사유 (실패 메시지용)
|
||||
*
|
||||
* @scenario vector=validateComposerPath, payload_class=wrapper_script_name
|
||||
*
|
||||
* @effects legitimate_paths_still_reach_exec
|
||||
*/
|
||||
#[DataProvider('acceptedShapeProvider')]
|
||||
public function test_legitimate_paths_are_not_rejected_before_exec(string $payload, string $reason): void
|
||||
{
|
||||
$result = $this->invoke('validateComposerPath', [$payload, 'php']);
|
||||
|
||||
// 실행 결과는 환경 의존이므로 "사전 거부 메시지" 가 아닌지로 판정한다.
|
||||
$this->assertStringNotContainsString(
|
||||
'not_allowed',
|
||||
(string) $result['message'],
|
||||
"정상 입력이 형태 검사에서 사전 거부됨 ({$reason}): {$payload}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 형태 규칙에 걸려야 하는 입력 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function rejectedShapeProvider(): array
|
||||
{
|
||||
return [
|
||||
'상대경로' => ['bin/composer', 'bare 명령이 아닌 상대경로'],
|
||||
'부모 참조' => ['/usr/local/../../tmp/composer', '.. 세그먼트'],
|
||||
'해시 주석 위장' => ['-rphpinfo()#.phar', '# + 하이픈 선두'],
|
||||
'인터프리터 + 스크립트' => ['/usr/bin/python3 /tmp/evil.py', 'Composer 자리 형태 위반'],
|
||||
'Composer 자리 임의 이름' => ['/usr/bin/php /tmp/evil.sh', 'composer 계열도 .phar 도 아님'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 형태 규칙을 통과해야 하는 정상 입력 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function acceptedShapeProvider(): array
|
||||
{
|
||||
return [
|
||||
'bare composer' => ['composer', '시스템 기본'],
|
||||
'절대경로 composer' => ['/usr/local/bin/composer', '일반 설치'],
|
||||
'버전 붙은 이름' => ['/usr/local/bin/composer2', 'composer 계열'],
|
||||
'phar' => ['/opt/composer.phar', 'phar 아카이브'],
|
||||
'Windows bat' => ['C:\\laragon\\bin\\composer\\composer.bat', 'Windows 배치'],
|
||||
'존재하지 않는 절대경로' => ['/usr/local/php99/bin/composer', 'stat 미사용 회귀 가드'],
|
||||
'2토큰 멀티 PHP' => ['/usr/local/php84/bin/php /usr/local/bin/composer.phar', '멀티 PHP 운영 패턴'],
|
||||
'래퍼 스크립트 PHP' => ['/usr/local/bin/php-8.2-wrapper.sh /usr/local/bin/composer.phar', 'PHP 자리 이름 무제한'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 인자 주입 페이로드가 실행되었을 때 만들어지는 마커 경로.
|
||||
*
|
||||
* 페이로드에는 따옴표를 넣을 수 없어(셸 메타문자 차단) 같은 경로를 `chr()` 연결로
|
||||
* 조립한다. 두 값이 어긋나면 증명이 성립하지 않으므로 여기서 한 번만 정의한다.
|
||||
*/
|
||||
private function argumentInjectionMarkerPath(): string
|
||||
{
|
||||
return sys_get_temp_dir().chr(47).chr(103).chr(55).chr(105).chr(53).chr(50).chr(55);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ use ValidationApi;
|
||||
class InstallerSecurityHardeningTest extends TestCase
|
||||
{
|
||||
private static bool $loaded = false;
|
||||
|
||||
private static string $tempBase = '';
|
||||
|
||||
private static string $skipReason = '';
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
@@ -28,7 +30,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
return;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/stubs/lang_stub.php';
|
||||
require_once __DIR__.'/stubs/lang_stub.php';
|
||||
|
||||
if (! defined('MIN_PHP_VERSION')) {
|
||||
define('MIN_PHP_VERSION', '8.2.0');
|
||||
@@ -45,11 +47,11 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
define('BASE_PATH', $projectRoot);
|
||||
}
|
||||
// STATE_PATH 는 테스트 격리용 임시 경로로 분리 — 운영 storage/installer-state.json 미오염.
|
||||
self::$tempBase = sys_get_temp_dir() . '/g7-installer-hardening-' . bin2hex(random_bytes(4));
|
||||
@mkdir(self::$tempBase . '/storage/app', 0755, true);
|
||||
self::$tempBase = sys_get_temp_dir().'/g7-installer-hardening-'.bin2hex(random_bytes(4));
|
||||
@mkdir(self::$tempBase.'/storage/app', 0755, true);
|
||||
|
||||
if (! defined('STATE_PATH')) {
|
||||
define('STATE_PATH', self::$tempBase . '/storage/installer-state.json');
|
||||
define('STATE_PATH', self::$tempBase.'/storage/installer-state.json');
|
||||
}
|
||||
|
||||
// 안전 가드: STATE_PATH 는 PHP 상수라 한 프로세스에서 단 한 번만 정의된다.
|
||||
@@ -59,18 +61,18 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
|
||||
$resolvedState = realpath(dirname((string) STATE_PATH)) ?: dirname((string) STATE_PATH);
|
||||
if (strpos($resolvedState, $tempPrefix) !== 0) {
|
||||
self::$skipReason = 'STATE_PATH (' . STATE_PATH . ') 가 시스템 temp 하위가 아님 — '
|
||||
. '다른 Installer 테스트의 상수 정의가 선행됨. 실제 installer-state.json 파괴 방지를 위해 skip. '
|
||||
. '격리 실행: php vendor/bin/phpunit --filter=InstallerSecurityHardeningTest';
|
||||
self::$skipReason = 'STATE_PATH ('.STATE_PATH.') 가 시스템 temp 하위가 아님 — '
|
||||
.'다른 Installer 테스트의 상수 정의가 선행됨. 실제 installer-state.json 파괴 방지를 위해 skip. '
|
||||
.'격리 실행: php vendor/bin/phpunit --filter=InstallerSecurityHardeningTest';
|
||||
self::$loaded = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
require_once $projectRoot . '/public/install/api/check-configuration.php';
|
||||
require_once $projectRoot . '/public/install/includes/installer-state.php';
|
||||
require_once $projectRoot . '/public/install/includes/functions.php';
|
||||
require_once $projectRoot . '/public/install/includes/task-runner.php';
|
||||
require_once $projectRoot.'/public/install/api/check-configuration.php';
|
||||
require_once $projectRoot.'/public/install/includes/installer-state.php';
|
||||
require_once $projectRoot.'/public/install/includes/functions.php';
|
||||
require_once $projectRoot.'/public/install/includes/task-runner.php';
|
||||
|
||||
self::$loaded = true;
|
||||
}
|
||||
@@ -88,6 +90,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
{
|
||||
$ref = new ReflectionClass($obj);
|
||||
$m = $ref->getMethod($method);
|
||||
|
||||
return $m->invokeArgs($obj, $args);
|
||||
}
|
||||
|
||||
@@ -123,7 +126,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
// Critical-1 — getComposerCommand 공백 분기 RCE 차단
|
||||
// ========================================================================
|
||||
|
||||
public function test_getComposerCommand_rejects_space_containing_input_falls_back_to_composer(): void
|
||||
public function test_get_composer_command_rejects_space_containing_input_falls_back_to_composer(): void
|
||||
{
|
||||
$this->writeState(['composer_binary' => 'sh -c "id > /tmp/g7_should_not_run"']);
|
||||
|
||||
@@ -133,7 +136,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$this->clearState();
|
||||
}
|
||||
|
||||
public function test_getComposerCommand_rejects_shell_metachars_falls_back_to_composer(): void
|
||||
public function test_get_composer_command_rejects_shell_metachars_falls_back_to_composer(): void
|
||||
{
|
||||
foreach ([
|
||||
'/bin/sh; touch /tmp/x',
|
||||
@@ -149,7 +152,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$this->clearState();
|
||||
}
|
||||
|
||||
public function test_getComposerCommand_passes_through_safe_absolute_path(): void
|
||||
public function test_get_composer_command_passes_through_safe_absolute_path(): void
|
||||
{
|
||||
// open_basedir 같은 PHP 런타임 제약 환경의 false negative 를 피하기 위해
|
||||
// stat 의존 가드가 제거됨. 메타문자 없는 단일 절대경로는 escape 후 그대로 사용.
|
||||
@@ -162,7 +165,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$this->clearState();
|
||||
}
|
||||
|
||||
public function test_getComposerCommand_empty_returns_default(): void
|
||||
public function test_get_composer_command_empty_returns_default(): void
|
||||
{
|
||||
$this->writeState(['composer_binary' => '']);
|
||||
|
||||
@@ -170,7 +173,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$this->clearState();
|
||||
}
|
||||
|
||||
public function test_getComposerCommandForDisplay_does_not_leak_shell_payload(): void
|
||||
public function test_get_composer_command_for_display_does_not_leak_shell_payload(): void
|
||||
{
|
||||
$this->writeState(['composer_binary' => 'rm -rf /']);
|
||||
|
||||
@@ -181,15 +184,15 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$this->clearState();
|
||||
}
|
||||
|
||||
public function test_isInstallerExecutablePath_rejects_metachars(): void
|
||||
public function test_is_installer_executable_path_rejects_metachars(): void
|
||||
{
|
||||
// 백슬래시는 Windows 경로 구분자이므로 차단 대상 아님 (escapeshellarg 가 셸 인젝션 차단)
|
||||
foreach (['foo bar', 'a;b', 'a`b', 'a$b', 'a|b', "a\nb", 'a"b', "a'b", "a\0b", "a\x01b", "a\rb"] as $bad) {
|
||||
$this->assertFalse(isInstallerExecutablePath($bad), "메타문자 거부: " . bin2hex($bad));
|
||||
$this->assertFalse(isInstallerExecutablePath($bad), '메타문자 거부: '.bin2hex($bad));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_isInstallerExecutablePath_accepts_windows_paths(): void
|
||||
public function test_is_installer_executable_path_accepts_windows_paths(): void
|
||||
{
|
||||
// Windows 절대경로는 백슬래시 포함이지만 공백·메타문자 없으면 통과.
|
||||
// 공백 포함 디렉토리(예: 'C:\\Program Files\\...') 는 공백 분리 입력 형식의 토큰
|
||||
@@ -204,7 +207,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_isInstallerExecutablePath_rejects_windows_path_with_space_known_limitation(): void
|
||||
public function test_is_installer_executable_path_rejects_windows_path_with_space_known_limitation(): void
|
||||
{
|
||||
// 'C:\\Program Files\\...' 같은 공백 포함 Windows 경로는 본 회복 범위 외.
|
||||
// 공백 분리 입력(PHP + Composer 합성) 형식 휴리스틱과 충돌 — escapeshellarg 단일 토큰
|
||||
@@ -221,7 +224,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
* stat 가드가 제거되어 시놀로지 DSM 등 open_basedir 환경에서도
|
||||
* 정상 절대경로 입력이 통과해야 함. 실제 실행 가능 여부는 exec 결과로 판정.
|
||||
*/
|
||||
public function test_isInstallerExecutablePath_accepts_safe_absolute_path_without_stat(): void
|
||||
public function test_is_installer_executable_path_accepts_safe_absolute_path_without_stat(): void
|
||||
{
|
||||
// 실제로 존재하지 않는 경로지만 메타문자 없음 — stat 가드 제거로 true
|
||||
$this->assertTrue(isInstallerExecutablePath('/usr/local/bin/php83'));
|
||||
@@ -233,7 +236,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
// Composer 공백 분리 입력 안전 복원 — 멀티 PHP 환경 호환성
|
||||
// ========================================================================
|
||||
|
||||
public function test_getComposerCommand_accepts_php_composer_space_separated_input(): void
|
||||
public function test_get_composer_command_accepts_php_composer_space_separated_input(): void
|
||||
{
|
||||
// 시놀로지/cPanel/Plesk 멀티 PHP 환경의 운영 패턴
|
||||
$this->writeState(['composer_binary' => '/usr/local/bin/php83 /usr/local/bin/composer']);
|
||||
@@ -241,12 +244,12 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$cmd = getComposerCommand();
|
||||
|
||||
// 두 토큰 각각 escapeshellarg 적용 후 공백으로 합성
|
||||
$expected = escapeshellarg('/usr/local/bin/php83') . ' ' . escapeshellarg('/usr/local/bin/composer');
|
||||
$expected = escapeshellarg('/usr/local/bin/php83').' '.escapeshellarg('/usr/local/bin/composer');
|
||||
$this->assertSame($expected, $cmd);
|
||||
$this->clearState();
|
||||
}
|
||||
|
||||
public function test_getComposerCommand_rejects_space_separated_with_metachar_in_first_token(): void
|
||||
public function test_get_composer_command_rejects_space_separated_with_metachar_in_first_token(): void
|
||||
{
|
||||
// 첫 토큰에 메타문자 → 거부, composer 폴백
|
||||
$this->writeState(['composer_binary' => '/usr/local/bin/php$(id) /usr/local/bin/composer']);
|
||||
@@ -257,7 +260,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$this->clearState();
|
||||
}
|
||||
|
||||
public function test_getComposerCommand_rejects_space_separated_with_metachar_in_second_token(): void
|
||||
public function test_get_composer_command_rejects_space_separated_with_metachar_in_second_token(): void
|
||||
{
|
||||
// 두 번째 토큰에 메타문자 → 거부, composer 폴백
|
||||
$this->writeState(['composer_binary' => '/usr/local/bin/php83 /usr/local/bin/composer;id']);
|
||||
@@ -268,7 +271,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$this->clearState();
|
||||
}
|
||||
|
||||
public function test_getComposerCommand_rejects_three_or_more_tokens(): void
|
||||
public function test_get_composer_command_rejects_three_or_more_tokens(): void
|
||||
{
|
||||
// 3 토큰 이상은 두 번째 토큰에 공백이 남음 → 두 번째 토큰의 메타문자(공백) 로 거부
|
||||
$this->writeState(['composer_binary' => '/bin/php /bin/composer extra_arg']);
|
||||
@@ -279,7 +282,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$this->clearState();
|
||||
}
|
||||
|
||||
public function test_getComposerCommandForDisplay_shows_human_friendly_space_separated_form(): void
|
||||
public function test_get_composer_command_for_display_shows_human_friendly_space_separated_form(): void
|
||||
{
|
||||
$this->writeState(['composer_binary' => '/usr/local/bin/php83 /usr/local/bin/composer']);
|
||||
|
||||
@@ -290,7 +293,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$this->clearState();
|
||||
}
|
||||
|
||||
public function test_getComposerCommandForDisplay_does_not_leak_shell_payload_in_space_separated(): void
|
||||
public function test_get_composer_command_for_display_does_not_leak_shell_payload_in_space_separated(): void
|
||||
{
|
||||
// 두 토큰 중 하나라도 메타문자 포함이면 display 도 composer 폴백
|
||||
$this->writeState(['composer_binary' => '/bin/php `id`']);
|
||||
@@ -306,9 +309,9 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
// validatePhpPath / validateComposerPath stat 가드 완화 (인스톨러 검증 API)
|
||||
// ========================================================================
|
||||
|
||||
public function test_validatePhpPath_rejects_shell_metachars(): void
|
||||
public function test_validate_php_path_rejects_shell_metachars(): void
|
||||
{
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
foreach ([
|
||||
'/usr/local/bin/php; id',
|
||||
'/usr/local/bin/php$(id)',
|
||||
@@ -321,28 +324,28 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_validatePhpPath_rejects_empty(): void
|
||||
public function test_validate_php_path_rejects_empty(): void
|
||||
{
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
$result = $this->invokePrivate($api, 'validatePhpPath', ['']);
|
||||
$this->assertFalse($result['valid']);
|
||||
}
|
||||
|
||||
public function test_validatePhpPath_safe_path_reaches_exec_phase(): void
|
||||
public function test_validate_php_path_safe_path_reaches_exec_phase(): void
|
||||
{
|
||||
// 메타문자 없는 절대경로는 stat 가드 없이 exec 단계까지 도달.
|
||||
// 존재하지 않으면 exec 실패 (return code != 0) 로 거부 — 단, 거부 메시지는
|
||||
// 'error_php_exec_failed' 로 stat 거부와 동일. 핵심: stat 가드가 사라졌다는 점.
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
$result = $this->invokePrivate($api, 'validatePhpPath', ['/nonexistent/path/to/php']);
|
||||
|
||||
// 실제 실행 실패로 invalid 이지만, 그 판정이 exec 결과에 기반함을 의미적으로 검증.
|
||||
$this->assertFalse($result['valid']);
|
||||
}
|
||||
|
||||
public function test_validateComposerPath_rejects_shell_metachars(): void
|
||||
public function test_validate_composer_path_rejects_shell_metachars(): void
|
||||
{
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
foreach ([
|
||||
'/usr/local/bin/composer; id',
|
||||
'/usr/local/bin/composer$(id)',
|
||||
@@ -354,10 +357,10 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_validateComposerPath_rejects_space_separated_with_metachar(): void
|
||||
public function test_validate_composer_path_rejects_space_separated_with_metachar(): void
|
||||
{
|
||||
// 공백 분리 입력의 토큰별 메타문자 검증
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
foreach ([
|
||||
'/bin/php$(id) /bin/composer',
|
||||
'/bin/php /bin/composer;id',
|
||||
@@ -369,23 +372,23 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_splitPhpComposerTokens_helper_splits_into_two_tokens(): void
|
||||
public function test_split_php_composer_tokens_helper_splits_into_two_tokens(): void
|
||||
{
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
$result = $this->invokePrivate($api, 'splitPhpComposerTokens', ['/usr/local/bin/php83 /usr/local/bin/composer']);
|
||||
$this->assertSame(['php' => '/usr/local/bin/php83', 'composer' => '/usr/local/bin/composer'], $result);
|
||||
}
|
||||
|
||||
public function test_splitPhpComposerTokens_helper_returns_null_for_single_token(): void
|
||||
public function test_split_php_composer_tokens_helper_returns_null_for_single_token(): void
|
||||
{
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
$result = $this->invokePrivate($api, 'splitPhpComposerTokens', ['/usr/local/bin/composer']);
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function test_isInstallerSafePathArg_helper_accepts_safe_paths(): void
|
||||
public function test_is_installer_safe_path_arg_helper_accepts_safe_paths(): void
|
||||
{
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
|
||||
foreach ([
|
||||
'/usr/local/bin/php83',
|
||||
@@ -401,14 +404,14 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_isInstallerSafePathArg_helper_rejects_metachars_and_empty(): void
|
||||
public function test_is_installer_safe_path_arg_helper_rejects_metachars_and_empty(): void
|
||||
{
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
|
||||
$this->assertFalse($this->invokePrivate($api, 'isInstallerSafePathArg', ['']));
|
||||
// 백슬래시는 Windows 경로 구분자이므로 차단 대상이 아님 — 셸 인젝션 차단은 escapeshellarg 가 담당
|
||||
foreach (['foo bar', 'a;b', 'a`b', 'a$b', 'a|b', "a\nb", 'a"b', "a'b", "a\0b", "a\x01b", "a\rb"] as $bad) {
|
||||
$this->assertFalse($this->invokePrivate($api, 'isInstallerSafePathArg', [$bad]), "메타문자 거부: " . bin2hex($bad));
|
||||
$this->assertFalse($this->invokePrivate($api, 'isInstallerSafePathArg', [$bad]), '메타문자 거부: '.bin2hex($bad));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +419,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
// 워커 정합 — 검증 통과한 입력이 워커에서도 silent fallback 없이 전달
|
||||
// ========================================================================
|
||||
|
||||
public function test_getComposerCommand_passes_through_single_absolute_path_without_silent_fallback(): void
|
||||
public function test_get_composer_command_passes_through_single_absolute_path_without_silent_fallback(): void
|
||||
{
|
||||
// 검증 단계에서 통과한 단일 절대경로가 워커에서도 그대로 사용됨을 보장.
|
||||
// stat 가드 제거 후 회귀 — 정상 입력이 silent fallback 으로 사라지지 않음.
|
||||
@@ -433,9 +436,9 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
// High-2 — checkCorePendingPath traversal 차단
|
||||
// ========================================================================
|
||||
|
||||
public function test_checkCorePendingPath_rejects_parent_traversal(): void
|
||||
public function test_check_core_pending_path_rejects_parent_traversal(): void
|
||||
{
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
|
||||
foreach (['../../../etc', '..', 'foo/../bar', 'foo/..\\bar'] as $bad) {
|
||||
$_GET = ['path' => $bad];
|
||||
@@ -452,9 +455,9 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$_GET = [];
|
||||
}
|
||||
|
||||
public function test_checkCorePendingPath_rejects_null_byte(): void
|
||||
public function test_check_core_pending_path_rejects_null_byte(): void
|
||||
{
|
||||
$api = new ValidationApi();
|
||||
$api = new ValidationApi;
|
||||
$_GET = ['path' => "valid\0../etc"];
|
||||
ob_start();
|
||||
$this->invokePrivate($api, 'checkCorePendingPath', []);
|
||||
@@ -465,10 +468,10 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$_GET = [];
|
||||
}
|
||||
|
||||
public function test_checkCorePendingPath_returns_uniform_message_for_nonexistent(): void
|
||||
public function test_check_core_pending_path_returns_uniform_message_for_nonexistent(): void
|
||||
{
|
||||
$api = new ValidationApi();
|
||||
$_GET = ['path' => '/nonexistent/g7-test-' . bin2hex(random_bytes(4))];
|
||||
$api = new ValidationApi;
|
||||
$_GET = ['path' => '/nonexistent/g7-test-'.bin2hex(random_bytes(4))];
|
||||
ob_start();
|
||||
$this->invokePrivate($api, 'checkCorePendingPath', []);
|
||||
$body = ob_get_clean();
|
||||
@@ -484,7 +487,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
// Medium-1 — escapeEnvValue 개행 제거
|
||||
// ========================================================================
|
||||
|
||||
public function test_escapeEnvValue_strips_newlines(): void
|
||||
public function test_escape_env_value_strips_newlines(): void
|
||||
{
|
||||
$payload = "secret\nINJECTED=true";
|
||||
$result = escapeEnvValue($payload);
|
||||
@@ -499,21 +502,21 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
$this->assertStringEndsWith('"', $result);
|
||||
}
|
||||
|
||||
public function test_escapeEnvValue_strips_crlf(): void
|
||||
public function test_escape_env_value_strips_crlf(): void
|
||||
{
|
||||
$result = escapeEnvValue("a\r\nb");
|
||||
|
||||
$this->assertSame('"ab"', $result);
|
||||
}
|
||||
|
||||
public function test_escapeEnvValue_preserves_normal_password(): void
|
||||
public function test_escape_env_value_preserves_normal_password(): void
|
||||
{
|
||||
$result = escapeEnvValue('p@ssw0rd!#$%');
|
||||
|
||||
$this->assertSame('"p@ssw0rd!#$%"', $result);
|
||||
}
|
||||
|
||||
public function test_escapeEnvValue_escapes_quotes_and_backslashes(): void
|
||||
public function test_escape_env_value_escapes_quotes_and_backslashes(): void
|
||||
{
|
||||
$result = escapeEnvValue('a"b\\c');
|
||||
|
||||
@@ -524,7 +527,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
// Medium-2 — PDO DSN 파라미터 sanitize
|
||||
// ========================================================================
|
||||
|
||||
public function test_getDatabaseConnection_rejects_semicolon_in_host(): void
|
||||
public function test_get_database_connection_rejects_semicolon_in_host(): void
|
||||
{
|
||||
$this->expectException(\PDOException::class);
|
||||
|
||||
@@ -537,7 +540,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
], false);
|
||||
}
|
||||
|
||||
public function test_getDatabaseConnection_rejects_equals_in_database(): void
|
||||
public function test_get_database_connection_rejects_equals_in_database(): void
|
||||
{
|
||||
$this->expectException(\PDOException::class);
|
||||
|
||||
@@ -550,7 +553,7 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
], false);
|
||||
}
|
||||
|
||||
public function test_getDatabaseConnection_rejects_newline_in_port(): void
|
||||
public function test_get_database_connection_rejects_newline_in_port(): void
|
||||
{
|
||||
$this->expectException(\PDOException::class);
|
||||
|
||||
@@ -562,4 +565,102 @@ class InstallerSecurityHardeningTest extends TestCase
|
||||
'db_write_password' => '',
|
||||
], false);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// KVE-2026-1677 — 설치 워커의 실행 바이너리 경로
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 저장된 php_binary 가 형태 규칙을 위반하면 시스템 기본값으로 폴백한다.
|
||||
*
|
||||
* 이 값은 설치 워커에서 실제 명령의 실행 바이너리가 된다(`--version` 이 붙지 않는
|
||||
* 호출 지점). 이전에는 아무 검증 없이 그대로 반환해, 저장만 되면 인자 자리가 열렸다.
|
||||
*
|
||||
* @scenario vector=getPhpBinary, payload_class=argument_injection_dash_r
|
||||
*
|
||||
* @effects getPhpBinary_falls_back_to_default_for_disallowed_shape
|
||||
*/
|
||||
public function test_get_php_binary_falls_back_to_default_for_disallowed_shape(): void
|
||||
{
|
||||
foreach ([
|
||||
'-rphpinfo();' => '코드 실행 플래그',
|
||||
'-dauto_prepend_file=/tmp/x' => 'ini 주입',
|
||||
'bin/php' => '상대경로',
|
||||
'/usr/local/../../tmp/php' => '.. 세그먼트',
|
||||
'/usr/bin/php$(id)' => '셸 메타문자',
|
||||
] as $payload => $reason) {
|
||||
$this->writeState(['php_binary' => $payload]);
|
||||
|
||||
$this->assertSame(
|
||||
'php',
|
||||
getPhpBinary(),
|
||||
"형태 위반 값이 그대로 실행 바이너리가 됨 ({$reason}): {$payload}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상 절대경로는 그대로 사용된다 (stat 미사용 — 존재하지 않아도 통과).
|
||||
*
|
||||
* @scenario vector=getPhpBinary, payload_class=wrapper_script_name
|
||||
*
|
||||
* @effects getPhpBinary_passes_through_legitimate_paths
|
||||
*/
|
||||
public function test_get_php_binary_passes_through_legitimate_paths(): void
|
||||
{
|
||||
foreach ([
|
||||
'/usr/local/php84/bin/php',
|
||||
'/usr/local/bin/php-8.2-wrapper.sh',
|
||||
'C:\\php\\php.exe',
|
||||
'/usr/local/php99/bin/php',
|
||||
] as $path) {
|
||||
$this->writeState(['php_binary' => $path]);
|
||||
|
||||
$this->assertSame($path, getPhpBinary(), "정상 경로가 폴백됨: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer 자리에 인터프리터 + 임의 스크립트를 넣으면 기본값으로 폴백한다.
|
||||
*
|
||||
* 하이픈 선두만 막으면 `PHP경로 /tmp/올려둔파일` 형태가 남는다.
|
||||
*
|
||||
* @scenario vector=getComposerCommand, payload_class=interpreter_plus_script
|
||||
*
|
||||
* @effects getComposerCommand_rejects_interpreter_plus_arbitrary_script
|
||||
*/
|
||||
public function test_get_composer_command_rejects_interpreter_plus_arbitrary_script(): void
|
||||
{
|
||||
foreach ([
|
||||
'/usr/bin/php /tmp/evil.php',
|
||||
'/usr/bin/python3 /tmp/evil.py',
|
||||
'/usr/bin/php -rphpinfo();',
|
||||
'/usr/bin/php /tmp/composer-evil.sh',
|
||||
] as $payload) {
|
||||
$this->writeState(['composer_binary' => $payload]);
|
||||
|
||||
$this->assertSame(
|
||||
'composer',
|
||||
getComposerCommand(),
|
||||
"인자 자리에 임의 스크립트/옵션이 통과함: {$payload}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상 멀티 PHP 입력은 계속 두 토큰으로 실행된다 (비파괴).
|
||||
*
|
||||
* @scenario vector=getComposerCommand, payload_class=wrapper_script_name
|
||||
*
|
||||
* @effects getComposerCommand_keeps_multi_php_pair_support
|
||||
*/
|
||||
public function test_get_composer_command_keeps_multi_php_pair_support(): void
|
||||
{
|
||||
$this->writeState(['composer_binary' => '/usr/local/bin/php-8.2-wrapper.sh /usr/local/bin/composer.phar']);
|
||||
|
||||
$cmd = getComposerCommand();
|
||||
|
||||
$this->assertStringContainsString('php-8.2-wrapper.sh', $cmd);
|
||||
$this->assertStringContainsString('composer.phar', $cmd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Installer;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Step 3 저장 시점의 바이너리 경로 형태 강제 회귀 테스트 (KVE-2026-1677)
|
||||
*
|
||||
* `php_binary` / `composer_binary` 는 설치 워커에서 실제 명령의 실행 바이너리가 된다.
|
||||
* 실행 시점(task-runner)과 진단 시점(check-configuration)에도 같은 정책이 걸려 있지만,
|
||||
* 형태 위반 값이 애초에 `.env` / 설치 상태 파일에 기록되지 않도록 저장 시점에서도 막는다.
|
||||
* 세 지점 중 하나라도 빠지면 "저장은 되는데 실행만 폴백" 같은 어긋난 상태가 남는다.
|
||||
*
|
||||
* 본 테스트는 저장/리다이렉트 분기에 도달하지 않도록 DB 검증을 일부러 실패시켜
|
||||
* (`db_write_host` 공백) 상태 파일·세션을 건드리지 않는다. 그래서 `STATE_PATH` 를
|
||||
* 정의하지 않으며, 인접 Installer 테스트의 상수 선점과도 무관하다.
|
||||
*
|
||||
* @scenario vector=handleStep3Post, payload_class=argument_injection_dash_r, expected_outcome=return_invalid_message
|
||||
*
|
||||
* @effects step3_post_rejects_disallowed_binary_paths_before_save
|
||||
*/
|
||||
class InstallerStep3BinaryPathStoreGuardTest extends TestCase
|
||||
{
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
require_once __DIR__.'/stubs/lang_stub.php';
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
if (! defined('BASE_PATH')) {
|
||||
define('BASE_PATH', $projectRoot);
|
||||
}
|
||||
if (! defined('SUPPORTED_LANGUAGES')) {
|
||||
define('SUPPORTED_LANGUAGES', ['ko' => '한국어', 'en' => 'English']);
|
||||
}
|
||||
|
||||
require_once $projectRoot.'/public/install/includes/functions.php';
|
||||
require_once $projectRoot.'/public/install/includes/binary-path-policy.php';
|
||||
require_once $projectRoot.'/public/install/includes/request-handler.php';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_POST = [];
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 POST 를 실행하고 에러 배열을 돌려줍니다.
|
||||
*
|
||||
* @param array<string, string> $post POST 본문 (DB 검증은 일부러 실패시킨다)
|
||||
* @return array<string, string> 에러 배열
|
||||
*/
|
||||
private function runStep3(array $post): array
|
||||
{
|
||||
// 저장/리다이렉트 분기에 도달하지 않도록 DB 검증을 실패시킨다.
|
||||
$_POST = $post + [
|
||||
'db_write_host' => '',
|
||||
'db_write_database' => '',
|
||||
'db_write_username' => '',
|
||||
'admin_email' => 'not-an-email',
|
||||
'admin_language' => 'ko',
|
||||
'admin_password' => '',
|
||||
'admin_password_confirm' => '',
|
||||
];
|
||||
|
||||
$formData = [];
|
||||
$errors = [];
|
||||
handleStep3Post('ko', $formData, $errors);
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP 자리에 코드 실행 플래그를 넣으면 저장 시점에 거부된다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_step3_rejects_php_binary_with_code_execution_flag(): void
|
||||
{
|
||||
$errors = $this->runStep3(['php_binary' => '-r file_put_contents("g7","x");']);
|
||||
|
||||
$this->assertArrayHasKey(
|
||||
'php_binary',
|
||||
$errors,
|
||||
'PHP 자리의 선두 하이픈 토큰이 저장 시점에 통과했다 — 형태 위반 값이 설치 상태에 기록된다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer 자리에 셸 메타문자가 있으면 저장 시점에 거부된다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_step3_rejects_composer_binary_with_shell_metachars(): void
|
||||
{
|
||||
$errors = $this->runStep3(['composer_binary' => 'sh -c "id > /tmp/g7"']);
|
||||
|
||||
$this->assertArrayHasKey('composer_binary', $errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer 자리의 공백 분리 입력에서 둘째 토큰이 임의 스크립트면 거부된다.
|
||||
*
|
||||
* `escapeshellarg` 는 "하나의 인자" 임만 보장하므로, 인터프리터 + 임의 스크립트
|
||||
* 조합은 그 스크립트를 그대로 실행한다. 이 자리를 저장 시점에서도 닫는다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_step3_rejects_interpreter_plus_arbitrary_script_pair(): void
|
||||
{
|
||||
$errors = $this->runStep3(['composer_binary' => '/usr/bin/php /tmp/evil.php']);
|
||||
|
||||
$this->assertArrayHasKey(
|
||||
'composer_binary',
|
||||
$errors,
|
||||
'Composer 자리의 둘째 토큰이 임의 스크립트인데 저장 시점에 통과했다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상 입력은 저장 시점 검증을 통과한다 (비파괴 확인).
|
||||
*
|
||||
* @param array<string, string> $post POST 본문
|
||||
* @param string $field 통과해야 하는 필드
|
||||
*
|
||||
* @scenario vector=handleStep3Post, payload_class=normal, expected_outcome=accept_normal
|
||||
*
|
||||
* @effects step3_post_accepts_legitimate_binary_paths
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
#[DataProvider('legitimateBinaryInputProvider')]
|
||||
public function test_step3_accepts_legitimate_binary_paths(array $post, string $field): void
|
||||
{
|
||||
$errors = $this->runStep3($post);
|
||||
|
||||
$this->assertArrayNotHasKey(
|
||||
$field,
|
||||
$errors,
|
||||
'정상 경로가 저장 시점에 거부됐다 — 설치가 불가능해진다: '.json_encode($post)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 시점 검증을 통과해야 하는 정상 입력 목록.
|
||||
*
|
||||
* @return array<string, array{array<string, string>, string}>
|
||||
*/
|
||||
public static function legitimateBinaryInputProvider(): array
|
||||
{
|
||||
return [
|
||||
'PHP 미입력' => [[], 'php_binary'],
|
||||
'PHP 기본값' => [['php_binary' => 'php'], 'php_binary'],
|
||||
'PHP 절대경로' => [['php_binary' => '/usr/bin/php8.2'], 'php_binary'],
|
||||
'PHP Windows 절대경로' => [['php_binary' => 'C:\\php\\php.exe'], 'php_binary'],
|
||||
'Composer 미입력' => [[], 'composer_binary'],
|
||||
'Composer 기본값' => [['composer_binary' => 'composer'], 'composer_binary'],
|
||||
'Composer 절대경로' => [['composer_binary' => '/usr/local/bin/composer'], 'composer_binary'],
|
||||
'Composer phar' => [['composer_binary' => '/opt/composer.phar'], 'composer_binary'],
|
||||
'PHP+phar 쌍' => [['composer_binary' => '/usr/bin/php /opt/composer.phar'], 'composer_binary'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services\LanguagePack;
|
||||
|
||||
use App\Services\LanguagePack\LanguagePackPhpArrayValidator;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 언어팩 PHP 파일 문법 검사기 테스트.
|
||||
*
|
||||
* KVE-2026-1678: 언어팩의 PHP 파일은 활성화되면 `require` 되어 그대로 실행된다. 기존 검사는
|
||||
* 위험 함수 11개를 정규식으로 나열해 막았고, 목록에 없는 함수·문법으로 전부 우회됐다.
|
||||
* 본 검사기는 "번역 배열만" 통과시키는 화이트리스트 문법이므로, 우회 매트릭스는
|
||||
* **함수 이름이 아니라 문법 형태**를 축으로 전개한다.
|
||||
*
|
||||
* DB·프레임워크 부팅이 필요 없는 순수 판정 유틸이라 PHPUnit\Framework\TestCase 를 직접 상속한다.
|
||||
*/
|
||||
class LanguagePackPhpArrayValidatorTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 번역 배열만 담은 정상 파일은 통과한다 (기존 자산 비파괴).
|
||||
*
|
||||
* @param string $source PHP 소스
|
||||
*
|
||||
* @scenario vector=php_syntax_validator, actor_permission=cli_system
|
||||
*
|
||||
* @effects translation_array_files_are_accepted
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('safeSourceProvider')]
|
||||
public function it_accepts_translation_array_files(string $source): void
|
||||
{
|
||||
$violation = LanguagePackPhpArrayValidator::inspect($source);
|
||||
|
||||
$this->assertNull(
|
||||
$violation,
|
||||
'정상 언어팩 파일이 거부됨: '.($violation['reason'] ?? '').' — '.$source
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 번역 배열 외의 문법은 전부 거부한다.
|
||||
*
|
||||
* 기존 블록리스트가 놓치던 함수들(`file_put_contents` `fopen` `call_user_func` `assert`
|
||||
* 백틱)과, 함수 이름을 아예 쓰지 않는 우회(문자열 리터럴 호출·가변 변수·heredoc·
|
||||
* 닫는 태그 뒤 인라인 코드)를 같은 규칙 하나로 막는지 확인한다.
|
||||
*
|
||||
* @param string $source PHP 소스
|
||||
* @param string $vector 우회 수법 (실패 메시지용)
|
||||
*
|
||||
* @scenario vector=php_syntax_validator, actor_permission=cli_system
|
||||
*
|
||||
* @effects function_calls_are_rejected_regardless_of_name, name_free_bypasses_are_rejected
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('unsafeSourceProvider')]
|
||||
public function it_rejects_anything_that_is_not_a_translation_array(string $source, string $vector): void
|
||||
{
|
||||
$this->assertFalse(
|
||||
LanguagePackPhpArrayValidator::isSafe($source),
|
||||
"차단되어야 하는 소스가 통과함 ({$vector}): {$source}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 깨진 소스는 파싱 단계에서 조기 거부한다.
|
||||
*
|
||||
* @scenario vector=php_syntax_validator, actor_permission=cli_system
|
||||
*
|
||||
* @effects broken_and_oversized_sources_are_rejected_early
|
||||
*/
|
||||
#[Test]
|
||||
public function it_rejects_sources_that_do_not_parse(): void
|
||||
{
|
||||
$violation = LanguagePackPhpArrayValidator::inspect('<?php return [ ;');
|
||||
|
||||
$this->assertNotNull($violation);
|
||||
$this->assertSame('parse_error', $violation['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 크기 상한을 넘는 소스는 파서를 돌리지 않고 거부한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_rejects_sources_over_the_size_limit(): void
|
||||
{
|
||||
$source = '<?php return ['.str_repeat("'a' => 'b', ", 200000).'];';
|
||||
|
||||
$this->assertGreaterThan(LanguagePackPhpArrayValidator::MAX_BYTES, strlen($source));
|
||||
|
||||
$violation = LanguagePackPhpArrayValidator::inspect($source);
|
||||
|
||||
$this->assertNotNull($violation);
|
||||
$this->assertSame('file_too_large', $violation['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩 깊이 상한을 넘는 배열은 거부한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_rejects_arrays_deeper_than_the_depth_limit(): void
|
||||
{
|
||||
$depth = LanguagePackPhpArrayValidator::MAX_DEPTH + 5;
|
||||
$source = '<?php return '.str_repeat('[', $depth).str_repeat(']', $depth).';';
|
||||
|
||||
$this->assertFalse(LanguagePackPhpArrayValidator::isSafe($source));
|
||||
}
|
||||
|
||||
/**
|
||||
* 위반 지점의 줄 번호를 보고한다 (운영자가 어느 줄을 고쳐야 하는지 알 수 있어야 한다).
|
||||
*
|
||||
* @scenario vector=php_syntax_validator, actor_permission=cli_system
|
||||
*
|
||||
* @effects violation_line_is_reported
|
||||
*/
|
||||
#[Test]
|
||||
public function it_reports_the_line_of_the_violation(): void
|
||||
{
|
||||
$source = "<?php\n\nreturn [\n 'a' => 'b',\n 'c' => system('id'),\n];\n";
|
||||
|
||||
$violation = LanguagePackPhpArrayValidator::inspect($source);
|
||||
|
||||
$this->assertNotNull($violation);
|
||||
$this->assertSame(5, $violation['line']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통과해야 하는 소스 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function safeSourceProvider(): array
|
||||
{
|
||||
return [
|
||||
'빈 배열' => ["<?php\n\nreturn [];\n"],
|
||||
'단순 문자열 배열' => ["<?php\n\nreturn ['hello' => 'こんにちは'];\n"],
|
||||
'중첩 배열' => ["<?php\nreturn ['a' => ['b' => ['c' => 'd']]];\n"],
|
||||
'숫자 키' => ["<?php\nreturn [0 => 'zero', 1 => 'one'];\n"],
|
||||
'키 없는 항목' => ["<?php\nreturn ['first', 'second'];\n"],
|
||||
'후행 쉼표' => ["<?php\nreturn [\n 'a' => 'b',\n];\n"],
|
||||
'declare 프리픽스' => ["<?php\n\ndeclare(strict_types=1);\n\nreturn ['a' => 'b'];\n"],
|
||||
'문자열 리터럴 연결' => ["<?php\nreturn ['a' => 'long '.'message'];\n"],
|
||||
'숫자·bool·null' => ["<?php\nreturn ['n' => 1, 'f' => 1.5, 'neg' => -3, 't' => true, 'f2' => false, 'x' => null];\n"],
|
||||
'주석 포함' => ["<?php\n// 인사말\nreturn [\n /* 그룹 */ 'a' => 'b', // 항목\n];\n"],
|
||||
'array() 레거시 문법' => ["<?php\nreturn array('a' => 'b');\n"],
|
||||
'닫는 태그 포함' => ["<?php\nreturn ['a' => 'b'];\n?>\n"],
|
||||
'이스케이프 포함 문자열' => ["<?php\nreturn ['a' => 'It\\'s ok', 'b' => \"tab\\there\"];\n"],
|
||||
'대문자 상수 표기' => ["<?php\nreturn ['a' => TRUE, 'b' => NULL];\n"],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단되어야 하는 소스 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function unsafeSourceProvider(): array
|
||||
{
|
||||
return [
|
||||
// 기존 블록리스트가 잡던 것
|
||||
'eval' => ["<?php\neval(\$_GET['c']);\nreturn [];\n", '기존 목록 — eval'],
|
||||
// 기존 블록리스트가 놓치던 것
|
||||
'file_put_contents' => ["<?php\nfile_put_contents('/tmp/g7','x');\nreturn [];\n", '목록 누락 — 파일 쓰기'],
|
||||
'fopen/fwrite' => ["<?php\n\$h = fopen('/tmp/g7','w');\nfwrite(\$h,'x');\nreturn [];\n", '목록 누락 — 스트림'],
|
||||
'unlink' => ["<?php\nunlink('/etc/passwd');\nreturn [];\n", '목록 누락 — 삭제'],
|
||||
'call_user_func' => ["<?php\ncall_user_func('system','id');\nreturn [];\n", '목록 누락 — 간접 호출'],
|
||||
'assert' => ["<?php\nassert(\"system('id')\");\nreturn [];\n", '목록 누락 — assert'],
|
||||
'백틱 실행 연산자' => ["<?php\n\$o = `id`;\nreturn [];\n", '목록 누락 — 백틱'],
|
||||
'file_get_contents 반환' => ["<?php\nreturn ['a' => file_get_contents('/etc/passwd')];\n", '목록 누락 — 값 자리 호출'],
|
||||
|
||||
// 함수 이름을 쓰지 않는 우회 (블록리스트로는 원리적으로 불가)
|
||||
'문자열 리터럴 호출' => ["<?php\nreturn [('system')('id')];\n", '이름 없는 호출 — 토큰 집합 검사 우회'],
|
||||
'가변 변수 호출' => ["<?php\n\$a='system';\$a('id');\nreturn [];\n", '가변 변수'],
|
||||
'변수 사용' => ["<?php\nreturn ['a' => \$GLOBALS['x']];\n", '변수'],
|
||||
'heredoc 보간' => ["<?php\n\$x = <<<EOT\n{\$_GET['c']}\nEOT;\nreturn [];\n", 'heredoc'],
|
||||
'닫는 태그 뒤 인라인 코드' => ["<?php\nreturn [];\n?>\n<?php system('id');\n", '닫는 태그 뒤 두 번째 블록'],
|
||||
'닫는 태그 뒤 출력' => ["<?php\nreturn [];\n?>\nleaked\n", '닫는 태그 뒤 인라인 출력'],
|
||||
'new 객체' => ["<?php\nreturn ['a' => new \\stdClass];\n", 'new'],
|
||||
'상수 참조' => ["<?php\nreturn ['a' => PHP_OS];\n", '상수 — 정보 노출'],
|
||||
'삼항 연산' => ["<?php\nreturn ['a' => 1 > 0 ? 'y' : 'n'];\n", '연산자'],
|
||||
'함수 선언' => ["<?php\nfunction g7_evil(){ system('id'); }\nreturn [];\n", '함수 선언'],
|
||||
'클래스 선언' => ["<?php\nclass Evil { }\nreturn [];\n", '클래스 선언'],
|
||||
'use 문' => ["<?php\nuse App\\Models\\User;\nreturn [];\n", 'use'],
|
||||
'return 없음' => ["<?php\n\$a = 1;\n", 'return 부재'],
|
||||
'return 뒤 추가 구문' => ["<?php\nreturn [];\nsystem('id');\n", 'return 뒤 잔여 토큰'],
|
||||
'단축 에코 태그' => ["<?= system('id') ?>\n", '단축 태그'],
|
||||
'선행 인라인 HTML' => ["leaked\n<?php\nreturn [];\n", '여는 태그 앞 출력'],
|
||||
'괄호 그룹화' => ["<?php\nreturn ('a');\n", '괄호 — array 뒤가 아님'],
|
||||
'declare 위장' => ["<?php\ndeclare(ticks=1);\nreturn [];\n", 'declare 다른 지시자'],
|
||||
'스프레드' => ["<?php\nreturn [...\$a];\n", '스프레드 연산자'],
|
||||
'함수 호출을 키로' => ["<?php\nreturn [system('id') => 'x'];\n", '키 자리 호출'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
namespace Tests\Unit\Services\LanguagePack;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\LanguagePack\LanguagePackPhpArrayValidator;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
@@ -53,7 +54,7 @@ class LanguagePackSecurityTest extends TestCase
|
||||
private function makeZip(array $files): string
|
||||
{
|
||||
$zipPath = $this->tempDir.DIRECTORY_SEPARATOR.uniqid('pack_', true).'.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach ($files as $name => $content) {
|
||||
$zip->addFromString($name, $content);
|
||||
@@ -109,7 +110,9 @@ class LanguagePackSecurityTest extends TestCase
|
||||
$file = new UploadedFile($zip, 'pack.zip', 'application/zip', null, true);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/허용되지 않/u');
|
||||
// 판정이 "위험 함수 목록" 에서 "번역 배열만 허용" 으로 바뀌었으므로 메시지도 바뀐다.
|
||||
// 이 테스트의 의도(backend 안의 eval 이 차단된다)는 그대로 유지한다.
|
||||
$this->expectExceptionMessageMatches('/번역 배열만/u');
|
||||
|
||||
$this->service->installFromFile($file, false, null);
|
||||
}
|
||||
@@ -130,4 +133,238 @@ class LanguagePackSecurityTest extends TestCase
|
||||
// 활성 디렉토리 정리
|
||||
File::deleteDirectory(base_path('lang-packs/test-core-ja'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 블록리스트가 놓치던 페이로드가 설치 단계에서 거부된다.
|
||||
*
|
||||
* KVE-2026-1678 주 시나리오. 정규식 블록리스트는 위험 함수 11개만 봤기 때문에
|
||||
* `file_put_contents` 한 줄로 우회됐고, 활성화되면 `require` 되어 그대로 실행됐다.
|
||||
*
|
||||
* @param string $payload backend PHP 파일 내용
|
||||
* @param string $vector 우회 수법 (실패 메시지용)
|
||||
*
|
||||
* @scenario vector=assert_security_rules, actor_permission=cli_system
|
||||
*
|
||||
* @effects install_rejects_php_that_is_not_a_translation_array
|
||||
*/
|
||||
#[DataProvider('blocklistBypassPayloadProvider')]
|
||||
public function test_install_rejects_payloads_the_old_blocklist_missed(string $payload, string $vector): void
|
||||
{
|
||||
$zip = $this->makeZip([
|
||||
'language-pack.json' => $this->validManifestJson(),
|
||||
'backend/ja/messages.php' => $payload,
|
||||
]);
|
||||
|
||||
$file = new UploadedFile($zip, 'pack.zip', 'application/zip', null, true);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
try {
|
||||
$this->service->installFromFile($file, false, null);
|
||||
} finally {
|
||||
// 거부되지 않고 설치되었다면 잔여물을 남기지 않는다.
|
||||
File::deleteDirectory(base_path('lang-packs/test-core-ja'));
|
||||
}
|
||||
|
||||
$this->fail("차단되어야 하는 페이로드가 설치됨 ({$vector})");
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용 확장자 밖의 파일은 거부된다 (대소문자 무관).
|
||||
*
|
||||
* 이전 판정은 `str_ends_with($absolute, '.php')` 라 `EVIL.PHP` 를 PHP 로 보지 않았고,
|
||||
* `.phar` `.phtml` 처럼 실행 가능한 다른 확장자도 아무 검사 없이 통과했다.
|
||||
*
|
||||
* @param string $path ZIP 안의 파일 경로
|
||||
* @param string $reason 차단 사유 (실패 메시지용)
|
||||
*
|
||||
* @scenario vector=assert_security_rules, actor_permission=cli_system
|
||||
*
|
||||
* @effects install_rejects_files_outside_the_extension_whitelist
|
||||
*/
|
||||
#[DataProvider('disallowedFilePathProvider')]
|
||||
public function test_install_rejects_files_outside_the_extension_whitelist(string $path, string $reason): void
|
||||
{
|
||||
$zip = $this->makeZip([
|
||||
'language-pack.json' => $this->validManifestJson(),
|
||||
'backend/ja/messages.php' => "<?php\nreturn [];\n",
|
||||
$path => "<?php\nsystem('id');\n",
|
||||
]);
|
||||
|
||||
$file = new UploadedFile($zip, 'pack.zip', 'application/zip', null, true);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
try {
|
||||
$this->service->installFromFile($file, false, null);
|
||||
} finally {
|
||||
File::deleteDirectory(base_path('lang-packs/test-core-ja'));
|
||||
}
|
||||
|
||||
$this->fail("차단되어야 하는 파일이 설치됨 ({$reason}): {$path}");
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 의 locale 과 다른 디렉토리에 놓인 PHP 는 거부된다.
|
||||
*
|
||||
* @scenario vector=assert_security_rules, actor_permission=cli_system
|
||||
*
|
||||
* @effects install_rejects_php_outside_the_declared_locale_directory
|
||||
*/
|
||||
public function test_install_rejects_php_in_a_different_locale_directory(): void
|
||||
{
|
||||
$zip = $this->makeZip([
|
||||
'language-pack.json' => $this->validManifestJson(),
|
||||
'backend/ko/messages.php' => "<?php\nreturn [];\n",
|
||||
]);
|
||||
|
||||
$file = new UploadedFile($zip, 'pack.zip', 'application/zip', null, true);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/backend/i');
|
||||
|
||||
try {
|
||||
$this->service->installFromFile($file, false, null);
|
||||
} finally {
|
||||
File::deleteDirectory(base_path('lang-packs/test-core-ja'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상 언어팩 구성(declare 프리픽스·문자열 연결·중첩 배열·json·md)은 그대로 설치된다.
|
||||
*
|
||||
* 확장(`modules/`·`plugins/`) 의 lang 파일에 실제로 나타나는 형태이므로
|
||||
* 이 케이스가 깨지면 기존 자산이 설치 불가가 된다.
|
||||
*
|
||||
* @scenario vector=assert_security_rules, actor_permission=cli_system
|
||||
*
|
||||
* @effects install_accepts_real_world_translation_files
|
||||
*/
|
||||
public function test_install_accepts_real_world_translation_files(): void
|
||||
{
|
||||
$zip = $this->makeZip([
|
||||
'language-pack.json' => $this->validManifestJson(),
|
||||
'backend/ja/messages.php' => "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n 'a' => 'long '.'message',\n 'nested' => ['x' => ['y' => 1, 'z' => null]],\n];\n",
|
||||
'frontend/ja.json' => '{"hello":"こんにちは"}',
|
||||
'README.md' => "# pack\n",
|
||||
]);
|
||||
|
||||
$file = new UploadedFile($zip, 'pack.zip', 'application/zip', null, true);
|
||||
|
||||
$pack = $this->service->installFromFile($file, false, null);
|
||||
|
||||
$this->assertSame('test-core-ja', $pack->identifier);
|
||||
|
||||
File::deleteDirectory(base_path('lang-packs/test-core-ja'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 심볼릭 링크가 포함된 패키지는 거부된다 (패키지 밖 파일을 끌어들이는 통로 차단).
|
||||
*
|
||||
* @scenario vector=assert_security_rules, actor_permission=cli_system
|
||||
*
|
||||
* @effects install_rejects_packages_containing_symlinks
|
||||
*/
|
||||
public function test_install_rejects_packages_containing_symlinks(): void
|
||||
{
|
||||
$packageRoot = $this->tempDir.DIRECTORY_SEPARATOR.'symlink_pack';
|
||||
File::ensureDirectoryExists($packageRoot.DIRECTORY_SEPARATOR.'backend'.DIRECTORY_SEPARATOR.'ja');
|
||||
File::put($packageRoot.DIRECTORY_SEPARATOR.'language-pack.json', $this->validManifestJson());
|
||||
File::put($packageRoot.DIRECTORY_SEPARATOR.'backend'.DIRECTORY_SEPARATOR.'ja'.DIRECTORY_SEPARATOR.'messages.php', "<?php\nreturn [];\n");
|
||||
|
||||
$target = $this->tempDir.DIRECTORY_SEPARATOR.'outside.json';
|
||||
File::put($target, '{}');
|
||||
|
||||
if (! @symlink($target, $packageRoot.DIRECTORY_SEPARATOR.'linked.json')) {
|
||||
$this->markTestSkipped('이 환경에서는 심볼릭 링크를 만들 수 없어 (권한 부족) 링크 거부를 실측할 수 없다.');
|
||||
}
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$this->invokeAssertSecurityRules($packageRoot, json_decode($this->validManifestJson(), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장소에 동봉된 번들 언어팩 전부가 새 검사를 통과한다.
|
||||
*
|
||||
* 회귀 방지의 핵심 — 검사를 조인 결과로 기존 자산이 설치 불가가 되면 안 된다.
|
||||
* 번들에도 같은 검사를 적용하기로 했으므로(검사 경로 단일화) 전수로 확인한다.
|
||||
*
|
||||
* @scenario vector=assert_security_rules, actor_permission=cli_system
|
||||
*
|
||||
* @effects every_bundled_pack_passes_the_new_validator
|
||||
*/
|
||||
public function test_all_bundled_packs_pass_the_validator(): void
|
||||
{
|
||||
$root = base_path('lang-packs/_bundled');
|
||||
|
||||
$this->assertDirectoryExists($root);
|
||||
|
||||
$files = [];
|
||||
foreach (File::allFiles($root) as $file) {
|
||||
if (strtolower($file->getExtension()) === 'php') {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotEmpty($files, '번들 언어팩 PHP 파일이 하나도 수집되지 않았다 — 전수 검사가 공전한다');
|
||||
|
||||
$violations = [];
|
||||
foreach ($files as $path) {
|
||||
$violation = LanguagePackPhpArrayValidator::inspectFile($path);
|
||||
|
||||
if ($violation !== null) {
|
||||
$violations[] = str_replace($root, '', $path)." (line {$violation['line']}: {$violation['reason']})";
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $violations, '번들 언어팩이 새 검사에서 거부됨: '.implode(', ', $violations));
|
||||
}
|
||||
|
||||
/**
|
||||
* private 보안 검사를 직접 호출합니다 (ZIP 으로는 심볼릭 링크를 넣을 수 없다).
|
||||
*
|
||||
* @param string $packageRoot 패키지 루트
|
||||
* @param array<string, mixed> $manifest manifest 데이터
|
||||
*/
|
||||
private function invokeAssertSecurityRules(string $packageRoot, array $manifest): void
|
||||
{
|
||||
$method = new \ReflectionMethod($this->service, 'assertSecurityRules');
|
||||
$method->invoke($this->service, $packageRoot, $manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 정규식 블록리스트가 놓치던 페이로드 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function blocklistBypassPayloadProvider(): array
|
||||
{
|
||||
return [
|
||||
'file_put_contents' => ["<?php\nfile_put_contents('/tmp/g7_lp_proof','x');\nreturn [];\n", '파일 쓰기'],
|
||||
'fopen + fwrite' => ["<?php\n\$h=fopen('/tmp/g7_lp_proof','w');fwrite(\$h,'x');\nreturn [];\n", '스트림'],
|
||||
'call_user_func' => ["<?php\ncall_user_func('system','id');\nreturn [];\n", '간접 호출'],
|
||||
'assert' => ["<?php\nassert(\"system('id')\");\nreturn [];\n", 'assert'],
|
||||
'백틱' => ["<?php\n\$o=`id`;\nreturn [];\n", '백틱 실행 연산자'],
|
||||
'문자열 리터럴 호출' => ["<?php\nreturn [('system')('id')];\n", '이름 없는 호출'],
|
||||
'닫는 태그 뒤 코드' => ["<?php\nreturn [];\n?>\n<?php system('id');\n", '두 번째 PHP 블록'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용 확장자 밖이라 거부되어야 하는 파일 경로 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function disallowedFilePathProvider(): array
|
||||
{
|
||||
return [
|
||||
'대문자 확장자' => ['backend/ja/EVIL.PHP', '확장자 판정이 대소문자를 구분하면 통과한다'],
|
||||
'phar' => ['backend/ja/evil.phar', '실행 가능한 아카이브'],
|
||||
'phtml' => ['backend/ja/evil.phtml', 'PHP 템플릿 확장자'],
|
||||
'php5' => ['backend/ja/evil.php5', '레거시 PHP 확장자'],
|
||||
'확장자 없음' => ['backend/ja/htaccess_like', '확장자 화이트리스트 밖'],
|
||||
'루트 PHP' => ['evil.php', 'backend 밖 PHP'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@ namespace Tests\Unit\Services\LanguagePack;
|
||||
|
||||
use App\Enums\LanguagePackScope;
|
||||
use App\Enums\LanguagePackStatus;
|
||||
use App\Exceptions\LanguagePackOperationException;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePack\LanguagePackRegistry;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -225,7 +229,7 @@ class LanguagePackServiceTest extends TestCase
|
||||
$this->makePack('sirsoft', 'ja', LanguagePackStatus::Active->value);
|
||||
$this->registry->invalidate();
|
||||
|
||||
\Illuminate\Support\Facades\DB::table('modules')->insert([
|
||||
DB::table('modules')->insert([
|
||||
'identifier' => 'gnuboard7-hello_module',
|
||||
'name' => 'Hello',
|
||||
'vendor' => 'gnuboard7',
|
||||
@@ -249,7 +253,7 @@ class LanguagePackServiceTest extends TestCase
|
||||
$this->makePack('sirsoft', 'ja', LanguagePackStatus::Active->value);
|
||||
$this->registry->invalidate();
|
||||
|
||||
\Illuminate\Support\Facades\DB::table('modules')->insert([
|
||||
DB::table('modules')->insert([
|
||||
'identifier' => 'gnuboard7-hello_module',
|
||||
'name' => 'Hello',
|
||||
'vendor' => 'gnuboard7',
|
||||
@@ -274,7 +278,7 @@ class LanguagePackServiceTest extends TestCase
|
||||
$this->makePack('sirsoft', 'ja', LanguagePackStatus::Active->value);
|
||||
$this->registry->invalidate();
|
||||
|
||||
\Illuminate\Support\Facades\DB::table('modules')->insert([
|
||||
DB::table('modules')->insert([
|
||||
'identifier' => 'gnuboard7-hello_module',
|
||||
'name' => 'Hello',
|
||||
'vendor' => 'gnuboard7',
|
||||
@@ -411,7 +415,7 @@ class LanguagePackServiceTest extends TestCase
|
||||
$this->assertFileExists($installedDir.'/seed/permissions.json');
|
||||
|
||||
// cleanup — 활성 디렉토리 제거 (RefreshDatabase 와 별개)
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($installedDir);
|
||||
File::deleteDirectory($installedDir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -445,7 +449,257 @@ class LanguagePackServiceTest extends TestCase
|
||||
'재설치 시 자기 자신을 슬롯 충돌로 오인하여 active → installed 로 강등되면 안 됨 (회귀)');
|
||||
|
||||
// cleanup
|
||||
\Illuminate\Support\Facades\File::deleteDirectory(base_path('lang-packs/g7-core-ja'));
|
||||
File::deleteDirectory(base_path('lang-packs/g7-core-ja'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 활성화는 `activated` 훅을 정확히 한 번만 발화한다.
|
||||
*
|
||||
* 이전 구현은 설치 트랜잭션에서 status 를 직접 active 로 쓴 뒤 훅을 발화했고,
|
||||
* 활성화 경로(activate)도 같은 훅을 발화해 두 경로가 공존했다. 활성화를 activate()
|
||||
* 한 곳으로 모았으므로 발화도 한 번이어야 한다 — 두 번 발화되면 훅 구독자가
|
||||
* 캐시 재생성·알림 등을 중복 수행한다.
|
||||
*
|
||||
* @scenario vector=finalize_install_activation, actor_permission=cli_system
|
||||
*
|
||||
* @effects activated_hook_fires_exactly_once_on_auto_activate
|
||||
*/
|
||||
public function test_activated_hook_fires_exactly_once_on_auto_activate(): void
|
||||
{
|
||||
$bundledPath = base_path('lang-packs/_bundled/g7-core-ja');
|
||||
if (! is_dir($bundledPath) || ! is_file($bundledPath.'/language-pack.json')) {
|
||||
$this->markTestSkipped('g7-core-ja 번들이 아직 빌드되지 않음 — build-language-pack-ja.cjs 실행 후 재시도');
|
||||
}
|
||||
|
||||
$fired = 0;
|
||||
HookManager::addAction('core.language_packs.activated', function () use (&$fired) {
|
||||
$fired++;
|
||||
});
|
||||
|
||||
try {
|
||||
$pack = $this->service->installFromBundled('g7-core-ja', autoActivate: true);
|
||||
|
||||
$this->assertSame(LanguagePackStatus::Active->value, $pack->status);
|
||||
$this->assertSame(1, $fired, 'activated 훅이 정확히 1회 발화되지 않음');
|
||||
} finally {
|
||||
HookManager::clearAction('core.language_packs.activated');
|
||||
File::deleteDirectory(base_path('lang-packs/g7-core-ja'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 슬롯에 이미 다른 활성 팩이 있으면 자동 활성화를 건너뛴다 (기존 동작 유지).
|
||||
*
|
||||
* 활성화를 activate() 경유로 바꾸면서 슬롯이 점유된 경우 예외가 새로 터지지 않아야 한다 —
|
||||
* 설치 자체는 성공하고 상태만 installed 로 남는 것이 기존 동작이다.
|
||||
*
|
||||
* @scenario vector=finalize_install_activation, actor_permission=cli_system
|
||||
*
|
||||
* @effects auto_activate_skipped_when_slot_occupied
|
||||
*/
|
||||
public function test_auto_activate_skipped_when_slot_occupied(): void
|
||||
{
|
||||
$bundledPath = base_path('lang-packs/_bundled/g7-core-ja');
|
||||
if (! is_dir($bundledPath) || ! is_file($bundledPath.'/language-pack.json')) {
|
||||
$this->markTestSkipped('g7-core-ja 번들이 아직 빌드되지 않음 — build-language-pack-ja.cjs 실행 후 재시도');
|
||||
}
|
||||
|
||||
// 같은 슬롯(core / ja)을 다른 팩이 이미 점유한 상태
|
||||
$this->makePack('other', 'ja', LanguagePackStatus::Active->value);
|
||||
|
||||
try {
|
||||
$pack = $this->service->installFromBundled('g7-core-ja', autoActivate: true);
|
||||
|
||||
$this->assertSame(
|
||||
LanguagePackStatus::Installed->value,
|
||||
$pack->status,
|
||||
'슬롯이 점유되어 있으면 설치는 성공하되 활성화는 건너뛰어야 한다'
|
||||
);
|
||||
} finally {
|
||||
File::deleteDirectory(base_path('lang-packs/g7-core-ja'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 활성화는 트랜잭션의 직접 기록이 아니라 activate() 를 경유하며,
|
||||
* 그 경로에는 의존성 검사가 실제로 걸린다.
|
||||
*
|
||||
* 두 가지를 각각 단언한다.
|
||||
* (1) 경로 — `installed` 훅이 발화하는 시점(트랜잭션 직후)의 DB 상태가 `installed` 여야
|
||||
* 한다. 예전처럼 트랜잭션이 status 를 active 로 직접 썼다면 이 시점에 이미 active 다.
|
||||
* 그 뒤 `activated` 훅이 발화하고 최종 상태가 active 가 되는 순서까지 확인하면
|
||||
* 활성 승격이 activate() 에서 일어났다는 것이 확정된다.
|
||||
* (2) 검사 — activate() 는 저장된 manifest 로 의존성을 검사한다. 코어 로케일이
|
||||
* 활성이 아닌 확장 스코프 팩은 activate() 단계에서 거부되어야 한다.
|
||||
*
|
||||
* @scenario vector=finalize_install_activation, actor_permission=cli_system
|
||||
*
|
||||
* @effects auto_activate_goes_through_activate_and_enforces_dependencies
|
||||
*/
|
||||
public function test_finalize_install_with_auto_activate_goes_through_activate_and_runs_dependency_checks(): void
|
||||
{
|
||||
$identifier = 'actpath-core-ja';
|
||||
$bundledDir = $this->createBundledFixture($identifier, $this->coreJaManifest($identifier, 'actpath', '1.0.0'));
|
||||
|
||||
/** @var list<string> $order 훅 발화 순서 */
|
||||
$order = [];
|
||||
$statusAtInstalledHook = null;
|
||||
|
||||
HookManager::addAction('core.language_packs.installed', function () use (&$order, &$statusAtInstalledHook, $identifier) {
|
||||
$order[] = 'installed';
|
||||
$statusAtInstalledHook = DB::table('language_packs')
|
||||
->where('identifier', $identifier)
|
||||
->value('status');
|
||||
});
|
||||
HookManager::addAction('core.language_packs.activated', function () use (&$order) {
|
||||
$order[] = 'activated';
|
||||
});
|
||||
|
||||
try {
|
||||
$pack = $this->service->installFromBundled($identifier, autoActivate: true);
|
||||
|
||||
// (1) 경로 — 트랜잭션은 installed 로만 기록하고, 활성 승격은 그 뒤 activate() 에서 일어난다.
|
||||
$this->assertSame(
|
||||
LanguagePackStatus::Installed->value,
|
||||
$statusAtInstalledHook,
|
||||
'설치 트랜잭션이 status 를 active 로 직접 기록했다 — activate() 를 경유하지 않는다'
|
||||
);
|
||||
$this->assertSame(['installed', 'activated'], $order, '활성화가 설치 트랜잭션보다 먼저/동시에 일어났다');
|
||||
$this->assertSame(LanguagePackStatus::Active->value, $pack->status);
|
||||
} finally {
|
||||
HookManager::clearAction('core.language_packs.installed');
|
||||
HookManager::clearAction('core.language_packs.activated');
|
||||
File::deleteDirectory($bundledDir);
|
||||
File::deleteDirectory(base_path('lang-packs/'.$identifier));
|
||||
}
|
||||
|
||||
// (2) 검사 — activate() 는 저장된 manifest 의 의존성을 실제로 검사한다.
|
||||
// 코어 로케일이 활성이 아닌 상태에서 모듈 스코프 팩을 활성화하면 거부되어야 한다.
|
||||
// (위에서 설치한 팩이 코어 ja 를 점유하므로 의존성이 미충족인 다른 로케일을 쓴다)
|
||||
$dependent = LanguagePack::query()->create([
|
||||
'identifier' => 'actpath-module-de',
|
||||
'vendor' => 'actpath',
|
||||
'scope' => LanguagePackScope::Module->value,
|
||||
'target_identifier' => 'acme-demo',
|
||||
'locale' => 'de',
|
||||
'locale_name' => 'DE',
|
||||
'locale_native_name' => 'Deutsch',
|
||||
'text_direction' => 'ltr',
|
||||
'version' => '1.0.0',
|
||||
'status' => LanguagePackStatus::Installed->value,
|
||||
'is_protected' => false,
|
||||
'manifest' => [
|
||||
'identifier' => 'actpath-module-de',
|
||||
'scope' => LanguagePackScope::Module->value,
|
||||
'target_identifier' => 'acme-demo',
|
||||
'locale' => 'de',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertFalse(
|
||||
$this->registry->hasActiveCoreLocale('de'),
|
||||
'전제 조건 붕괴 — 코어 de 가 활성이면 의존성 검사가 통과해버려 이 단언이 무의미해진다'
|
||||
);
|
||||
|
||||
$this->expectException(LanguagePackOperationException::class);
|
||||
$this->service->activate($dependent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 팩의 업데이트는 활성 상태를 유지한다 — activate() 경유 전환의 회귀 가드.
|
||||
*
|
||||
* `performUpdate` 는 `$autoActivate = $pack->isActive()` 로 활성 여부를 승계해
|
||||
* 재설치 경로로 넘긴다. 활성화가 트랜잭션 직접 기록에서 activate() 경유로 바뀌면서
|
||||
* 이 경로에 의존성 검사·슬롯 충돌 검사가 새로 끼어들었으므로, 활성 팩의 업데이트가
|
||||
* 조용히 installed 로 떨어지거나 예외로 막히지 않는지 고정한다.
|
||||
*
|
||||
* @scenario vector=finalize_install_activation, actor_permission=cli_system
|
||||
*
|
||||
* @effects update_of_an_active_pack_keeps_it_active
|
||||
*/
|
||||
public function test_perform_update_of_active_pack_keeps_it_active(): void
|
||||
{
|
||||
$identifier = 'updact-core-ja';
|
||||
$bundledDir = $this->createBundledFixture($identifier, $this->coreJaManifest($identifier, 'updact', '1.0.0'));
|
||||
|
||||
try {
|
||||
$pack = $this->service->installFromBundled($identifier, autoActivate: true);
|
||||
$this->assertSame(LanguagePackStatus::Active->value, $pack->status, '전제 조건 — 업데이트 대상은 활성 팩이어야 한다');
|
||||
|
||||
// 번들에 신버전 배치 후 업데이트 (bundled 소스 강제)
|
||||
$this->createBundledFixture($identifier, $this->coreJaManifest($identifier, 'updact', '1.0.1'));
|
||||
|
||||
$updated = $this->service->performUpdate($pack, force: true);
|
||||
|
||||
$this->assertSame('1.0.1', $updated->version);
|
||||
$this->assertSame(
|
||||
LanguagePackStatus::Active->value,
|
||||
$updated->status,
|
||||
'활성 팩을 업데이트했더니 활성 상태를 잃었다 — activate() 경유 전환의 회귀'
|
||||
);
|
||||
} finally {
|
||||
File::deleteDirectory($bundledDir);
|
||||
File::deleteDirectory(base_path('lang-packs/'.$identifier));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성 팩의 업데이트는 활성으로 승격되지 않는다 (기존 동작 유지).
|
||||
*
|
||||
* @scenario vector=finalize_install_activation, actor_permission=cli_system
|
||||
*
|
||||
* @effects update_of_an_inactive_pack_does_not_promote_it
|
||||
*/
|
||||
public function test_perform_update_of_inactive_pack_does_not_promote_it(): void
|
||||
{
|
||||
$identifier = 'updinact-core-ja';
|
||||
$bundledDir = $this->createBundledFixture($identifier, $this->coreJaManifest($identifier, 'updinact', '1.0.0'));
|
||||
|
||||
try {
|
||||
$pack = $this->service->installFromBundled($identifier, autoActivate: false);
|
||||
$this->assertSame(LanguagePackStatus::Installed->value, $pack->status);
|
||||
|
||||
$this->createBundledFixture($identifier, $this->coreJaManifest($identifier, 'updinact', '1.0.1'));
|
||||
|
||||
$updated = $this->service->performUpdate($pack, force: true);
|
||||
|
||||
$this->assertSame('1.0.1', $updated->version);
|
||||
$this->assertSame(
|
||||
LanguagePackStatus::Installed->value,
|
||||
$updated->status,
|
||||
'비활성 팩이 업데이트만으로 활성 승격되면 안 된다'
|
||||
);
|
||||
} finally {
|
||||
File::deleteDirectory($bundledDir);
|
||||
File::deleteDirectory(base_path('lang-packs/'.$identifier));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 ja 번들 픽스처용 manifest 를 만듭니다.
|
||||
*
|
||||
* @param string $identifier 번들 식별자
|
||||
* @param string $vendor 벤더
|
||||
* @param string $version 버전
|
||||
* @return array<string, mixed> manifest 데이터
|
||||
*/
|
||||
private function coreJaManifest(string $identifier, string $vendor, string $version): array
|
||||
{
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'namespace' => $vendor,
|
||||
'vendor' => $vendor,
|
||||
'name' => ['ko' => $identifier, 'en' => $identifier, 'ja' => $identifier],
|
||||
'description' => ['ko' => '테스트', 'en' => 'Test'],
|
||||
'version' => $version,
|
||||
'scope' => LanguagePackScope::Core->value,
|
||||
'target_identifier' => null,
|
||||
'locale' => 'ja',
|
||||
'locale_name' => 'JA',
|
||||
'locale_native_name' => '日本語',
|
||||
'text_direction' => 'ltr',
|
||||
'g7_version' => '>=7.0.0-beta.4',
|
||||
];
|
||||
}
|
||||
|
||||
public function test_install_blocks_downgrade_without_force(): void
|
||||
@@ -485,11 +739,11 @@ class LanguagePackServiceTest extends TestCase
|
||||
]);
|
||||
|
||||
// force=false → 다운그레이드 차단 예외
|
||||
$this->expectException(\App\Exceptions\LanguagePackOperationException::class);
|
||||
$this->expectException(LanguagePackOperationException::class);
|
||||
$this->service->installFromBundled($identifier);
|
||||
} finally {
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($bundledDir);
|
||||
\Illuminate\Support\Facades\File::deleteDirectory(base_path('lang-packs/'.$identifier));
|
||||
File::deleteDirectory($bundledDir);
|
||||
File::deleteDirectory(base_path('lang-packs/'.$identifier));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,8 +787,8 @@ class LanguagePackServiceTest extends TestCase
|
||||
$pack = $this->service->installFromBundled($identifier, force: true);
|
||||
$this->assertSame('1.0.0-beta.1', $pack->version);
|
||||
} finally {
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($bundledDir);
|
||||
\Illuminate\Support\Facades\File::deleteDirectory(base_path('lang-packs/'.$identifier));
|
||||
File::deleteDirectory($bundledDir);
|
||||
File::deleteDirectory(base_path('lang-packs/'.$identifier));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,8 +802,8 @@ class LanguagePackServiceTest extends TestCase
|
||||
private function createBundledFixture(string $identifier, array $manifest): string
|
||||
{
|
||||
$path = base_path('lang-packs/_bundled/'.$identifier);
|
||||
\Illuminate\Support\Facades\File::ensureDirectoryExists($path);
|
||||
\Illuminate\Support\Facades\File::put(
|
||||
File::ensureDirectoryExists($path);
|
||||
File::put(
|
||||
$path.DIRECTORY_SEPARATOR.'language-pack.json',
|
||||
json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
);
|
||||
@@ -584,7 +838,7 @@ class LanguagePackServiceTest extends TestCase
|
||||
$this->assertFalse($match->exists, '가상 레코드는 exists=false 여야 함');
|
||||
$this->assertNull($match->id, '가상 레코드는 id 가 null 이어야 함');
|
||||
} finally {
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($path);
|
||||
File::deleteDirectory($path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,7 +878,7 @@ class LanguagePackServiceTest extends TestCase
|
||||
$match = $packs->firstWhere('identifier', $identifier);
|
||||
$this->assertNull($match, '동일 슬롯에 DB 레코드가 있으면 가상 레코드가 반환되지 않아야 함');
|
||||
} finally {
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($path);
|
||||
File::deleteDirectory($path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,7 +914,7 @@ class LanguagePackServiceTest extends TestCase
|
||||
$this->assertNotNull($virtual);
|
||||
$this->assertSame(LanguagePackStatus::Uninstalled->value, $virtual->status);
|
||||
} finally {
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($path);
|
||||
File::deleteDirectory($path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,7 +943,7 @@ class LanguagePackServiceTest extends TestCase
|
||||
$this->assertNotNull($items->firstWhere('identifier', $identifier));
|
||||
$this->assertNull($items->firstWhere('status', LanguagePackStatus::Active->value));
|
||||
} finally {
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($path);
|
||||
File::deleteDirectory($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\ScheduleCommandValidator;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Modules\G7Testing\Console\FakeExtensionScheduleCommand;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
@@ -145,6 +147,10 @@ class ScheduleCommandValidatorTest extends TestCase
|
||||
* 차단목록에 오른 artisan 명령은 거부한다 (코드 실행형·파괴적 명령).
|
||||
*
|
||||
* @param string $command 차단되어야 하는 command
|
||||
*
|
||||
* @scenario command_class=denylisted, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects denylist_blocks_destructive_and_code_executing_commands
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('deniedArtisanCommandProvider')]
|
||||
@@ -160,6 +166,10 @@ class ScheduleCommandValidatorTest extends TestCase
|
||||
* 차단목록에 없는 artisan 명령은 허용한다 (기본 허용).
|
||||
*
|
||||
* @param string $command 허용되어야 하는 command
|
||||
*
|
||||
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects allowlisted_maintenance_commands_still_run
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('allowedArtisanCommandProvider')]
|
||||
@@ -177,10 +187,331 @@ class ScheduleCommandValidatorTest extends TestCase
|
||||
#[Test]
|
||||
public function it_extracts_the_artisan_command_name_from_the_first_token(): void
|
||||
{
|
||||
$this->assertSame('cache:clear', ScheduleCommandValidator::extractArtisanCommandName(' cache:clear --force '));
|
||||
$this->assertSame('cache:clear', ScheduleCommandValidator::extractArtisanCommandName(' cache:clear --tags=a '));
|
||||
$this->assertNull(ScheduleCommandValidator::extractArtisanCommandName(' '));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Artisan — 파서 분기 (KVE-2026-1679 추가 발견분)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 검증기가 본 이름과 Symfony 가 해석하는 이름이 갈리는 입력은 거부한다.
|
||||
*
|
||||
* 검증기는 `preg_split` 첫 토큰을 명령명으로 봤지만 실행부는 문자열 전체를
|
||||
* StringInput 으로 재파싱했다. 따옴표·백슬래시·선행 옵션 한 쌍이면 차단목록에 있는
|
||||
* `tinker` 가 그대로 실행됐다. 판정이 거부라는 것뿐 아니라, 명령명 추출이
|
||||
* **다른 이름을 돌려주지 않는 것**까지 단언한다 — 이름이 돌아오면 그 이름으로 실행하는
|
||||
* 호출부가 언제든 다시 생길 수 있다.
|
||||
*
|
||||
* @param string $command 파서 분기를 유발하는 command
|
||||
* @param string $symfonyName Symfony 가 실제로 해석하는 명령명
|
||||
*
|
||||
* @scenario command_class=parser_divergent, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects parser_divergent_input_is_rejected, extracted_name_equals_executed_name
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('parserDivergenceArtisanCommandProvider')]
|
||||
public function it_blocks_artisan_commands_whose_parsed_name_diverges(string $command, string $symfonyName): void
|
||||
{
|
||||
$this->assertFalse(
|
||||
ScheduleCommandValidator::isArtisanCommandAllowed($command),
|
||||
"파서 분기 입력이 통과함: {$command}"
|
||||
);
|
||||
|
||||
$this->assertNull(
|
||||
ScheduleCommandValidator::extractArtisanCommandName($command),
|
||||
"정규 형태가 아닌 입력에서 명령명이 반환됨 (Symfony 는 '{$symfonyName}' 으로 해석): {$command}"
|
||||
);
|
||||
|
||||
$this->assertNull(
|
||||
ScheduleCommandValidator::resolveArtisanCommand($command),
|
||||
"파서 분기 입력에 실행 계획이 만들어짐: {$command}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파서 분기 입력의 거부 사유는 형식 오류로 보고된다.
|
||||
*
|
||||
* @scenario command_class=malformed, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects rejection_reason_is_reported_per_category
|
||||
*/
|
||||
#[Test]
|
||||
public function it_reports_malformed_as_the_reason_for_parser_divergent_input(): void
|
||||
{
|
||||
$verdict = ScheduleCommandValidator::inspectArtisanCommand('"tinker" --execute=1');
|
||||
|
||||
$this->assertFalse($verdict['allowed']);
|
||||
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_MALFORMED, $verdict['reason']);
|
||||
$this->assertNull($verdict['name']);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Artisan — 허용목록 / 옵션 / 인자
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 허용목록에 없는 명령은 무해하더라도 거부한다 (허용목록 전환의 증명).
|
||||
*
|
||||
* @param string $command 허용목록 밖 command
|
||||
*
|
||||
* @scenario command_class=core_not_allowlisted, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects commands_outside_the_allowlist_are_rejected
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('notAllowlistedArtisanCommandProvider')]
|
||||
public function it_blocks_artisan_commands_outside_the_allowlist(string $command): void
|
||||
{
|
||||
$verdict = ScheduleCommandValidator::inspectArtisanCommand($command);
|
||||
|
||||
$this->assertFalse($verdict['allowed'], "허용목록 밖 명령이 통과함: {$command}");
|
||||
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_NOT_ALLOWED, $verdict['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용된 명령이라도 선언되지 않은 옵션·위치 인자·단축 옵션은 거부한다.
|
||||
*
|
||||
* @param string $command 차단되어야 하는 command
|
||||
* @param string $reason 기대 거부 사유 코드
|
||||
*
|
||||
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects undeclared_options_and_positional_arguments_are_rejected
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('blockedOptionArtisanCommandProvider')]
|
||||
public function it_blocks_allowlisted_commands_with_denied_usage(string $command, string $reason): void
|
||||
{
|
||||
$verdict = ScheduleCommandValidator::inspectArtisanCommand($command);
|
||||
|
||||
$this->assertFalse($verdict['allowed'], "거부되어야 하는 사용법이 통과함: {$command}");
|
||||
$this->assertSame($reason, $verdict['reason'], "거부 사유가 다름: {$command}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용된 명령은 (명령명, 인자배열) 실행 계획으로 변환된다.
|
||||
*
|
||||
* 이 배열을 `Artisan::call()` 에 넘겨야 StringInput 재파싱 경로가 사라진다.
|
||||
*
|
||||
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects allowed_command_resolves_to_name_and_parameter_array
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_the_execution_plan_for_allowed_commands(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['name' => 'queue:work', 'parameters' => ['--stop-when-empty' => true, '--max-time' => '300']],
|
||||
ScheduleCommandValidator::resolveArtisanCommand('queue:work --stop-when-empty --max-time=300')
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
['name' => 'cache:clear', 'parameters' => []],
|
||||
ScheduleCommandValidator::resolveArtisanCommand('cache:clear')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단목록은 허용목록보다 먼저 평가된다 (최종 거부권).
|
||||
*
|
||||
* 허용목록에 올라 있어도 차단목록에 있으면 거부되어야 한다 — 확장이 코어 명령 이름을
|
||||
* 가로채 등록하는 경우를 같은 규칙으로 막기 위함이다.
|
||||
*
|
||||
* @scenario command_class=denylisted, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects denylist_is_evaluated_before_the_allowlist
|
||||
*/
|
||||
#[Test]
|
||||
public function it_evaluates_the_denylist_before_the_allowlist(): void
|
||||
{
|
||||
config(['schedule_security.artisan.denylist' => ['cache:clear']]);
|
||||
|
||||
$verdict = ScheduleCommandValidator::inspectArtisanCommand('cache:clear');
|
||||
|
||||
$this->assertFalse($verdict['allowed']);
|
||||
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_DENIED, $verdict['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* `make:` 접두사는 41개 명령을 개별 나열 없이 일괄 차단한다.
|
||||
*
|
||||
* @scenario command_class=code_generating, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects make_prefix_blocks_all_code_generating_commands
|
||||
*/
|
||||
#[Test]
|
||||
public function it_blocks_every_command_matching_a_denylisted_prefix(): void
|
||||
{
|
||||
foreach (['make:migration', 'make:command', 'make:controller'] as $command) {
|
||||
$verdict = ScheduleCommandValidator::inspectArtisanCommand($command);
|
||||
|
||||
$this->assertFalse($verdict['allowed'], "접두사 차단이 적용되지 않음: {$command}");
|
||||
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_DENIED, $verdict['reason']);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Artisan — 확장 소유 명령 계층
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 게이트가 켜져 있으면 설치된 확장이 소유한 명령을 자동으로 허용한다.
|
||||
*
|
||||
* 이름 패턴이 아니라 등록된 명령 인스턴스의 클래스 네임스페이스로 판정하므로,
|
||||
* `{확장}:{작업}` 형태를 따르지 않는 확장 명령도 함께 허용된다.
|
||||
*
|
||||
* @scenario command_class=extension_owned, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects extension_owned_commands_are_allowed_by_namespace
|
||||
*/
|
||||
#[Test]
|
||||
public function it_allows_commands_owned_by_installed_extensions(): void
|
||||
{
|
||||
$command = $this->registerExtensionCommand();
|
||||
|
||||
$this->assertTrue(
|
||||
ScheduleCommandValidator::isArtisanCommandAllowed($command),
|
||||
"확장 소유 명령이 차단됨: {$command}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게이트를 끄면 확장 소유 명령도 거부된다 (설정으로 좁힐 수 있어야 한다).
|
||||
*
|
||||
* @scenario command_class=extension_owned, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects extension_gate_can_be_turned_off
|
||||
*/
|
||||
#[Test]
|
||||
public function it_blocks_extension_commands_when_the_gate_is_disabled(): void
|
||||
{
|
||||
$command = $this->registerExtensionCommand();
|
||||
|
||||
config(['schedule_security.artisan.allow_extension_commands' => false]);
|
||||
|
||||
$verdict = ScheduleCommandValidator::inspectArtisanCommand($command);
|
||||
|
||||
$this->assertFalse($verdict['allowed'], "게이트를 껐는데 확장 명령이 통과함: {$command}");
|
||||
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_NOT_ALLOWED, $verdict['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 소유 명령이라도 자기 정의에 없는 옵션은 거부한다.
|
||||
*
|
||||
* @scenario command_class=extension_owned, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects extension_commands_only_accept_their_own_options
|
||||
*/
|
||||
#[Test]
|
||||
public function it_blocks_undefined_options_on_extension_commands(): void
|
||||
{
|
||||
$command = $this->registerExtensionCommand();
|
||||
|
||||
$verdict = ScheduleCommandValidator::inspectArtisanCommand($command.' --g7-not-a-real-option');
|
||||
|
||||
$this->assertFalse($verdict['allowed']);
|
||||
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_OPTION, $verdict['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 네임스페이스를 비워도 코어 허용목록은 그대로 동작한다 (계층 독립성).
|
||||
*
|
||||
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects extension_gate_can_be_turned_off
|
||||
*/
|
||||
#[Test]
|
||||
public function it_keeps_core_allowlist_working_without_extension_namespaces(): void
|
||||
{
|
||||
config(['schedule_security.artisan.extension_namespaces' => []]);
|
||||
|
||||
$this->assertTrue(ScheduleCommandValidator::isArtisanCommandAllowed('cache:clear'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Artisan — config 불변식
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 허용목록과 차단목록은 겹치지 않아야 한다.
|
||||
*
|
||||
* 겹치면 거부권이 허용목록 항목을 조용히 무력화해, 설정만 보고는 무엇이 실행 가능한지
|
||||
* 알 수 없게 된다.
|
||||
*
|
||||
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects allowlist_and_denylist_stay_disjoint
|
||||
*/
|
||||
#[Test]
|
||||
public function it_keeps_the_allowlist_and_denylist_disjoint(): void
|
||||
{
|
||||
$allowlist = array_map('strtolower', array_keys((array) config('schedule_security.artisan.allowlist')));
|
||||
$denylist = array_map('strtolower', (array) config('schedule_security.artisan.denylist'));
|
||||
|
||||
$this->assertSame([], array_values(array_intersect($allowlist, $denylist)));
|
||||
|
||||
foreach ((array) config('schedule_security.artisan.denylist_prefixes') as $prefix) {
|
||||
foreach ($allowlist as $name) {
|
||||
$this->assertStringStartsNotWith(
|
||||
strtolower((string) $prefix),
|
||||
$name,
|
||||
"차단 접두사가 허용목록 항목을 무력화함: {$name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용목록의 명령과 옵션은 실제로 등록된 시그니처에 존재해야 한다.
|
||||
*
|
||||
* 오타나 시그니처 변경으로 허용 옵션이 실체와 어긋나면, 정상 운영 스케줄이
|
||||
* 저장 시점에 거부되거나 반대로 존재하지 않는 옵션이 허용된 것처럼 보인다.
|
||||
*
|
||||
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
|
||||
*
|
||||
* @effects allowlist_matches_real_command_signatures
|
||||
*/
|
||||
#[Test]
|
||||
public function it_keeps_the_allowlist_in_sync_with_real_command_signatures(): void
|
||||
{
|
||||
$registered = Artisan::all();
|
||||
|
||||
foreach ((array) config('schedule_security.artisan.allowlist') as $name => $spec) {
|
||||
$this->assertArrayHasKey($name, $registered, "허용목록에 없는 명령이 등재됨: {$name}");
|
||||
|
||||
$definition = $registered[$name]->getDefinition();
|
||||
|
||||
foreach ((array) ($spec['options'] ?? []) as $option) {
|
||||
$this->assertTrue(
|
||||
$definition->hasOption($option),
|
||||
"허용 옵션이 실제 시그니처에 없음: {$name} --{$option}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 소유 명령 계층 검증에 쓸 명령을 레지스트리에 등록하고 그 이름을 돌려줍니다.
|
||||
*
|
||||
* 설치된 확장 중 하나를 골라 쓰면 확장 구성에 따라 테스트가 조용히 skip 되어
|
||||
* 계층 자체가 검증되지 않는다. `Modules\` 네임스페이스의 픽스처를 실제 레지스트리에
|
||||
* 등록해 판정 결과를 고정한다 — 판정은 이름이 아니라 등록된 인스턴스의
|
||||
* 클래스 네임스페이스로 이뤄지므로 이 등록이 실제 확장과 동일한 경로를 탄다.
|
||||
*/
|
||||
private function registerExtensionCommand(): string
|
||||
{
|
||||
require_once __DIR__.'/../../Fixtures/Extension/FakeExtensionScheduleCommand.php';
|
||||
|
||||
$command = new FakeExtensionScheduleCommand;
|
||||
|
||||
Artisan::registerCommand($command);
|
||||
|
||||
return $command->getName();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Data Providers
|
||||
// ========================================================================
|
||||
@@ -259,6 +590,19 @@ class ScheduleCommandValidatorTest extends TestCase
|
||||
'env:decrypt' => ['env:decrypt'],
|
||||
'schedule:run' => ['schedule:run'],
|
||||
'빈 명령' => [''],
|
||||
// 보고서 2단계 체인 — 코드 생성 후 실행
|
||||
'make:migration — 접두사 차단' => ['make:migration evil --create=x --path=/tmp/g7 --realpath'],
|
||||
'migrate — 임의 경로 실행' => ['migrate --path=/tmp/g7 --realpath --force'],
|
||||
'db:seed — 임의 클래스 실행' => ['db:seed --class=Evil'],
|
||||
'test' => ['test'],
|
||||
'serve' => ['serve'],
|
||||
'core:update' => ['core:update'],
|
||||
'extension:composer-install' => ['extension:composer-install'],
|
||||
'playwright:issue-token' => ['playwright:issue-token'],
|
||||
'invoke-serialized-closure' => ['invoke-serialized-closure'],
|
||||
'stub:publish' => ['stub:publish'],
|
||||
'vendor:publish' => ['vendor:publish'],
|
||||
'migrate:refresh — 기존 목록 누락분' => ['migrate:refresh'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -273,6 +617,64 @@ class ScheduleCommandValidatorTest extends TestCase
|
||||
'cache:clear' => ['cache:clear'],
|
||||
'인자 포함' => ['queue:work --stop-when-empty'],
|
||||
'inspire' => ['inspire'],
|
||||
'optimize:clear' => ['optimize:clear'],
|
||||
'값 있는 옵션' => ['queue:prune-batches --hours=48'],
|
||||
'옵션 2개' => ['queue:work --stop-when-empty --max-time=300'],
|
||||
'seo:warmup --layout' => ['seo:warmup --layout=home'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 무해하지만 허용목록에 없어 거부되어야 하는 artisan command 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function notAllowlistedArtisanCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'route:list — 정보 노출' => ['route:list'],
|
||||
'about — 환경 정보 노출' => ['about'],
|
||||
'queue:failed' => ['queue:failed'],
|
||||
'storage:link' => ['storage:link'],
|
||||
'schedule:list' => ['schedule:list'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증기와 Symfony 의 해석이 갈리는 command 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function parserDivergenceArtisanCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'큰따옴표 위장' => ['"tinker" --execute=1', 'tinker'],
|
||||
'작은따옴표 위장' => ["'tinker' --execute=1", 'tinker'],
|
||||
'백슬래시 위장' => ['tin\\ker --execute=1', 'tinker'],
|
||||
'선행 옵션으로 명령명 밀어내기' => ['--env=local tinker', 'tinker'],
|
||||
'선행 옵션 + 마이그레이션 체인' => ['--no-interaction migrate --path=/tmp/x --realpath', 'migrate'],
|
||||
'따옴표로 허용목록 위장' => ['"cache:clear" --execute=1', 'cache:clear'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용목록에 있으나 사용법이 거부되어야 하는 command 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function blockedOptionArtisanCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'미허용 옵션 (cache:clear --tags)' => ['cache:clear --tags=a', ScheduleCommandValidator::ARTISAN_REASON_OPTION],
|
||||
'워커 상주 (--daemon)' => ['queue:work --daemon', ScheduleCommandValidator::ARTISAN_REASON_OPTION],
|
||||
'안전장치 무력화 (--force)' => ['queue:work --force', ScheduleCommandValidator::ARTISAN_REASON_OPTION],
|
||||
'정의에 없는 옵션' => ['optimize:clear --whatever', ScheduleCommandValidator::ARTISAN_REASON_OPTION],
|
||||
'경로 지정 옵션 (model:prune --path)' => ['model:prune --path=/tmp/g7', ScheduleCommandValidator::ARTISAN_REASON_OPTION],
|
||||
'클래스 지정 옵션 (model:prune --model)' => ['model:prune --model=App\\\\Models\\\\User', ScheduleCommandValidator::ARTISAN_REASON_MALFORMED],
|
||||
'단축 옵션' => ['cache:clear -v', ScheduleCommandValidator::ARTISAN_REASON_MALFORMED],
|
||||
'위치 인자' => ['cache:clear redis', ScheduleCommandValidator::ARTISAN_REASON_ARGUMENT],
|
||||
'중복 옵션' => ['queue:work --tries=1 --tries=9', ScheduleCommandValidator::ARTISAN_REASON_MALFORMED],
|
||||
'옵션 구분자 단독' => ['cache:clear --', ScheduleCommandValidator::ARTISAN_REASON_MALFORMED],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# audit:allow test-scenario-coverage reason: 본 매니페스트는 인스톨러 후속 보안 감사로 발견된 5개 결함 패치의 시나리오 매트릭스 SSoT 로 기록. 회귀 가드는 test_files (InstallerSecurityHardeningTest 16건) + manual_verification 의 양방향 PoC 로 커버.
|
||||
# audit:allow test-scenario-coverage reason: 본 매니페스트는 인스톨러 보안 패치의 시나리오 매트릭스 SSoT 로 기록. 회귀 가드는 test_files (InstallerSecurityHardening / ComposerPathValidation / BinaryPathPolicy) + manual_verification 의 양방향 PoC 로 커버하며, payload_class 19종 × vector 9종은 DataProvider 전수 전개라 조합별 docblock 마킹과 1:1 대응하지 않는다.
|
||||
|
||||
feature: 인스톨러 후속 보안 강화 (composer 명령 / 확장 ID / pending 경로 / .env 라인 주입 / DSN 파라미터)
|
||||
|
||||
@@ -15,9 +15,23 @@ description: |
|
||||
- escapeEnvValue 가 개행 미escape → DB 비밀번호 등 입력에 \n 포함 시 .env 라인 주입.
|
||||
- PDO DSN 구성 시 host/port/database 에 ;= 등 메타문자 미차단 → DSN 파라미터 주입.
|
||||
|
||||
KVE-2026-1677 (2026-08-04 추가):
|
||||
- Composer 칸의 공백 분리 입력에서 둘째 토큰이 인자 자리인데 문자 블랙리스트가
|
||||
`-` `(` `)` `.` `/` 를 모두 허용해 닫히지 않았다. escapeshellarg 는 "하나의 인자"
|
||||
임만 보장할 뿐 그 인자가 실행 파일인지 스크립트인지는 보장하지 않는다.
|
||||
실측: 명령 끝의 ` --version` 은 스크립트 인자로 넘어가므로
|
||||
`PHP경로 <임의스크립트>` 는 그 스크립트를 그대로 실행한다(마커 파일로 확인).
|
||||
`-rCODE` 는 이 호출 지점에서는 `--version` 이 CLI 를 단락시켜 실행되지 않지만,
|
||||
` --version` 을 붙이지 않는 설치 워커 경로에서는 실행되므로 자리 자체를 닫는다.
|
||||
- task-runner 의 getPhpBinary() 가 저장된 php_binary 를 검증 없이 실행 바이너리로
|
||||
반환하고 있었다.
|
||||
- 판정을 공용 정책(public/install/includes/binary-path-policy.php)으로 모아
|
||||
인스톨러 API·설치 워커·저장 시점이 같은 규칙을 쓰게 정렬했다. stat 은 호출하지
|
||||
않는다 — #361 의 open_basedir 회귀를 재발시키지 않기 위함이다.
|
||||
|
||||
axes:
|
||||
vector: [getComposerCommand, getComposerCommandForDisplay, save_extensions_id, check_core_pending_path, escapeEnvValue, getDatabaseConnection]
|
||||
payload_class: [empty, normal, shell_meta_space, shell_meta_semicolon, shell_meta_backtick, shell_meta_dollarparen, shell_meta_pipe, shell_meta_redirect, newline_lf, newline_crlf, parent_traversal, null_byte, dsn_semicolon, dsn_equals]
|
||||
vector: [getComposerCommand, getComposerCommandForDisplay, save_extensions_id, check_core_pending_path, escapeEnvValue, getDatabaseConnection, getPhpBinary, validatePhpPath, validateComposerPath, handleStep3Post]
|
||||
payload_class: [empty, normal, shell_meta_space, shell_meta_semicolon, shell_meta_backtick, shell_meta_dollarparen, shell_meta_pipe, shell_meta_redirect, newline_lf, newline_crlf, parent_traversal, null_byte, dsn_semicolon, dsn_equals, argument_injection_dash_r, interpreter_plus_script, relative_path, hash_comment_phar, wrapper_script_name]
|
||||
expected_outcome: [accept_normal, fall_back_to_default, return_invalid_message, throw_pdo_exception, strip_silently]
|
||||
|
||||
exclusions:
|
||||
@@ -68,8 +82,30 @@ effects:
|
||||
- dsn_throws_for_null_byte_in_any_param
|
||||
- dsn_accepts_normal_localhost_3306
|
||||
|
||||
# KVE-2026-1677: 실행 바이너리 경로의 인자 자리 차단
|
||||
- argument_injection_never_reaches_exec
|
||||
- leading_hyphen_token_is_rejected
|
||||
- php_slot_rejects_leading_hyphen
|
||||
- relative_and_traversal_paths_are_rejected
|
||||
- legitimate_paths_still_reach_exec
|
||||
- getPhpBinary_falls_back_to_default_for_disallowed_shape
|
||||
- getPhpBinary_passes_through_legitimate_paths
|
||||
- getComposerCommand_rejects_interpreter_plus_arbitrary_script
|
||||
- getComposerCommand_keeps_multi_php_pair_support
|
||||
- step3_post_rejects_disallowed_binary_paths_before_save
|
||||
- step3_post_accepts_legitimate_binary_paths
|
||||
|
||||
# E2E axis 없음: 인스톨러는 템플릿 엔진(레이아웃 JSON / 컴포넌트 TSX / 핸들러 TS)을 쓰지
|
||||
# 않는 독립 PHP 화면이고, 이번 변경은 전부 서버측 판정(정책 함수 · API · 설치 워커 ·
|
||||
# 저장 시점)이다. Playwright 스위트는 설치가 끝난 애플리케이션을 대상으로 하며 설치
|
||||
# 과정 자체를 구동하지 않으므로 (frontend-change-requires-e2e 룰의 대상이 아님)
|
||||
# 양방향 확인은 아래 manual_verification 의 PoC 가 담당한다.
|
||||
|
||||
test_files:
|
||||
- tests/Unit/Installer/InstallerSecurityHardeningTest.php
|
||||
- tests/Unit/Installer/ComposerPathValidationTest.php
|
||||
- tests/Unit/Installer/BinaryPathPolicyTest.php
|
||||
- tests/Unit/Installer/InstallerStep3BinaryPathStoreGuardTest.php
|
||||
|
||||
manual_verification:
|
||||
- description: "Critical-1 양방향 PoC — composer_binary 셸 페이로드"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
feature: 언어팩 PHP 구문 화이트리스트 및 활성화 권한 경계
|
||||
|
||||
description: |
|
||||
언어팩의 PHP 파일은 활성화되면 fallback 경로에 배선되어 `require` 된다. 즉 파일 내용이
|
||||
그대로 실행되며, `Accept-Language: {locale}` 요청 한 번이면 발화한다.
|
||||
|
||||
기존 방어는 두 지점 모두 뚫려 있었다.
|
||||
- 내용 검사: 위험 함수 11개를 정규식으로 나열한 블록리스트 —
|
||||
`file_put_contents` `fopen` `call_user_func` `assert` 백틱 등이 목록에 없어 통과했고,
|
||||
`('system')('id')` 처럼 함수 이름을 아예 쓰지 않는 형태는 원리적으로 잡을 수 없었다.
|
||||
- 권한 경계: 설치(install)와 활성화(manage)는 별도 권한인데, 설치 요청의
|
||||
`auto_activate` 플래그가 `activate()` 를 우회하고 `status=Active` 를 직접 기록해
|
||||
install 권한만으로 활성화가 성립했다. 활성 승격이 `require` 배선의 필수 조건이므로
|
||||
이 경계가 체인의 마지막 관문이다.
|
||||
|
||||
본 기능은 판정을 뒤집는다.
|
||||
- 내용: "번역 배열만" 통과시키는 토큰 상태기계 (함수 호출은 이름과 무관하게 전부 거부)
|
||||
- 형식·위치: 확장자 화이트리스트(json/php/md, 대소문자 무관) + PHP 위치 강제 + 심볼릭 링크 거부
|
||||
- 권한: 외부 소스(파일·URL·GitHub)의 `auto_activate` 에 활성화 권한을 필드 단위로 요구
|
||||
- 활성화 경로 단일화: 설치 트랜잭션은 항상 installed 로 기록하고 activate() 를 경유
|
||||
|
||||
axes:
|
||||
vector: [php_syntax_validator, assert_security_rules, install_from_untrusted_source, install_from_bundled, finalize_install_activation]
|
||||
actor_permission: [cli_system, install_only, manage_only, install_and_manage]
|
||||
|
||||
cross_product: |
|
||||
vector × actor_permission 이 핵심이다. 같은 판정이라도 어느 계층에 걸리느냐에 따라
|
||||
깨뜨리는 것이 다르다 — 권한 검사를 서비스 계층에 두면 CLI(`language-pack:install`,
|
||||
`language-pack:provision`)와 확장 설치 cascade 가 깨지고, FormRequest 의 `authorize()`
|
||||
에 두면 `auto_activate` 를 보내지 않은 정상 설치까지 403 이 된다.
|
||||
|
||||
페이로드 형태(문자열 리터럴 호출 / 가변 변수 / heredoc / 닫는 태그 뒤 코드 / 대문자 확장자
|
||||
/ phar / 잘못된 locale 디렉토리 / 심볼릭 링크)는 별도 axis 로 두지 않는다. 전부 같은
|
||||
문법 규칙 하나 또는 같은 파일 정책 하나에서 갈리므로 axis 로 두면 조합만 늘고 판정
|
||||
지점은 하나다. 대신 DataProvider 로 전수 전개하고 effects 로 대표한다.
|
||||
|
||||
exclusions:
|
||||
- { vector: php_syntax_validator, actor_permission: install_only, reason: "프레임워크 의존이 없는 순수 판정 유틸 — 권한 컨텍스트가 존재하지 않는다" }
|
||||
- { vector: php_syntax_validator, actor_permission: manage_only, reason: "동일 — 권한 개념 없음" }
|
||||
- { vector: php_syntax_validator, actor_permission: install_and_manage, reason: "동일 — 권한 개념 없음" }
|
||||
- { vector: assert_security_rules, actor_permission: install_only, reason: "서비스 계층의 내용·형식 검사로 권한과 무관하게 모든 설치 경로에 동일 적용" }
|
||||
- { vector: assert_security_rules, actor_permission: manage_only, reason: "동일 — 권한별 분기 없음" }
|
||||
- { vector: assert_security_rules, actor_permission: install_and_manage, reason: "동일 — 권한별 분기 없음" }
|
||||
- { vector: install_from_untrusted_source, actor_permission: cli_system, reason: "CLI 는 FormRequest 를 거치지 않는다 — 권한 게이트를 서비스에 두지 않기로 한 설계의 직접 귀결이며 CLI 비파괴는 별도 커맨드 테스트가 담당" }
|
||||
- { vector: install_from_bundled, actor_permission: manage_only, reason: "설치 권한이 없어 라우트 미들웨어에서 끊긴다 — 외부 소스 쪽에서 같은 경계를 이미 단언하므로 번들에서 반복하지 않는다" }
|
||||
- { vector: install_from_bundled, actor_permission: cli_system, reason: "동일 — FormRequest 미경유" }
|
||||
- { vector: finalize_install_activation, actor_permission: install_only, reason: "서비스 계층 활성화 경로로 HTTP 권한 컨텍스트가 없다 — 권한 판정은 FormRequest 계층이 소유" }
|
||||
- { vector: finalize_install_activation, actor_permission: manage_only, reason: "동일 — 권한 컨텍스트 없음" }
|
||||
- { vector: finalize_install_activation, actor_permission: install_and_manage, reason: "동일 — 권한 컨텍스트 없음" }
|
||||
|
||||
effects:
|
||||
# 구문 화이트리스트
|
||||
- translation_array_files_are_accepted
|
||||
- function_calls_are_rejected_regardless_of_name
|
||||
- name_free_bypasses_are_rejected
|
||||
- broken_and_oversized_sources_are_rejected_early
|
||||
- violation_line_is_reported
|
||||
# 파일 정책
|
||||
- install_rejects_php_that_is_not_a_translation_array
|
||||
- install_rejects_files_outside_the_extension_whitelist
|
||||
- install_rejects_php_outside_the_declared_locale_directory
|
||||
- install_rejects_packages_containing_symlinks
|
||||
- install_accepts_real_world_translation_files
|
||||
- every_bundled_pack_passes_the_new_validator
|
||||
# 권한 경계
|
||||
- install_only_user_cannot_auto_activate
|
||||
- install_without_auto_activate_is_not_blocked
|
||||
- manage_user_can_auto_activate
|
||||
- manage_only_user_cannot_install_at_all
|
||||
- overwriting_an_active_pack_does_not_inherit_active_status
|
||||
# 활성화 경로 단일화
|
||||
- activated_hook_fires_exactly_once_on_auto_activate
|
||||
- auto_activate_skipped_when_slot_occupied
|
||||
- auto_activate_goes_through_activate_and_enforces_dependencies
|
||||
- update_of_an_active_pack_keeps_it_active
|
||||
- update_of_an_inactive_pack_does_not_promote_it
|
||||
|
||||
# E2E axis 없음: 판정은 전부 서버측(FormRequest / Service)이다. 관리자 화면에서
|
||||
# auto_activate 를 보내는 곳은 번들 재설치 모달 하나뿐이고, 게이트를 네 경로 전부에
|
||||
# 걸기로 하면서(PO 결정 2026-08-04) 이 모달이 보내는 값을 운영자의 활성화 권한
|
||||
# (`abilities.can_activate`)으로 분기하도록 바꿨다 — 권한이 없으면 auto_activate 를
|
||||
# 보내지 않아 재설치는 그대로 되고 팩만 installed 로 내려간다.
|
||||
#
|
||||
# 이 분기는 표현식 한 줄이고 관측 지점이 "요청 본문에 무엇이 실렸는가" 하나뿐이라,
|
||||
# 브라우저를 띄우는 것보다 서버측 권한 조합 테스트가 같은 것을 더 촘촘히 고정한다
|
||||
# (install_only / install_and_manage 두 조합 모두 단언). 렌더 결과·조작 흐름이
|
||||
# 달라지지 않으므로 E2E axis 를 두지 않는다.
|
||||
|
||||
test_files:
|
||||
- tests/Unit/Services/LanguagePack/LanguagePackPhpArrayValidatorTest.php
|
||||
- tests/Unit/Services/LanguagePack/LanguagePackSecurityTest.php
|
||||
- tests/Unit/Services/LanguagePack/LanguagePackServiceTest.php
|
||||
- tests/Feature/Api/Admin/LanguagePackControllerTest.php
|
||||
@@ -0,0 +1,94 @@
|
||||
feature: 예약 작업 Artisan 명령 허용목록 전환 및 파서 일치
|
||||
|
||||
description: |
|
||||
스케줄의 artisan 타입은 "기본 허용, 차단목록만 거부" 정책이었다. 등록 명령 238개 중
|
||||
차단은 9개뿐이었고, `migrate:refresh` 가 목록에서 빠져 있는 등 목록 관리 방식이
|
||||
실패한다는 실증이 코드 안에 이미 있었다. 스케줄 생성·실행 권한을 위임받은 계정이
|
||||
`make:migration` 으로 임의 경로에 PHP 파일을 만들고 `migrate --path=` 로 실행하는
|
||||
2단계 체인이 성립했다.
|
||||
|
||||
검증 과정에서 보고서보다 짧은 경로를 추가로 발견했다. 검증기는 `preg_split` 의 첫
|
||||
토큰을 명령명으로 봤지만 실행부는 `Artisan::call($command, [], ...)` 로 문자열째
|
||||
넘겼고, parameters 가 비면 Laravel 이 문자열 전체를 StringInput 으로 재파싱한다.
|
||||
그래서 따옴표·백슬래시·선행 옵션 한 쌍이면 차단목록에 있는 `tinker` 가 그대로
|
||||
실행됐다(`"tinker" --execute=…`). 저장 시점과 실행 시점이 같은 추출기를 공유하므로
|
||||
이중 검증이 둘 다 뚫렸다.
|
||||
|
||||
본 기능은 세 가지를 함께 바꾼다.
|
||||
- 판정 전환: 3계층(차단목록 최종 거부권 → 코어 정적 허용목록 → 확장 소유 자동 허용)
|
||||
- 사용법 제한: 허용목록 항목마다 허용 옵션 선언, 위치 인자·단축 옵션 거부
|
||||
- 파서 일치: 엄격 파서로만 해석하고 실행은 (명령명, 인자배열)로 — 재해석 지점 제거
|
||||
|
||||
허용목록으로 바꾸는 것만으로는 해소되지 않는다. 같은 추출기를 쓰면 `"cache:clear"`
|
||||
로 위장한 뒤 다른 명령이 실행되는 형태로 똑같이 우회되기 때문이다.
|
||||
|
||||
axes:
|
||||
command_class: [core_allowlisted, core_not_allowlisted, denylisted, code_generating, extension_owned, parser_divergent, malformed]
|
||||
enforcement_point: [validator_unit, store, update, run]
|
||||
|
||||
cross_product: |
|
||||
command_class × enforcement_point 이 핵심이다. 저장 시점과 실행 시점이 같은 판정
|
||||
함수를 공유해야 하고(한쪽만 고치면 DB 직접 수정이 우회로가 된다), 명령 부류마다
|
||||
거부 사유가 달라 운영자에게 보이는 메시지가 갈린다.
|
||||
|
||||
옵션 형태(허용 옵션 / 미허용 옵션 / 위치 인자 / 단축 옵션)는 별도 axis 로 두지 않는다.
|
||||
명령명 자체가 거부되는 부류에서는 옵션 형태가 결과를 바꾸지 않고, 허용목록 명령에
|
||||
대해서만 의미가 있어 축이 비대칭이 된다. 대신 DataProvider 로 전수 전개하고
|
||||
effects `undeclared_options_and_positional_arguments_are_rejected` 로 대표한다.
|
||||
|
||||
파서 분기 4종(큰따옴표 / 작은따옴표 / 백슬래시 / 선행 옵션)도 같은 이유로 축이 아니라
|
||||
DataProvider 다 — 넷 다 엄격 파서의 같은 규칙 하나에서 걸린다.
|
||||
|
||||
exclusions:
|
||||
- { command_class: extension_owned, enforcement_point: store, reason: "확장 소유 여부는 validator 단일 지점에서 갈린다 — HTTP 계층은 같은 판정 함수를 호출할 뿐 별도 분기가 없다" }
|
||||
- { command_class: extension_owned, enforcement_point: update, reason: "동일 — 판정 분기 없음" }
|
||||
- { command_class: extension_owned, enforcement_point: run, reason: "동일 — 판정 분기 없음" }
|
||||
- { command_class: code_generating, enforcement_point: run, reason: "저장 시점에 거부되어 DB 에 남지 않는다. 실행 시점의 DB 직접 주입 방어는 parser_divergent·core_not_allowlisted 가 대표" }
|
||||
- { command_class: malformed, enforcement_point: run, reason: "동일 — 실행 시점 대표는 parser_divergent (형식 오류의 대표 사례)" }
|
||||
- { command_class: core_allowlisted, enforcement_point: update, reason: "update 의 고유 축은 '요청에 type 이 없을 때 저장된 타입으로 폴백' 배선이며 판정 자체는 store 와 같은 Rule — 대표 1건(code_generating)으로 검증" }
|
||||
- { command_class: core_not_allowlisted, enforcement_point: update, reason: "동일 — 대표 1건으로 검증" }
|
||||
- { command_class: denylisted, enforcement_point: update, reason: "동일 — 대표 1건으로 검증" }
|
||||
- { command_class: parser_divergent, enforcement_point: update, reason: "동일 — 대표 1건으로 검증" }
|
||||
- { command_class: malformed, enforcement_point: update, reason: "동일 — 대표 1건으로 검증" }
|
||||
|
||||
effects:
|
||||
# 판정 계층
|
||||
- allowlisted_maintenance_commands_still_run
|
||||
- commands_outside_the_allowlist_are_rejected
|
||||
- denylist_blocks_destructive_and_code_executing_commands
|
||||
- denylist_is_evaluated_before_the_allowlist
|
||||
- make_prefix_blocks_all_code_generating_commands
|
||||
- extension_owned_commands_are_allowed_by_namespace
|
||||
- extension_gate_can_be_turned_off
|
||||
- extension_commands_only_accept_their_own_options
|
||||
# 사용법 제한
|
||||
- undeclared_options_and_positional_arguments_are_rejected
|
||||
- allowed_command_resolves_to_name_and_parameter_array
|
||||
- rejection_reason_is_reported_per_category
|
||||
# 파서 일치
|
||||
- parser_divergent_input_is_rejected
|
||||
- extracted_name_equals_executed_name
|
||||
- run_dispatches_artisan_command_as_parsed_parameters
|
||||
# 설정 불변식
|
||||
- allowlist_and_denylist_stay_disjoint
|
||||
- allowlist_matches_real_command_signatures
|
||||
# 엔드투엔드 차단
|
||||
- store_rejects_artisan_command_outside_allowlist
|
||||
- store_rejects_parser_divergent_artisan_command
|
||||
- store_rejects_allowlisted_command_with_denied_option
|
||||
- store_allows_allowlisted_command_with_allowed_option
|
||||
- store_rejects_malformed_artisan_command
|
||||
- update_falls_back_to_the_stored_type_when_the_request_omits_it
|
||||
- run_blocks_artisan_schedule_outside_allowlist
|
||||
- run_blocks_parser_divergent_artisan_schedule
|
||||
- run_blocks_denylisted_artisan_schedule
|
||||
- parser_divergent_payload_never_reaches_artisan
|
||||
- low_privilege_operator_cannot_stage_migration_rce_chain
|
||||
|
||||
# E2E axis 없음: 판정은 전부 서버측이고 레이아웃 JSON·컴포넌트 TSX·엔진 TS 변경이 없다.
|
||||
# 예약 작업 관리 화면은 `config/core.php` 에서 is_active=false 로 비노출 상태라
|
||||
# 브라우저 경로 자체가 존재하지 않는다 (frontend-change-requires-e2e 룰의 대상이 아님).
|
||||
|
||||
test_files:
|
||||
- tests/Unit/Support/ScheduleCommandValidatorTest.php
|
||||
- tests/Feature/Security/ScheduleCommandGuardTest.php
|
||||
Reference in New Issue
Block a user