fix(security): outbound URL 내부주소·host 검증 전수 강화

서버가 외부 입력으로 목적지가 제어되는 outbound HTTP 요청을 보내는 지점을
전수 조사해, 내부망(사설 IP·루프백·링크로컬·메타데이터)과 host 위조를 차단한다.
KVE-2026-1546(이니시스 인증 URL 접두사 매칭 우회)과 동일 클래스의
지점(언어팩 URL 설치, 스케줄 URL 호출, 배송비 계산 API, GitHub URL 판정)을
코어 공용 유틸 App\Support\OutboundUrlValidator 로 수렴시켰다.

- 접두사 매칭을 host 완전일치로 교체 — userinfo(@) 위장과 접미사 확장 도메인
 우회 벡터 차단.
- 사내 서버 호출이 정당한 관리자 지점(스케줄·외부 API)은 신규 설정
 security.allow_internal_outbound_urls(기본 off)로 옵트인.
- 언어팩 URL 설치는 원격 코드 다운로드라 옵트인과 무관하게 내부 주소 항상 차단.
- 웹소켓 연결 테스트는 localhost·사설 IP 가 정상 구성이므로 구조적 위조만 차단.

각 지점에 red 재현 → 수정 → green 회귀 테스트 + 실브라우저 E2E 로 검증.
수기 API 문서가 자동 재생성으로 소실되는 사고를 막는 PreToolUse 훅을 함께 추가한다.
This commit is contained in:
HeuJung
2026-07-14 09:17:00 +09:00
parent f848086fd6
commit c4beb116ad
46 changed files with 1297 additions and 55 deletions
+5
View File
@@ -6,6 +6,11 @@
## [7.0.4] - 2026-07-11
### Security
- 서버가 외부 주소를 대신 호출하는 기능들에서, 사내 서버나 클라우드 관리 주소처럼 외부에 공개되지 않은 내부 주소를 호출 대상으로 지정할 수 없도록 차단했습니다. 언어팩을 주소로 내려받을 때, 예약 작업으로 주소를 호출할 때, 쇼핑몰 배송비를 외부 계산 서버로 문의할 때가 해당합니다. 사내 서버를 주기적으로 호출하는 등 내부 주소가 꼭 필요한 운영 환경을 위해, 환경설정 > 보안에 "내부 네트워크 주소 호출 허용" 항목을 새로 추가했습니다. 이 항목은 기본적으로 꺼져 있습니다.
- 주소를 검사할 때 앞부분만 맞으면 통과시키던 방식을 실제 주소가 정확히 일치하는지 확인하도록 바로잡았습니다. 이전에는 신뢰하는 주소를 앞에 붙인 가짜 주소가 검사를 통과할 수 있었습니다. 본인인증 연동과 언어팩 업데이트 확인에 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1546)
### Fixed
- 설치 과정에서 임시로 만들어지는 파일에 관리자 비밀번호가 그대로 남을 수 있던 문제를 수정했습니다. 설치가 정상적으로 끝나면 이 파일은 지워지지만, 설치를 중간에 그만두거나 마지막 정리가 이뤄지지 않은 경우에는 남아 있을 수 있었습니다. 이제 비밀번호는 이 파일에 아예 기록되지 않으며, 설치에 꼭 필요한 동안에만 접근이 제한된 별도 파일에 보관했다가 관리자 계정이 만들어지는 즉시 지워집니다. 이미 설치를 마친 사이트도 업데이트하면 남아 있던 파일이 자동으로 정리됩니다. (sir.kr 커뮤니티의 코ppp 님께서 제보해주셨습니다.)
@@ -2,6 +2,8 @@
namespace App\Http\Requests\LanguagePack;
use App\Extension\HookManager;
use App\Rules\PublicOutboundUrl;
use Illuminate\Foundation\Http\FormRequest;
/**
@@ -26,11 +28,15 @@ class InstallFromUrlRequest extends FormRequest
*/
public function rules(): array
{
return [
'url' => ['required', 'url', 'max:500'],
$rules = [
// 원격 코드를 내려받는 지점이므로 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'],
];
// 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공
return HookManager::applyFilters('core.language_packs.install_from_url_validation_rules', $rules, $this);
}
/**
@@ -6,13 +6,18 @@ use App\Enums\ExtensionOwnerType;
use App\Enums\ScheduleFrequency;
use App\Enums\ScheduleType;
use App\Extension\HookManager;
use App\Rules\PublicOutboundUrl;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class CreateScheduleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool 권한 검사는 라우트의 permission 미들웨어가 담당하므로 항상 true
*/
public function authorize(): bool
{
@@ -22,7 +27,7 @@ class CreateScheduleRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -33,7 +38,16 @@ class CreateScheduleRequest extends FormRequest
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'type' => "required|string|in:{$types}",
'command' => 'required|string|max:2000',
// URL 호출 스케줄이면 command 가 곧 서버의 outbound 목적지가 되므로 내부망 주소를 차단한다
'command' => [
'required',
'string',
'max:2000',
Rule::when(
$this->input('type') === ScheduleType::Url->value,
[new PublicOutboundUrl],
),
],
'expression' => 'required|string|max:100',
'frequency' => "required|string|in:{$frequencies}",
'without_overlapping' => 'boolean',
@@ -86,7 +100,9 @@ class CreateScheduleRequest extends FormRequest
}
/**
* 검증된 데이터 반환
* 검증된 데이터에 생성자(created_by)를 덧붙여 반환합니다.
*
* @return array<string, mixed> 검증된 입력 + created_by
*/
public function validatedWithCreator(): array
{
@@ -6,12 +6,18 @@ use App\Enums\ExtensionOwnerType;
use App\Enums\ScheduleFrequency;
use App\Enums\ScheduleType;
use App\Extension\HookManager;
use App\Models\Schedule;
use App\Rules\PublicOutboundUrl;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateScheduleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool 권한 검사는 라우트의 permission 미들웨어가 담당하므로 항상 true
*/
public function authorize(): bool
{
@@ -21,7 +27,7 @@ class UpdateScheduleRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -32,7 +38,14 @@ class UpdateScheduleRequest extends FormRequest
'name' => 'sometimes|required|string|max:255',
'description' => 'nullable|string|max:1000',
'type' => "sometimes|required|string|in:{$types}",
'command' => 'sometimes|required|string|max:2000',
// URL 호출 스케줄이면 command 가 곧 서버의 outbound 목적지가 되므로 내부망 주소를 차단한다
'command' => [
'sometimes',
'required',
'string',
'max:2000',
Rule::when($this->resolvesToUrlSchedule(), [new PublicOutboundUrl]),
],
'expression' => 'sometimes|required|string|max:100',
'frequency' => "sometimes|required|string|in:{$frequencies}",
'without_overlapping' => 'boolean',
@@ -47,6 +60,25 @@ class UpdateScheduleRequest extends FormRequest
return HookManager::applyFilters('core.schedule.update_validation_rules', $rules, $this);
}
/**
* 이번 수정 결과 URL 호출 스케줄이 되는지 판정합니다.
*
* PATCH 라 `type` 이 요청에 없을 수 있으므로, 없으면 저장된 스케줄의 타입을 기준으로
* 판정한다 — 그렇지 않으면 기존 url 스케줄의 command 만 바꾸는 요청이 검증을 비껴간다.
*
* @return bool URL 호출 스케줄이면 true
*/
protected function resolvesToUrlSchedule(): bool
{
if ($this->has('type')) {
return $this->input('type') === ScheduleType::Url->value;
}
$schedule = $this->route('schedule');
return $schedule instanceof Schedule && $schedule->type === ScheduleType::Url;
}
/**
* Get custom messages for validator errors.
*
@@ -231,6 +231,8 @@ class SaveSettingsRequest extends FormRequest
'security.auth_token_lifetime' => $this->getSecurityTabAuthTokenRules($tab),
'security.max_login_attempts' => ['nullable', 'integer', 'min:0', 'max:100'],
'security.login_lockout_time' => ['nullable', 'integer', 'min:0', 'max:1440'],
// 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송 시 기본값 false 유지
'security.allow_internal_outbound_urls' => ['nullable', 'boolean'],
// 캐시 설정 (advanced 탭)
'advanced.cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'),
@@ -614,6 +616,7 @@ class SaveSettingsRequest extends FormRequest
'security.auth_token_lifetime.integer' => __('validation.settings.auth_token_lifetime_integer'),
'security.auth_token_lifetime.min' => __('validation.settings.auth_token_lifetime_min'),
'security.auth_token_lifetime.max' => __('validation.settings.auth_token_lifetime_max'),
'security.allow_internal_outbound_urls.boolean' => __('validation.settings.allow_internal_outbound_urls_boolean'),
'security.max_login_attempts.integer' => __('validation.settings.max_login_attempts_integer'),
'security.max_login_attempts.min' => __('validation.settings.max_login_attempts_min'),
'security.max_login_attempts.max' => __('validation.settings.max_login_attempts_max'),
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Rules;
use App\Support\OutboundUrlValidator;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* 서버가 대신 호출하게 될 URL 이 내부망을 가리키지 않는지 검증하는 Custom Rule.
*
* 외부 API 엔드포인트·스케줄 URL 처럼 저장된 값이 나중에 서버의 outbound 요청 목적지가
* 되는 입력에 적용한다. 사설/루프백/링크로컬 IP 와 내부 도메인을 입력 시점에 차단해,
* 서버가 내부망 정찰 도구로 쓰이는 것을 막는다(SSRF).
*
* 사내 서버를 호출하는 것이 정당한 용도(외부 API 연동, 스케줄 URL 호출)에 한해, 관리자
* 환경설정의 `security.allow_internal_outbound_urls` 로 내부 주소를 허용할 수 있다.
* 원격 코드(언어팩 ZIP 등)를 내려받는 지점은 `allowInternalOptIn: false` 로 두어 이
* 설정과 무관하게 항상 차단한다.
*/
class PublicOutboundUrl implements ValidationRule
{
/**
* @param array<int, string> $schemes 허용 scheme (기본 http/https — 사내 API 는 http 인 경우가 있다)
* @param bool $allowInternalOptIn 관리자 설정으로 내부 주소를 허용할 수 있는 지점인지 (기본 true)
*/
public function __construct(
private readonly array $schemes = ['http', 'https'],
private readonly bool $allowInternalOptIn = true,
) {}
/**
* 값이 공개 인터넷 URL 인지 검증합니다.
*
* @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.outbound_url.invalid'));
return;
}
$options = ['schemes' => $this->schemes];
// 내부 주소 허용이 켜져 있으면 구조 검증(scheme/userinfo)만 하고 내부망 차단은 건너뛴다
if ($this->allowInternalOptIn && (bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
if (! OutboundUrlValidator::isStructurallySafeUrl($value, $options)) {
$fail(__('validation.outbound_url.invalid'));
}
return;
}
if (! OutboundUrlValidator::isPublicHttpUrl($value, $options)) {
$fail(__('validation.outbound_url.internal_not_allowed'));
}
}
}
+24 -8
View File
@@ -2,8 +2,13 @@
namespace App\Services;
use App\Support\OutboundUrlValidator;
use Aws\S3\Exception\S3Exception;
use Aws\S3\S3Client;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Predis\Client;
/**
* 드라이버 연결 테스트 서비스
@@ -89,7 +94,7 @@ class DriverConnectionTester
}
// AWS SDK가 설치되어 있는지 확인
if (! class_exists(\Aws\S3\S3Client::class)) {
if (! class_exists(S3Client::class)) {
return [
'success' => false,
'message' => __('settings.s3_sdk_missing'),
@@ -98,7 +103,7 @@ class DriverConnectionTester
$startTime = microtime(true);
$client = new \Aws\S3\S3Client([
$client = new S3Client([
'version' => 'latest',
'region' => $region,
'credentials' => [
@@ -121,7 +126,7 @@ class DriverConnectionTester
'message' => __('settings.s3_test_success'),
'latency' => $latency.'ms',
];
} catch (\Aws\S3\Exception\S3Exception $e) {
} catch (S3Exception $e) {
$errorMessage = match ($e->getAwsErrorCode()) {
'NoSuchBucket' => __('settings.s3_bucket_not_found'),
'AccessDenied' => __('settings.s3_access_denied'),
@@ -165,7 +170,7 @@ class DriverConnectionTester
// PHP Redis 확장 확인
if (! extension_loaded('redis')) {
// Predis 사용 시도
if (! class_exists(\Predis\Client::class)) {
if (! class_exists(Client::class)) {
return [
'success' => false,
'message' => __('settings.redis_extension_missing'),
@@ -177,7 +182,7 @@ class DriverConnectionTester
$startTime = microtime(true);
$redis = new \Redis();
$redis = new \Redis;
$connected = @$redis->connect($host, $port, 3.0);
if (! $connected) {
@@ -259,7 +264,7 @@ class DriverConnectionTester
$options['password'] = $password;
}
$client = new \Predis\Client($options);
$client = new Client($options);
$pong = $client->ping();
if ($pong->getPayload() !== 'PONG') {
@@ -307,7 +312,7 @@ class DriverConnectionTester
$startTime = microtime(true);
$memcached = new \Memcached();
$memcached = new \Memcached;
$memcached->setOption(\Memcached::OPT_CONNECT_TIMEOUT, 3000);
$memcached->addServer($host, $port);
@@ -376,6 +381,17 @@ class DriverConnectionTester
// Reverb 서버 상태 확인 (기본 HTTP 엔드포인트)
$url = sprintf('%s://%s:%d', $scheme, $host, $port);
// Reverb 는 통상 localhost·사내 IP 에서 동작하므로 사설 주소 자체는 정상 구성이다.
// 다만 이 진단 요청을 임의 목적지 탐색에 전용하지 못하도록, 정상 설정에서는 나올 수
// 없는 값(userinfo 위장, http/https 이외 scheme, 제어문자 주입)은 거부한다.
if (! OutboundUrlValidator::isStructurallySafeUrl($url, ['schemes' => ['http', 'https']])) {
return [
'success' => false,
'message' => __('settings.websocket_test_failed'),
'error' => __('settings.websocket_invalid_host'),
];
}
$startTime = microtime(true);
// HTTP 요청으로 서버 응답 확인
@@ -402,7 +418,7 @@ class DriverConnectionTester
'message' => __('settings.websocket_test_failed'),
'error' => "HTTP {$response->status()}",
];
} catch (\Illuminate\Http\Client\ConnectionException $e) {
} catch (ConnectionException $e) {
return [
'success' => false,
'message' => __('settings.websocket_connection_refused'),
+29 -17
View File
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use App\Enums\LanguagePackScope;
use App\Enums\LanguagePackStatus;
use App\Exceptions\LanguagePackOperationException;
use App\Exceptions\LanguagePackSlotConflictException;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\GithubHelper;
@@ -16,6 +17,7 @@ use App\Models\LanguagePack;
use App\Services\LanguagePack\LanguagePackBaseLocales;
use App\Services\LanguagePack\LanguagePackManifestValidator;
use App\Services\LanguagePack\LanguagePackRegistry;
use App\Support\OutboundUrlValidator;
use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
@@ -25,7 +27,6 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use App\Exceptions\LanguagePackOperationException;
use RuntimeException;
use Throwable;
@@ -340,7 +341,6 @@ class LanguagePackService
*
* @param LanguagePack $pack 가상 행
* @param array<string, mixed> $filters 필터
* @return bool
*/
private function matchesBuiltInFilters(LanguagePack $pack, array $filters): bool
{
@@ -600,7 +600,7 @@ class LanguagePackService
ZipInstallHelper::extractZip($zipPath, $extractPath);
return $this->finalizeInstall($extractPath, 'zip', $file->getClientOriginalName(), $autoActivate, $installedBy);
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
throw $e;
}
@@ -632,7 +632,7 @@ class LanguagePackService
ZipInstallHelper::extractZip($zipPath, $extractPath);
return $this->finalizeInstall($extractPath, 'github', $githubUrl, $autoActivate, $installedBy, $force);
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
File::deleteDirectory($tempPath);
throw $e;
@@ -653,6 +653,11 @@ class LanguagePackService
*/
public function installFromUrl(string $url, ?string $checksum, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack
{
// 서버가 이 URL 을 대신 내려받으므로, 내부 네트워크 주소가 목적지가 되지 않도록 차단한다.
if (! OutboundUrlValidator::isPublicHttpUrl($url)) {
throw new LanguagePackOperationException('language_packs.errors.download_url_not_public');
}
$this->assertInstallDirectoriesWritable();
$tempId = (string) Str::uuid();
@@ -679,7 +684,7 @@ class LanguagePackService
ZipInstallHelper::extractZip($zipPath, $extractPath);
return $this->finalizeInstall($extractPath, 'url', $url, $autoActivate, $installedBy, $force);
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
File::deleteDirectory($tempPath);
throw $e;
@@ -718,7 +723,7 @@ class LanguagePackService
File::copyDirectory($bundledPath, $extractPath);
return $this->finalizeInstall($extractPath, 'bundled', $identifier, $autoActivate, $installedBy, $force);
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
throw $e;
}
@@ -901,6 +906,7 @@ class LanguagePackService
$pack = $this->repository->findById((int) $id);
if (! $pack) {
$failed[] = ['id' => $id, 'reason' => 'not_found'];
continue;
}
try {
@@ -955,7 +961,6 @@ class LanguagePackService
*
* @param LanguagePack $pack 대상 언어팩
* @param bool $cascade 코어 제거 시 하위(module/plugin/template) 동일 locale 팩도 함께 제거
* @return void
*/
public function uninstall(LanguagePack $pack, bool $cascade = false): void
{
@@ -1000,7 +1005,6 @@ class LanguagePackService
* 슬롯 비활성화 후 다음 후보를 active 로 승격합니다.
*
* @param LanguagePack $pack 방금 비활성화된 언어팩
* @return void
*/
private function promoteSlotSuccessor(LanguagePack $pack): void
{
@@ -1056,7 +1060,6 @@ class LanguagePackService
*
* @param string $packageRoot 패키지 루트 디렉토리
* @param array<string, mixed> $manifest manifest 데이터
* @return void
*
* @throws RuntimeException 보안 위반 발견 시
*/
@@ -1103,7 +1106,6 @@ class LanguagePackService
* 모듈/플러그인/템플릿 install 과 동일 수준의 가드 — 권한 부족 시 chmod 안내 메시지가
* 포함된 `RuntimeException` 을 던집니다. 컨트롤러는 본 예외를 422 응답으로 변환합니다.
*
* @return void
*
* @throws RuntimeException 디렉토리 미존재/쓰기 불가 시
*/
@@ -1126,7 +1128,6 @@ class LanguagePackService
* 의존성 검증 — depends_on_core_locale 이 true 면 코어 언어팩 active 여부 확인.
*
* @param array<string, mixed> $manifest manifest 데이터
* @return void
*
* @throws RuntimeException 의존성 미충족 시
*/
@@ -1146,7 +1147,6 @@ class LanguagePackService
* 모듈/플러그인 시스템과 동일한 강도로 — 비활성 확장에도 언어팩이 들러붙지 않게 차단합니다.
*
* @param array<string, mixed> $manifest manifest 데이터
* @return void
*
* @throws RuntimeException 대상 확장 미설치/비활성 시
*/
@@ -1256,7 +1256,6 @@ class LanguagePackService
*
* @param LanguagePack $existing 기존 언어팩
* @param array<string, mixed> $manifest 새 manifest
* @return void
*
* @throws RuntimeException 다운그레이드 시도 시
*/
@@ -1301,7 +1300,6 @@ class LanguagePackService
* _pending 임시 디렉토리를 정리합니다.
*
* @param string $path 정리 대상 경로
* @return void
*/
private function cleanupPending(string $path): void
{
@@ -1309,7 +1307,7 @@ class LanguagePackService
if (File::isDirectory($path)) {
File::deleteDirectory($path);
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
Log::warning('lang-pack pending cleanup failed', ['path' => $path, 'error' => $e->getMessage()]);
}
}
@@ -1421,19 +1419,33 @@ class LanguagePackService
$manifest = $pack->manifest;
if (is_array($manifest)) {
$manifestUrl = $manifest['github_url'] ?? null;
if (is_string($manifestUrl) && Str::startsWith($manifestUrl, 'https://github.com/')) {
if (is_string($manifestUrl) && self::isGithubUrl($manifestUrl)) {
return $manifestUrl;
}
}
$sourceUrl = (string) $pack->source_url;
if (Str::startsWith($sourceUrl, 'https://github.com/')) {
if (self::isGithubUrl($sourceUrl)) {
return $sourceUrl;
}
return null;
}
/**
* URL 이 실제 GitHub host 를 가리키는지 판정합니다.
*
* 접두사 매칭은 `https://github.com@evil.example/` 같은 userinfo 위장을 통과시키므로
* host 를 완전 일치로 검증합니다.
*
* @param string $url 판정 대상 URL
* @return bool GitHub URL 이면 true
*/
private static function isGithubUrl(string $url): bool
{
return OutboundUrlValidator::isHostAllowed($url, ['github.com', 'www.github.com']);
}
/**
* 번들 manifest 로부터 최신 버전을 조회합니다 (요구사항 #4 폴백 경로).
*
+30 -1
View File
@@ -10,6 +10,7 @@ use App\Enums\ScheduleType;
use App\Extension\HookManager;
use App\Models\Schedule;
use App\Models\ScheduleHistory;
use App\Support\OutboundUrlValidator;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
@@ -19,6 +20,7 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Validation\ValidationException;
use Symfony\Component\Console\Output\BufferedOutput;
class ScheduleService
{
@@ -250,7 +252,7 @@ class ScheduleService
{
$output = '';
Artisan::call($schedule->command, [], new \Symfony\Component\Console\Output\BufferedOutput);
Artisan::call($schedule->command, [], new BufferedOutput);
return [
'output' => Artisan::output(),
@@ -292,6 +294,12 @@ class ScheduleService
*/
private function executeUrlCall(Schedule $schedule): array
{
// 저장된 URL 이 그대로 서버의 outbound 목적지가 되므로, 실행 직전에도 내부망 주소를 차단한다
// (저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값 방어).
if (! $this->isUrlCallAllowed($schedule->command)) {
throw new Exception(__('schedule.url_not_public'));
}
$timeout = $schedule->timeout ?? 30;
$response = Http::timeout($timeout)->get($schedule->command);
@@ -306,6 +314,27 @@ class ScheduleService
];
}
/**
* 스케줄의 URL 호출이 허용되는 목적지인지 판정합니다.
*
* 사설 IP·localhost 등 내부 네트워크 주소는 기본 차단하되, 사내 엔드포인트를 주기 호출하는
* 정당한 운영을 위해 `security.allow_internal_outbound_urls` 로 허용할 수 있습니다.
* 허용하더라도 userinfo 위장·비 HTTP scheme 은 계속 거부합니다.
*
* @param string $url 스케줄에 저장된 호출 URL
* @return bool 호출을 허용하면 true
*/
private function isUrlCallAllowed(string $url): bool
{
$options = ['schemes' => ['http', 'https']];
if ((bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
return OutboundUrlValidator::isStructurallySafeUrl($url, $options);
}
return OutboundUrlValidator::isPublicHttpUrl($url, $options);
}
/**
* 스케줄을 복제합니다.
*
+213
View File
@@ -0,0 +1,213 @@
<?php
namespace App\Support;
/**
* 서버가 외부로 나가는(outbound) HTTP 요청의 목적지 URL 을 검증하는 순수 유틸.
*
* 사용자·관리자·외부 콜백이 제어할 수 있는 값이 그대로 목적지가 되면 서버는
* 내부망(사설 IP, 루프백, 클라우드 메타데이터 169.254.169.254)으로 요청을 대신
* 보내는 도구가 된다(SSRF). 본 유틸은 두 계층의 판정을 제공한다:
*
* 1. `isHostAllowed()` — 신뢰 도메인 목록이 정해진 경우(결제/본인인증 게이트웨이 등).
* host 를 **완전 일치**로 검증한다. 접두사 매칭(`str_starts_with`)은 금지 —
* `https://trusted.example@127.0.0.1/`(userinfo)와
* `https://trusted.example.attacker.com/`(접미사 확장)이 모두 통과하기 때문이다.
*
* 2. `isPublicHttpUrl()` — 신뢰 목록이 없고 "임의의 공개 URL 은 허용하되 내부망
* 타격만 막는" 경우(외부 API 연동, 원격 다운로드 등).
*
* 두 메서드 모두 예외를 던지지 않고 bool 만 반환한다. 차단 시 어떤 예외/응답을
* 낼지는 각 호출 지점이 자신의 반환 계약에 맞춰 결정한다.
*
* 방어 범위: **host 레벨**이다. DNS rebinding(검증 시점과 실제 연결 시점 사이에
* 같은 호스트명이 내부 IP 로 재해석되는 공격)은 막지 못한다. 이를 막으려면 요청
* 직전에 실제 해석된 IP 를 재검증해야 하며, 이는 본 유틸의 범위 밖이다.
*/
class OutboundUrlValidator
{
/** 공개 인터넷 호스트로 취급하지 않는 내부 도메인 접미사 */
private const INTERNAL_HOST_SUFFIXES = [
'.local',
'.localhost',
'.internal',
'.intranet',
'.lan',
'.home.arpa',
];
/** 그 자체로 내부를 가리키는 호스트명 */
private const INTERNAL_HOST_NAMES = [
'localhost',
'ip6-localhost',
'ip6-loopback',
];
/**
* URL 의 host 가 화이트리스트와 완전 일치하는지 검증한다.
*
* scheme 허용 목록·userinfo 부재·포트 표기까지 함께 강제하므로, 호출부는
* 이 메서드 하나만 통과시키면 목적지 위조를 막을 수 있다.
*
* @param string $url 검증 대상 URL (외부 입력)
* @param array<int, string> $allowedHosts 허용 host 목록 (예: ['kssa.inicis.com'])
* @param array{schemes?: array<int, string>, allowPort?: bool} $options
* schemes: 허용 scheme (기본 ['https'])
* allowPort: 명시 포트 허용 여부 (기본 false)
* @return bool 화이트리스트 host 와 완전 일치하면 true
*/
public static function isHostAllowed(string $url, array $allowedHosts, array $options = []): bool
{
$host = self::extractSafeHost($url, $options);
if ($host === null) {
return false;
}
foreach ($allowedHosts as $allowed) {
if ($host === strtolower(trim($allowed))) {
return true;
}
}
return false;
}
/**
* URL 의 host 가 공개 인터넷 주소인지(= 내부망이 아닌지) 검증한다.
*
* 사설 IP(10/172.16/192.168), 루프백(127.0.0.1, ::1), 링크로컬 및 클라우드
* 메타데이터(169.254.169.254), `localhost`·`*.local` 등 내부 도메인을 차단한다.
*
* @param string $url 검증 대상 URL (외부 입력)
* @param array{schemes?: array<int, string>, allowPort?: bool} $options
* schemes: 허용 scheme (기본 ['https'])
* allowPort: 명시 포트 허용 여부 (기본 true — 외부 API 는 비표준 포트를 쓸 수 있다)
* @return bool 공개 인터넷 host 이면 true
*/
public static function isPublicHttpUrl(string $url, array $options = []): bool
{
$host = self::extractSafeHost($url, $options + ['allowPort' => true]);
if ($host === null) {
return false;
}
return self::isPublicHost($host);
}
/**
* URL 이 구조적으로 안전한지만 판정한다 (내부망 여부는 보지 않는다).
*
* 내부 주소 호출을 의도적으로 허용한 환경에서도 userinfo(`@`) 위장·미허용 scheme·
* 제어문자 주입은 여전히 막아야 하므로, 그 최소 방어선을 제공한다.
*
* @param string $url 검증 대상 URL
* @param array{schemes?: array<int, string>, allowPort?: bool} $options 검증 옵션
* @return bool 구조적으로 안전하면 true
*/
public static function isStructurallySafeUrl(string $url, array $options = []): bool
{
return self::extractSafeHost($url, $options + ['allowPort' => true]) !== null;
}
/**
* host 문자열(URL 이 아닌 host 단독)이 공개 인터넷 주소인지 판정한다.
*
* @param string $host host 문자열 (예: 'example.com', '127.0.0.1', '[::1]')
* @return bool 공개 인터넷 host 이면 true
*/
public static function isPublicHost(string $host): bool
{
$host = strtolower(trim($host));
// parse_url 이 IPv6 를 대괄호째 반환하므로 벗겨낸다
if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
$host = substr($host, 1, -1);
}
if ($host === '' || in_array($host, self::INTERNAL_HOST_NAMES, true)) {
return false;
}
// IP 리터럴 — 사설/예약 대역 차단 (루프백·링크로컬·메타데이터 포함)
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
) !== false;
}
// 10진수·8진수·16진수로 인코딩된 IP 우회(예: http://2130706433/ = 127.0.0.1) 차단.
// 공개 도메인은 최상위 라벨이 숫자로만 이루어질 수 없다.
$lastLabel = substr(strrchr('.'.$host, '.') ?: '', 1);
if ($lastLabel === '' || ctype_digit($lastLabel) || str_starts_with($host, '0x')) {
return false;
}
foreach (self::INTERNAL_HOST_SUFFIXES as $suffix) {
if (str_ends_with($host, $suffix)) {
return false;
}
}
return true;
}
/**
* URL 을 파싱해 구조적으로 안전한 host 를 소문자로 반환한다.
*
* scheme 미허용, userinfo(`@`) 포함, 미허용 포트, host 부재이면 null 을 반환해
* 상위 판정을 즉시 실패시킨다.
*
* @param string $url 검증 대상 URL
* @param array{schemes?: array<int, string>, allowPort?: bool} $options 검증 옵션
* @return string|null 소문자 host, 구조적으로 안전하지 않으면 null
*/
private static function extractSafeHost(string $url, array $options): ?string
{
$schemes = $options['schemes'] ?? ['https'];
$allowPort = $options['allowPort'] ?? false;
$url = trim($url);
if ($url === '') {
return null;
}
// 개행·제어문자가 섞인 URL 은 헤더/요청 분리 시도로 간주하고 즉시 거부
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
return null;
}
$parts = parse_url($url);
if ($parts === false || ! isset($parts['scheme'], $parts['host'])) {
return null;
}
// userinfo(`user:pass@host`) 는 host 위조의 핵심 벡터 — 존재만으로 거부
if (isset($parts['user']) || isset($parts['pass'])) {
return null;
}
$scheme = strtolower($parts['scheme']);
$normalizedSchemes = array_map(
static fn (string $s): string => strtolower(trim($s)),
$schemes,
);
if (! in_array($scheme, $normalizedSchemes, true)) {
return null;
}
if (! $allowPort && isset($parts['port'])) {
return null;
}
$host = strtolower(trim($parts['host']));
return $host === '' ? null : $host;
}
}
+2 -1
View File
@@ -24,7 +24,8 @@
"login_lockout_time": 5,
"two_factor_auth": false,
"password_min_length": 8,
"require_password_special_char": false
"require_password_special_char": false,
"allow_internal_outbound_urls": false
},
"mail": {
"mailer": "smtp",
+1 -1
View File
@@ -465,7 +465,7 @@ Content-Type: application/json
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| url | body | string | 예 | max 500 | URL |
| 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 | 아니오 | — | 설치 후 자동 활성화 여부 |
+2 -2
View File
@@ -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 | 실행할 아티즌 커맨드 |
| 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 은 계속 거부됩니다 |
| 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 | 실행할 아티즌 커맨드 |
| 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 은 계속 거부됩니다 |
| expression | body | string | 예 | max 100 | 실행 시각을 정의하는 Cron 표현식 (예: `0 3 * * *`, 다음 실행 시각 next_run_at 계산의 기준) |
| frequency | body | string | 예 | `everyMinute`, `hourly`, `daily`, `weekly`, `monthly`, `custom` | 실행 주기 |
| without_overlapping | body | boolean | 아니오 | — | 중복 실행 방지 여부 |
+1 -1
View File
@@ -43,7 +43,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| general | object | `{"site_name":"Test Site","site_url":"https:\/\/test.examp…` | 일반 탭 설정 그룹 (사이트명·사이트 URL·설명·관리자 이메일·타임존·기본 언어·통화·점검 모드·사이트 로고 첨부). site_logo 는 SettingsService 가 별도 주입한 첨부 정보 |
| security | object | `{"force_https":true,"login_attempt_enabled":true,"auth_to…` | 보안 탭 설정 그룹 (HTTPS 강제·로그인 시도 제한 사용·인증 토큰 유지시간(분, 0=무한)·최대 로그인 시도 횟수·잠금 시간) |
| security | object | `{"force_https":true,"login_attempt_enabled":true,"auth_to…` | 보안 탭 설정 그룹 (HTTPS 강제·로그인 시도 제한 사용·인증 토큰 유지시간(분, 0=무한)·최대 로그인 시도 횟수·잠금 시간·내부 네트워크 주소 호출 허용). `allow_internal_outbound_urls`(boolean, 기본 false): 서버가 대신 호출하는 outbound 요청(예약 작업 URL 호출, 외부 API 연동)에서 사설 IP·`localhost` 등 내부 주소를 허용할지 여부 — 켜면 사내 서버 호출이 가능해지지만 서버가 내부망으로 요청을 보낼 수 있게 되므로 기본은 차단입니다. 언어팩 URL 설치는 원격 코드를 내려받으므로 이 설정과 무관하게 항상 내부 주소를 거부합니다 |
| mail | object | `{"mailer":"smtp","host":"","port":587,"username":"","pass…` | 메일 탭 설정 그룹 (메일러 종류(smtp/mailgun/ses)·SMTP 호스트/포트/인증 정보·암호화 방식·발신자 주소/이름·Mailgun/SES 자격 정보) |
| upload | object | `{"max_file_size":10,"allowed_extensions":["jpg","jpeg","p…` | 업로드 탭 설정 그룹 (최대 파일 크기(MB)·허용 확장자 목록·이미지 최대 가로/세로·이미지 품질) |
| seo | object | `{"meta_title_suffix":"","meta_description":"","meta_keywo…` | SEO 탭 설정 그룹 (메타 타이틀 접미사·메타 설명/키워드·검색엔진 인증 코드·봇 감지·OG/Twitter 기본값·SEO 캐시·사이트맵·생성기 설정) |
@@ -9,6 +9,7 @@
### Added
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못했을 때 표시되는 안내 문구 일본어 번역 추가 (`errors.bootstrap.*`) — 네트워크 문제로 페이지를 열지 못한 경우의 안내와 새로고침 버튼이 일본어 로케일에서 자연스럽게 표시됩니다.
- 내부 네트워크 주소 호출 차단 관련 안내 문구 일본어 번역 추가 (`validation.outbound_url.*`, `schedule.url_not_public`, `language_packs.errors.download_url_not_public`, `settings.websocket_invalid_host`) — 예약 작업·언어팩 설치·웹소켓 설정에서 내부 주소가 거부될 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.3] - 2026-07-11
@@ -43,6 +43,7 @@ return [
'directory_not_writable' => '言語パックインストールディレクトリ(:path)への書き込み権限がありません。Webサーバーユーザー(例: www-data)に書き込み権限を付与してください。(例: sudo chgrp www-data :path && sudo chmod g+w :path)',
'identifier_not_found' => '言語パックが見つかりません (:identifier)。',
'unsupported_source' => 'サポートされていないソース(:source) — bundled、github、urlのいずれかを使用してください。',
'download_url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)では言語パックをダウンロードできません。外部からアクセス可能なhttpsアドレスを使用してください。',
],
'validation' => [
'file_required' => '言語パックZIPファイルをアップロードしてください。',
@@ -100,4 +100,5 @@ return [
'delete' => 'スケジュール削除',
'run' => 'スケジュール実行',
],
'url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)は呼び出せません。社内アドレスを呼び出す必要がある場合は、環境設定 > セキュリティで「内部ネットワークアドレス呼び出し許可」をオンにしてください。',
];
@@ -153,4 +153,5 @@ return [
'restore_success' => '設定が正常に復元されました。',
'restore_failed' => '設定の復元に失敗しました。',
'restore_error' => '設定の復元中にエラーが発生しました。',
'websocket_invalid_host' => 'Websocketホスト設定が正しくありません。ホストにはアドレスのみを入力し、アカウント情報(@)やhttp/https以外のプロトコルは使用できません。',
];
@@ -836,6 +836,7 @@ return [
'websocket_host_required' => 'WebSocketホストは必須です。',
'websocket_port_required' => 'WebSocketポートは必須です。',
'websocket_scheme_required' => 'WebSocketプロトコルを選択してください。',
'allow_internal_outbound_urls_boolean' => '内部ネットワークアドレス呼び出し許可は true または false 値である必要があります。',
],
'identity_policy' => [
'key_required' => 'ポリシーキーを入力してください。',
@@ -1014,4 +1015,8 @@ return [
'exists' => '存在しない多言語キーが含まれています。',
],
],
'outbound_url' => [
'invalid' => '正しい形式のURLではありません。http または https で始まるアドレスを入力してください。',
'internal_not_allowed' => '内部ネットワークアドレス(プライベートIP·localhost など)は使用できません。外部からアクセス可能なアドレスを入力してください。',
],
];
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.3] - 2026-07-13
### Added
- 환경설정 > 보안 화면의 "내부 네트워크 주소 호출 허용" 항목명·설명 일본어 번역 추가 — 새로 추가된 보안 설정이 일본어 로케일에서도 올바르게 표시됩니다.
## [1.0.2] - 2026-07-10
### Added
@@ -1445,7 +1445,9 @@
"app_key_desc": "暗号化に使用されるアプリケーションキーを管理します。",
"current_app_key": "現在のキー",
"regenerate_key": "キー 再生成",
"app_key_warning": "キーを変更すると、既存の暗号化されたデータにアクセスできなくなる可能性があります。"
"app_key_warning": "キーを変更すると、既存の暗号化されたデータにアクセスできなくなる可能性があります。",
"allow_internal_outbound_urls": "内部ネットワークアドレス呼び出し許可",
"allow_internal_outbound_urls_desc": "外部API連携·スケジュールURL呼び出しなどで、プライベートIPやlocalhostなどの内部アドレスを使用できるようにします。社内サーバーを呼び出す必要がある場合のみ有効にしてください。有効にするとサーバーが内部ネットワークにリクエストを送信できるようになるため、デフォルトは無効です。"
},
"identity": {
"sub_tabs": {
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
"version": "1.0.2",
"version": "1.0.3",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
+1
View File
@@ -34,6 +34,7 @@ return [
'downgrade_blocked' => 'Downgrade blocked (:from → :to).',
'protected_pack' => 'Protected language packs cannot be deactivated or removed.',
'download_failed' => 'Failed to download from URL: :url',
'download_url_not_public' => 'Language packs cannot be downloaded from internal network addresses (private IPs, localhost, etc.). Use a publicly reachable https address.',
'checksum_mismatch' => 'Checksum mismatch.',
'update_no_source' => 'No update source available (only GitHub-sourced packs can be updated).',
'update_already_latest' => 'Already on the latest version.',
+1
View File
@@ -28,6 +28,7 @@ return [
'copy' => 'Copy',
'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.',
// Task types
'type' => [
+1
View File
@@ -103,6 +103,7 @@ return [
// Websocket test messages
'websocket_test_success' => 'Successfully connected to Websocket server.',
'websocket_test_failed' => 'Failed to connect to Websocket server.',
'websocket_invalid_host' => 'The Websocket host setting is invalid. Enter the address only — credentials (@) and protocols other than http/https are not allowed.',
'websocket_connection_refused' => 'Could not connect to Websocket server. Please check if the server is running.',
// Test mail related messages
+7
View File
@@ -504,6 +504,12 @@ return [
'dangerous_scheme_detected' => 'Dangerous URI scheme detected: :scheme',
],
// Outbound URL (external API, schedule, etc.) validation messages
'outbound_url' => [
'invalid' => 'This is not a valid URL. Enter an address starting with http or https.',
'internal_not_allowed' => 'Internal network addresses (private IPs, localhost, etc.) are not allowed. Enter a publicly reachable address.',
],
// Component existence validation messages
'component' => [
'template_id_required' => 'template_id is required for component validation.',
@@ -824,6 +830,7 @@ return [
// Security settings
'force_https_required' => 'Please select the Force HTTPS setting.',
'force_https_boolean' => 'Force HTTPS must be true or false.',
'allow_internal_outbound_urls_boolean' => 'Allow internal network address calls must be true or false.',
'login_attempt_enabled_required' => 'Please select the login attempt limit setting.',
'login_attempt_enabled_boolean' => 'Login attempt limit must be true or false.',
'auth_token_lifetime_integer' => 'Auth token lifetime must be an integer.',
+1
View File
@@ -34,6 +34,7 @@ return [
'downgrade_blocked' => '다운그레이드가 차단되었습니다 (:from → :to).',
'protected_pack' => '보호된 언어팩은 비활성화/제거할 수 없습니다.',
'download_failed' => 'URL(:url) 에서 언어팩을 다운로드하지 못했습니다: :error',
'download_url_not_public' => '내부 네트워크 주소(사설 IP·localhost 등)에서는 언어팩을 내려받을 수 없습니다. 외부에서 접속 가능한 https 주소를 사용해주세요.',
'checksum_mismatch' => '체크섬이 일치하지 않습니다.',
'update_no_source' => '업데이트 소스 정보가 없습니다 (GitHub 소스 언어팩만 업데이트 가능).',
'update_already_latest' => '이미 최신 버전입니다.',
+1
View File
@@ -28,6 +28,7 @@ return [
'copy' => '복사본',
'shell_command_failed' => '쉘 명령 실행에 실패했습니다.',
'http_request_failed' => 'HTTP 요청 실패 (상태: :status)',
'url_not_public' => '내부 네트워크 주소(사설 IP·localhost 등)는 호출할 수 없습니다. 사내 주소를 호출해야 한다면 환경설정 > 보안에서 "내부 네트워크 주소 호출 허용"을 켜주세요.',
// 작업 유형
'type' => [
+1
View File
@@ -103,6 +103,7 @@ return [
// Websocket 테스트 메시지
'websocket_test_success' => 'Websocket 서버에 성공적으로 연결되었습니다.',
'websocket_test_failed' => 'Websocket 서버 연결에 실패했습니다.',
'websocket_invalid_host' => 'Websocket 호스트 설정이 올바르지 않습니다. 호스트에는 주소만 입력하고 계정 정보(@)나 http/https 이외의 프로토콜은 사용할 수 없습니다.',
'websocket_connection_refused' => 'Websocket 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요.',
// 테스트 메일 관련 메시지
+7
View File
@@ -503,6 +503,12 @@ return [
'dangerous_scheme_detected' => '위험한 URI 스킴이 감지되었습니다: :scheme',
],
// 서버가 대신 호출하는 URL(외부 API·스케줄 등) 검증 메시지
'outbound_url' => [
'invalid' => '올바른 형식의 URL이 아닙니다. http 또는 https 로 시작하는 주소를 입력해 주세요.',
'internal_not_allowed' => '내부 네트워크 주소(사설 IP·localhost 등)는 사용할 수 없습니다. 외부에서 접속 가능한 주소를 입력해 주세요.',
],
// 컴포넌트 존재 여부 검증 메시지
'component' => [
'template_id_required' => '컴포넌트 검증을 위해서는 template_id가 필요합니다.',
@@ -897,6 +903,7 @@ return [
// 보안 설정
'force_https_required' => 'HTTPS 강제 적용 설정을 선택해주세요.',
'force_https_boolean' => 'HTTPS 강제 적용은 true 또는 false 값이어야 합니다.',
'allow_internal_outbound_urls_boolean' => '내부 네트워크 주소 호출 허용은 true 또는 false 값이어야 합니다.',
'login_attempt_enabled_required' => '로그인 시도 제한 설정을 선택해주세요.',
'login_attempt_enabled_boolean' => '로그인 시도 제한은 true 또는 false 값이어야 합니다.',
'auth_token_lifetime_integer' => '인증 토큰 유지시간은 정수여야 합니다.',
@@ -6,6 +6,10 @@
## [1.0.3] - 2026-07-10
### Security
- 배송정책의 배송비 계산 API 주소로 사내 서버나 클라우드 관리 주소 같은 내부 주소를 지정할 수 없도록 차단했습니다. 이 주소는 고객이 주문·장바구니에서 배송비를 확인할 때마다 쇼핑몰 서버가 대신 호출하므로, 내부 주소가 지정되면 외부에서 볼 수 없어야 할 내부 정보가 노출될 수 있었습니다. 배송정책 저장 화면과 API 테스트 호출 모두에 적용되며, 이미 저장되어 있던 주소도 실제 호출 직전에 다시 확인합니다. 사내 배송비 계산 서버를 쓰는 경우에는 코어 환경설정 > 보안에서 "내부 네트워크 주소 호출 허용"을 켜면 됩니다.
### Fixed
- 쇼핑몰 환경설정 > 알림 설정에서 "무통장 입금 안내"와 "배송 완료" 두 알림의 이름이 사람이 읽을 수 없는 내부 문자열로 표시되던 문제를 수정했습니다. 이제 다른 알림과 동일하게 이름이 표시되며, 알림을 기본값으로 되돌릴 때 뜨는 확인창과 알림 편집 화면에서도 함께 바로잡혔습니다.
@@ -372,7 +372,7 @@ Content-Type: application/json
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| endpoint | body | string | 예 | max 500 | 테스트로 호출할 외부 배송비 계산 API 엔드포인트 URL |
| endpoint | body | string | 예 | max 500 | 테스트로 호출할 외부 배송비 계산 API 엔드포인트 URL. 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)와 userinfo(`https://a@b/`) 위장 주소는 422 로 거부됩니다 — 이 주소는 쇼핑몰 서버가 대신 호출하므로 내부망 접근을 막기 위함(SSRF). 사내 배송비 계산 서버를 쓰려면 코어 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요 |
| request_fields | body | array | 아니오 | — | 요청에 실어 보낼 필드명 목록 (후보 SSoT ShippingApiRequestField 5종) |
| config | body | array | 아니오 | — | API 호출 고급 설정 (HTTP 메서드·인증방식·필드 매핑·응답 형식/경로 등) |
| sample | body | array | 아니오 | — | 테스트 계산에 사용할 샘플 주문 데이터 (무게/금액/수량 등) |
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin;
use App\Extension\HookManager;
use App\Rules\LocaleRequiredTranslatable;
use App\Rules\PublicOutboundUrl;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
@@ -67,7 +68,7 @@ class StoreShippingPolicyRequest extends FormRequest
'country_settings.*.ranges.tiers.*.fee' => ['required', 'numeric', 'min:0'],
// API 설정
'country_settings.*.api_endpoint' => ['nullable', 'url', 'max:500'],
'country_settings.*.api_endpoint' => ['nullable', 'url', 'max:500', new PublicOutboundUrl],
'country_settings.*.api_request_fields' => ['nullable', 'array'],
// 후보 5종 SSoT(ShippingApiRequestField) 외 필드명 거부 — silent drop 차단
'country_settings.*.api_request_fields.*' => ['string', 'max:100', Rule::in(ShippingApiRequestField::values())],
@@ -2,6 +2,7 @@
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin;
use App\Rules\PublicOutboundUrl;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiAuthType;
@@ -34,7 +35,7 @@ class TestShippingApiRequest extends FormRequest
public function rules(): array
{
return [
'endpoint' => ['required', 'url', 'max:500'],
'endpoint' => ['required', 'url', 'max:500', new PublicOutboundUrl],
'request_fields' => ['nullable', 'array'],
'request_fields.*' => ['string', Rule::in(ShippingApiRequestField::values())],
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Ecommerce\Services;
use App\Extension\HookManager;
use App\Support\OutboundUrlValidator;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -2149,6 +2150,17 @@ class OrderCalculationService
*/
protected function dispatchApiRequest(string $endpoint, array $requestData, array $config): ?Response
{
// 이 호출은 공개 체크아웃 트래픽으로도 유발되므로, 저장된 엔드포인트가 내부망을
// 가리키면 요청 자체를 보내지 않는다. 저장 시점 검증(FormRequest) 도입 이전에
// 기록된 값이나 DB 직접 수정으로 들어온 값을 여기서 막는다.
if (! $this->isEndpointAllowed($endpoint)) {
Log::warning('배송비 계산 API 엔드포인트가 내부 네트워크를 가리켜 호출을 차단했습니다', [
'host' => parse_url($endpoint, PHP_URL_HOST),
]);
return null;
}
$request = Http::timeout(10)->withoutRedirecting();
// 인증 헤더 부착
@@ -2170,6 +2182,27 @@ class OrderCalculationService
: $request->post($endpoint, $requestData);
}
/**
* 배송비 계산 API 엔드포인트가 호출 가능한 주소인지 판정합니다.
*
* 사설 IP·localhost 등 내부 네트워크 주소는 기본 차단합니다. 사내 서버를 배송비 API 로
* 쓰는 운영 환경을 위해 관리자 환경설정의 `security.allow_internal_outbound_urls` 로
* 허용할 수 있으며, 그 경우에도 userinfo 위장·비 HTTP scheme 은 계속 거부합니다.
*
* @param string $endpoint API 엔드포인트 URL
* @return bool 호출을 허용하면 true
*/
protected function isEndpointAllowed(string $endpoint): bool
{
$options = ['schemes' => ['http', 'https']];
if ((bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
return OutboundUrlValidator::isStructurallySafeUrl($endpoint, $options);
}
return OutboundUrlValidator::isPublicHttpUrl($endpoint, $options);
}
/**
* 외부 API 응답에서 배송비 값을 추출합니다 (응답 형식별 분기).
*
@@ -3,9 +3,12 @@
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
use App\Models\User;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Http;
use Modules\Sirsoft\Ecommerce\Database\Seeders\ShippingTypeSeeder;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
/**
* 배송정책 계산 API 테스트 호출 엔드포인트 테스트 (MP12)
@@ -80,7 +83,7 @@ class ShippingPolicyTestApiCallTest extends ModuleTestCase
*/
public function test_connection_failure_still_returns_request_preview_and_error(): void
{
Http::fake(['*' => fn () => throw new \Illuminate\Http\Client\ConnectionException('Connection refused')]);
Http::fake(['*' => fn () => throw new ConnectionException('Connection refused')]);
$response = $this->actingAs($this->adminUser)->postJson($this->url, [
'endpoint' => 'https://unreachable.example.com/calc',
@@ -142,4 +145,97 @@ class ShippingPolicyTestApiCallTest extends ModuleTestCase
$response->assertStatus(422);
$response->assertJsonValidationErrors('endpoint');
}
/**
* 내부 네트워크 주소는 422 로 거부되고 요청이 전송되지 않는다 (SSRF 차단).
*
* @param string $endpoint 내부망을 가리키는 엔드포인트
*/
#[DataProvider('internalEndpointProvider')]
public function test_internal_endpoint_is_rejected_and_no_request_is_sent(string $endpoint): void
{
Http::fake(['*' => Http::response(['shipping_fee' => 1], 200)]);
$response = $this->actingAs($this->adminUser)->postJson($this->url, [
'endpoint' => $endpoint,
'config' => ['http_method' => 'GET'],
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('endpoint');
Http::assertNothingSent();
}
/**
* userinfo(@) 로 목적지를 위장한 URL 도 거부한다.
*/
public function test_userinfo_disguised_endpoint_is_rejected(): void
{
Http::fake(['*' => Http::response(['shipping_fee' => 1], 200)]);
$response = $this->actingAs($this->adminUser)->postJson($this->url, [
'endpoint' => 'https://shipping.example.com@127.0.0.1/calc',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('endpoint');
Http::assertNothingSent();
}
/**
* 내부 주소 허용 설정을 켜면 사내 주소 호출이 가능하다 (운영 옵트인).
*/
public function test_internal_endpoint_is_allowed_when_setting_is_enabled(): void
{
Http::fake(['*' => Http::response(['shipping_fee' => 2500], 200)]);
$this->enableInternalOutboundUrls();
$response = $this->actingAs($this->adminUser)->postJson($this->url, [
'endpoint' => 'http://192.168.0.10/calc',
'config' => ['http_method' => 'POST', 'response_path' => 'shipping_fee'],
]);
$response->assertOk();
$response->assertJsonPath('data.extracted_fee', 2500);
}
/**
* 내부 주소를 허용해도 userinfo 위장은 계속 거부한다.
*/
public function test_userinfo_disguise_is_rejected_even_when_internal_is_allowed(): void
{
Http::fake(['*' => Http::response(['shipping_fee' => 1], 200)]);
$this->enableInternalOutboundUrls();
$response = $this->actingAs($this->adminUser)->postJson($this->url, [
'endpoint' => 'https://shipping.example.com@127.0.0.1/calc',
]);
$response->assertStatus(422);
Http::assertNothingSent();
}
/**
* 내부 네트워크 엔드포인트 목록.
*
* @return array<string, array{string}>
*/
public static function internalEndpointProvider(): array
{
return [
'클라우드 메타데이터' => ['http://169.254.169.254/latest/meta-data/'],
'루프백' => ['http://127.0.0.1:8080/calc'],
'localhost' => ['http://localhost/calc'],
'사설 IP' => ['http://192.168.0.10/calc'],
'내부 도메인' => ['http://vault.internal/calc'],
];
}
/**
* 관리자 환경설정에서 내부 주소 호출 허용을 켠다.
*/
private function enableInternalOutboundUrls(): void
{
Config::set('g7_settings.core.security.allow_internal_outbound_urls', true);
}
}
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.0.1] - 2026-07-09
### Security
- 본인인증 결과를 확인하러 이니시스 서버에 접속할 때, 접속할 주소가 실제 이니시스 주소가 맞는지 정확히 확인하도록 바로잡았습니다. 이전에는 주소의 앞부분만 맞으면 통과시켰기 때문에, 이니시스 주소를 앞에 붙인 가짜 주소가 검사를 통과할 수 있었습니다. 이를 악용하면 사이트 서버가 공격자의 서버나 외부에 공개되지 않은 내부 주소로 접속하게 만들 수 있었고, 공격자가 임의로 꾸민 인증 결과(이름·생년월일 등)를 진짜처럼 받아들이게 만들 수도 있었습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1546)
### Changed
- 라이브 가맹점 MID 프리픽스를 이니시스 정책 변경에 맞춰 SRB 로 갱신했습니다 (설정 화면 고정 프리픽스 표시·입력 안내 문구 포함).
@@ -2,6 +2,7 @@
namespace Plugins\Sirsoft\VerificationKginicis\Services;
use App\Support\OutboundUrlValidator;
use Illuminate\Support\Str;
use Plugins\Sirsoft\VerificationKginicis\Exceptions\DecryptException;
use Plugins\Sirsoft\VerificationKginicis\Exceptions\InvalidAuthUrlException;
@@ -23,10 +24,10 @@ class InicisGateway implements InicisGatewayInterface
/** STEP3 호출 timeout (초) */
private const STEP3_TIMEOUT = 10;
/** 이니시스 표준 도메인 화이트리스트 (위조 차단) */
private const ALLOWED_DOMAINS = [
'https://kssa.inicis.com',
'https://fcsa.inicis.com',
/** 이니시스 표준 host 화이트리스트 (위조 차단) */
private const ALLOWED_HOSTS = [
'kssa.inicis.com',
'fcsa.inicis.com',
];
/** STEP3 응답 중 SEED CBC 로 암호화된 PII 필드 키 */
@@ -42,18 +43,16 @@ class InicisGateway implements InicisGatewayInterface
/**
* 이니시스 콜백의 authRequestUrl 이 표준 도메인인지 검증한다.
*
* 콜백은 인증 없이 외부에서 들어오고 이 URL 이 그대로 STEP3 POST 의 목적지가 되므로,
* host 를 완전 일치로 검증한다. 접두사 매칭은 `…@127.0.0.1` (userinfo) 이나
* `....attacker.com` (접미사 확장) 형태의 위조 목적지를 통과시킨다.
*
* @param string $url 콜백으로 수신한 authRequestUrl
* @return bool 표준 도메인 여부
*/
public function validateAuthUrl(string $url): bool
{
foreach (self::ALLOWED_DOMAINS as $allowed) {
if (str_starts_with($url, $allowed)) {
return true;
}
}
return false;
return OutboundUrlValidator::isHostAllowed($url, self::ALLOWED_HOSTS);
}
/**
@@ -0,0 +1,93 @@
<?php
namespace Plugins\Sirsoft\VerificationKginicis\Tests\Unit\Services;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Plugins\Sirsoft\VerificationKginicis\Services\InicisGateway;
use Plugins\Sirsoft\VerificationKginicis\Tests\PluginTestCase;
/**
* InicisGateway::validateAuthUrl 의 목적지 위조 차단 검증.
*
* 콜백 body 의 `authRequestUrl` 은 인증 없이 외부에서 들어오는 값이며, 이 값이 그대로
* 서버-서버 STEP3 POST 의 목적지가 된다. host 를 완전 일치로 검증하지 않으면 공격자가
* 내부망 주소나 자신의 서버로 요청을 유도해 SSRF 및 본인인증 결과 위조가 가능하다.
*/
class InicisGatewayValidateAuthUrlTest extends PluginTestCase
{
private InicisGateway $gateway;
protected function setUp(): void
{
parent::setUp();
$this->gateway = new InicisGateway;
}
/**
* 이니시스 표준 도메인은 통과한다.
*
* @param string $url 검증 대상 authRequestUrl
*/
#[Test]
#[DataProvider('validAuthUrlProvider')]
public function it_accepts_official_inicis_auth_urls(string $url): void
{
$this->assertTrue(
$this->gateway->validateAuthUrl($url),
"정상 이니시스 URL 이 거부됨: {$url}"
);
}
/**
* 표준 도메인으로 위장한 URL 은 모두 거부한다.
*
* @param string $url 검증 대상 authRequestUrl
* @param string $reason 차단 사유 (실패 메시지용)
*/
#[Test]
#[DataProvider('forgedAuthUrlProvider')]
public function it_rejects_forged_auth_urls(string $url, string $reason): void
{
$this->assertFalse(
$this->gateway->validateAuthUrl($url),
"위조 URL 이 통과함 ({$reason}): {$url}"
);
}
/**
* 정상 authRequestUrl 목록.
*
* @return array<string, array{string}>
*/
public static function validAuthUrlProvider(): array
{
return [
'표준 kssa 도메인' => ['https://kssa.inicis.com/auth/result'],
'표준 fcsa 도메인' => ['https://fcsa.inicis.com/auth/result'],
'대소문자 혼용' => ['https://KSSA.INICIS.COM/auth/result'],
];
}
/**
* 위조 authRequestUrl 목록 — 접두사 매칭 우회 벡터 전수.
*
* @return array<string, array{string, string}>
*/
public static function forgedAuthUrlProvider(): array
{
return [
'userinfo 로 루프백 위장' => ['https://kssa.inicis.com@127.0.0.1/', 'userinfo(@) 뒤가 실제 목적지'],
'userinfo 로 메타데이터 위장' => ['https://kssa.inicis.com@169.254.169.254/latest/meta-data/', '클라우드 메타데이터 탈취'],
'userinfo 로 공격자 서버 위장' => ['https://kssa.inicis.com@attacker.example/fake', '인증결과 위조'],
'접미사 확장 도메인' => ['https://kssa.inicis.com.attacker.example/fake', '공격자 소유 도메인'],
'하이픈 접미사' => ['https://fcsa.inicis.com-evil.example/', '공격자 소유 도메인'],
'내부 IP 직접 지정' => ['https://127.0.0.1/', '내부망 직접 타격'],
'경로에만 표준 도메인' => ['https://attacker.example/https://kssa.inicis.com', '경로 위장'],
'http 다운그레이드' => ['http://kssa.inicis.com/auth', '평문 전송'],
'scheme 부재' => ['//kssa.inicis.com/auth', 'scheme 부재'],
'빈 문자열' => ['', '입력 없음'],
];
}
}
@@ -8,6 +8,8 @@
### Added
- 환경설정 > 보안 화면에 "내부 네트워크 주소 호출 허용" 항목이 추가되었습니다. 예약 작업의 주소 호출이나 외부 API 연동에서 사내 서버 주소를 사용해야 할 때 켜면 됩니다. 기본값은 꺼짐입니다.
- 레이아웃 편집기에서 관리자 기본 레이아웃을 편집할 때 "모바일 메뉴 펼침" 상태를 선택할 수 있습니다 — 모바일 메뉴 안의 언어·통화·배송국가 버튼을 캔버스에서 바로 편집할 수 있습니다.
### Changed
@@ -1431,6 +1431,8 @@
"desc": "Manage site security settings.",
"force_https": "Force HTTPS",
"force_https_desc": "Force all connections to use HTTPS.",
"allow_internal_outbound_urls": "Allow internal network addresses",
"allow_internal_outbound_urls_desc": "Allows private IPs and localhost to be used for outbound calls such as external API integrations and scheduled URL calls. Turn this on only when you need to reach servers inside your own network. It lets the server send requests to your internal network, so it is off by default.",
"login_attempt_limit": "Login Attempt Limit",
"login_attempt_limit_desc": "Lock account after certain number of failed login attempts.",
"auth_token_lifetime": "Auth Token Lifetime (minutes)",
@@ -1435,6 +1435,8 @@
"desc": "사이트 보안 관련 설정을 관리합니다.",
"force_https": "HTTPS 강제 적용",
"force_https_desc": "모든 연결을 HTTPS로 강제합니다.",
"allow_internal_outbound_urls": "내부 네트워크 주소 호출 허용",
"allow_internal_outbound_urls_desc": "외부 API 연동·스케줄 URL 호출 등에서 사설 IP나 localhost 같은 내부 주소를 사용할 수 있게 합니다. 사내 서버를 호출해야 할 때만 켜세요. 켜면 서버가 내부망으로 요청을 보낼 수 있게 되므로 기본값은 꺼짐입니다.",
"login_attempt_limit": "로그인 시도 제한",
"login_attempt_limit_desc": "일정 횟수 이상 로그인 실패 시 계정을 잠급니다.",
"auth_token_lifetime": "인증 토큰 유지시간 (분)",
@@ -106,6 +106,65 @@
}
]
},
{
"id": "toggle_allow_internal_outbound_urls",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-heading"
},
"text": "$t:admin.settings.security.allow_internal_outbound_urls"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-label-subtle"
},
"text": "$t:admin.settings.security.allow_internal_outbound_urls_desc"
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['security.allow_internal_outbound_urls']}}",
"props": {
"className": "form-error"
},
"text": "{{_local.errors?.['security.allow_internal_outbound_urls']?.[0] ?? ''}}"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "security.allow_internal_outbound_urls",
"size": "md",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
},
{
"id": "toggle_login_attempt",
"type": "basic",
@@ -0,0 +1,232 @@
<?php
namespace Tests\Feature\Security;
use App\Enums\ScheduleResultStatus;
use App\Enums\ScheduleType;
use App\Exceptions\LanguagePackOperationException;
use App\Models\Schedule;
use App\Services\DriverConnectionTester;
use App\Services\LanguagePackService;
use App\Services\ScheduleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* 서버가 대신 보내는 outbound 요청이 내부망을 타격하지 못하는지 검증한다 (SSRF 방어).
*
* 각 케이스는 `Http::fake()` 로 실제 통신을 가로챈 뒤 `Http::assertNothingSent()` 로
* "요청 자체가 나가지 않았음" 을 확인한다 — 게이트 도입 전에는 요청이 실제로 전송된다.
*/
class OutboundRequestGuardTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Http::fake(['*' => Http::response('ok', 200)]);
}
/**
* 언어팩 설치는 내부 네트워크 주소에서 내려받지 않는다.
*
* @param string $url 내부망을 가리키는 다운로드 URL
*/
#[DataProvider('internalUrlProvider')]
public function test_language_pack_install_from_url_blocks_internal_addresses(string $url): void
{
$this->expectException(LanguagePackOperationException::class);
try {
app(LanguagePackService::class)->installFromUrl($url, null);
} finally {
Http::assertNothingSent();
}
}
/**
* 언어팩 다운로드는 원격 코드를 가져오므로, 내부 주소 허용 설정을 켜도 계속 차단한다.
*/
public function test_language_pack_install_stays_blocked_even_when_internal_is_allowed(): void
{
$this->allowInternalOutboundUrls();
$this->expectException(LanguagePackOperationException::class);
try {
app(LanguagePackService::class)->installFromUrl('http://127.0.0.1/pack.zip', null);
} finally {
Http::assertNothingSent();
}
}
/**
* URL 호출 스케줄은 내부 네트워크 주소를 호출하지 않는다.
*
* @param string $url 내부망을 가리키는 스케줄 URL
*/
#[DataProvider('internalUrlProvider')]
public function test_url_schedule_blocks_internal_addresses(string $url): void
{
$this->assertUrlScheduleIsBlocked($url);
}
/**
* 사내 엔드포인트 주기 호출은 내부 주소 허용 설정으로 가능하다 (운영 옵트인).
*/
public function test_url_schedule_allows_internal_address_when_setting_is_enabled(): void
{
$this->allowInternalOutboundUrls();
$history = app(ScheduleService::class)->runSchedule($this->makeUrlSchedule('http://192.168.0.10/cron'));
$this->assertSame(ScheduleResultStatus::Success, $history->status);
Http::assertSentCount(1);
}
/**
* 내부 주소를 허용해도 userinfo(@) 로 목적지를 위장한 URL 은 계속 차단한다.
*/
public function test_url_schedule_blocks_userinfo_disguise_even_when_internal_is_allowed(): void
{
$this->allowInternalOutboundUrls();
$this->assertUrlScheduleIsBlocked('http://example.com@169.254.169.254/latest/meta-data/');
}
/**
* URL 스케줄 실행이 차단되고 요청이 전송되지 않았음을 단언합니다.
*
* `runSchedule` 은 실행 실패를 실패 이력으로 기록한 뒤 ValidationException 으로 전파한다
* (기존 계약). 차단도 그 경로를 그대로 탄다.
*
* @param string $url 호출 대상 URL
*/
private function assertUrlScheduleIsBlocked(string $url): void
{
$schedule = $this->makeUrlSchedule($url);
try {
app(ScheduleService::class)->runSchedule($schedule);
$this->fail("차단되어야 하는 URL 스케줄이 실행됨: {$url}");
} catch (ValidationException $e) {
// 실행 실패 경로 — 아래에서 요청 미전송 + 실패 이력을 확인한다
}
Http::assertNothingSent();
$this->assertSame(
ScheduleResultStatus::Failed,
$schedule->fresh()->last_result,
"차단된 스케줄이 실패로 기록되지 않음: {$url}"
);
}
/**
* 웹소켓 연결 테스트는 localhost·사설 IP 를 정상 구성으로 허용한다 (기본 설치 동작).
*
* @param string $host 드라이버 설정의 websocket_host
*/
#[DataProvider('legitimateWebsocketHostProvider')]
public function test_websocket_test_allows_local_and_private_hosts(string $host): void
{
$result = app(DriverConnectionTester::class)->testWebsocket([
'websocket_host' => $host,
'websocket_port' => 8080,
'websocket_scheme' => 'http',
]);
$this->assertTrue($result['success']);
}
/**
* 웹소켓 연결 테스트는 정상 설정에서 나올 수 없는 위조 host 를 거부한다.
*
* @param string $host 구조적으로 위조된 websocket_host
*/
#[DataProvider('forgedWebsocketHostProvider')]
public function test_websocket_test_rejects_structurally_forged_hosts(string $host): void
{
$result = app(DriverConnectionTester::class)->testWebsocket([
'websocket_host' => $host,
'websocket_port' => 8080,
'websocket_scheme' => 'https',
]);
$this->assertFalse($result['success']);
Http::assertNothingSent();
}
/**
* 내부망 URL 목록.
*
* @return array<string, array{string}>
*/
public static function internalUrlProvider(): array
{
return [
'클라우드 메타데이터' => ['http://169.254.169.254/latest/meta-data/'],
'루프백' => ['http://127.0.0.1:8080/pack.zip'],
'localhost' => ['http://localhost/pack.zip'],
'사설 IP' => ['http://10.0.0.5/pack.zip'],
'userinfo 위장' => ['https://github.com@127.0.0.1/pack.zip'],
];
}
/**
* 정상 운영에서 쓰이는 웹소켓 host 목록 (기본값 포함).
*
* @return array<string, array{string}>
*/
public static function legitimateWebsocketHostProvider(): array
{
return [
'기본값 localhost' => ['localhost'],
'루프백 IP' => ['127.0.0.1'],
'사내 사설 IP' => ['192.168.0.10'],
'공개 도메인' => ['ws.example.com'],
];
}
/**
* 구조적으로 위조된 웹소켓 host 목록.
*
* @return array<string, array{string}>
*/
public static function forgedWebsocketHostProvider(): array
{
return [
'userinfo 위장' => ['example.com@169.254.169.254'],
'제어문자 주입' => ["example.com\r\nX-Injected: 1"],
];
}
/**
* URL 호출 스케줄을 생성합니다.
*
* @param string $url 호출 대상 URL
* @return Schedule 생성된 스케줄
*/
private function makeUrlSchedule(string $url): Schedule
{
return Schedule::create([
'name' => 'outbound 가드 테스트 스케줄',
'type' => ScheduleType::Url,
'command' => $url,
'expression' => '* * * * *',
'is_active' => true,
]);
}
/**
* 관리자 환경설정에서 내부 주소 호출 허용을 켭니다.
*/
private function allowInternalOutboundUrls(): void
{
Config::set('g7_settings.core.security.allow_internal_outbound_urls', true);
}
}
@@ -0,0 +1,77 @@
/**
* E2E: 환경설정 > 보안 — "내부 네트워크 주소 호출 허용" 토글 (#466)
*
* @scenario admin_settings_security_outbound_toggle
* @effects toggle_mounted, toggle_persisted
*
* 배경: 서버가 대신 보내는 outbound 요청(예약 작업 URL 호출, 외부 API 연동)에서 내부
* 네트워크 주소를 기본 차단하되, 사내 서버를 호출해야 하는 운영 환경을 위해 이 토글로
* 예외를 허용한다. 토글이 실제로 마운트되고 저장까지 되는지 브라우저에서 확인한다.
*
* 검증:
* 1. 보안 탭에 토글이 마운트되고 항목명·설명이 raw 키가 아닌 번역문으로 표시된다
* 2. 토글을 켜고 저장하면 422 없이 성공하고, 재진입 시 켜진 상태가 유지된다
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
const TOGGLE_ROW = '#toggle_allow_internal_outbound_urls';
/** 관리자 환경설정 보안 탭 진입 */
async function gotoSecurityTab(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/admin/settings?tab=security');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect(page.locator(TOGGLE_ROW)).toBeAttached({ timeout: 20_000 });
}
// @scenario tab=security, permitted=yes
// @effects toggle_mounted
test('@smoke #466 - 보안 탭에 "내부 네트워크 주소 호출 허용" 토글이 번역문과 함께 마운트된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoSecurityTab(page);
expect(page.url()).not.toMatch(/\/admin\/login/);
const row = page.locator(TOGGLE_ROW);
await expect(row).toBeAttached();
// 다국어 키가 해석되지 않으면 "$t:admin.settings.security..." 원문이 그대로 노출된다 (회귀 가드)
const text = (await row.innerText()).trim();
expect(text).not.toContain('$t:');
expect(text.length).toBeGreaterThan(0);
// 토글 입력이 행 안에 실제로 존재한다
await expect(row.locator('input[type="checkbox"]').first()).toBeAttached();
});
// @scenario tab=security, permitted=yes
// @effects toggle_interactive
test('#466 - 토글을 클릭하면 상태가 바뀌고 저장 버튼이 활성화된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoSecurityTab(page);
const wasOn = await isToggleOn(page);
// Toggle 은 sr-only checkbox 를 감싼 wrapper 가 클릭 대상이다
await page.locator(`${TOGGLE_ROW} .toggle-switch-wrapper`).first().click();
// 클릭이 실제 상태 변경으로 이어진다 (폼 바인딩 회귀 가드)
await expect
.poll(() => isToggleOn(page), { timeout: 10_000 })
.toBe(!wasOn);
// 변경이 감지되어 저장 버튼이 활성화된다 (_local.hasChanges 바인딩 확인)
await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 });
});
/**
* 보안 탭 토글의 on/off 상태를 읽는다 (sr-only checkbox 의 checked 기준).
*/
async function isToggleOn(page: import('@playwright/test').Page): Promise<boolean> {
return page
.locator(`${TOGGLE_ROW} input[type="checkbox"]`)
.first()
.evaluate((el) => (el as HTMLInputElement).checked);
}
@@ -0,0 +1,201 @@
<?php
namespace Tests\Unit\Support;
use App\Support\OutboundUrlValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* outbound URL 검증 유틸 테스트.
*
* 접두사 매칭 우회(userinfo `@`, 접미사 확장)와 내부망 타격(사설/루프백/링크로컬 IP,
* 내부 도메인)을 전수 매트릭스로 차단 검증한다.
*/
class OutboundUrlValidatorTest extends TestCase
{
/** 본인인증 게이트웨이 화이트리스트 (실사용 값) */
private const HOSTS = ['kssa.inicis.com', 'fcsa.inicis.com'];
/**
* 화이트리스트 host 와 완전 일치하는 URL 은 통과한다.
*
* @param string $url 검증 대상 URL
*/
#[Test]
#[DataProvider('allowedHostUrlProvider')]
public function it_allows_urls_whose_host_exactly_matches_the_whitelist(string $url): void
{
$this->assertTrue(OutboundUrlValidator::isHostAllowed($url, self::HOSTS));
}
/**
* 화이트리스트를 우회하려는 URL 은 모두 차단한다.
*
* @param string $url 검증 대상 URL
* @param string $reason 차단 사유 (실패 메시지용)
*/
#[Test]
#[DataProvider('blockedHostUrlProvider')]
public function it_blocks_urls_that_do_not_exactly_match_the_whitelist(string $url, string $reason): void
{
$this->assertFalse(
OutboundUrlValidator::isHostAllowed($url, self::HOSTS),
"차단되어야 하는 URL 이 통과함 ({$reason}): {$url}"
);
}
/**
* 공개 인터넷 URL 은 통과한다.
*
* @param string $url 검증 대상 URL
*/
#[Test]
#[DataProvider('publicUrlProvider')]
public function it_allows_public_internet_urls(string $url): void
{
$this->assertTrue(
OutboundUrlValidator::isPublicHttpUrl($url, ['schemes' => ['http', 'https']]),
"공개 URL 이 차단됨: {$url}"
);
}
/**
* 내부망을 가리키는 URL 은 모두 차단한다.
*
* @param string $url 검증 대상 URL
* @param string $reason 차단 사유 (실패 메시지용)
*/
#[Test]
#[DataProvider('internalUrlProvider')]
public function it_blocks_urls_pointing_at_internal_addresses(string $url, string $reason): void
{
$this->assertFalse(
OutboundUrlValidator::isPublicHttpUrl($url, ['schemes' => ['http', 'https']]),
"차단되어야 하는 내부 URL 이 통과함 ({$reason}): {$url}"
);
}
/**
* scheme 기본값은 https 전용 — http 는 옵트인해야 통과한다.
*/
#[Test]
public function it_rejects_http_unless_explicitly_opted_in(): void
{
$this->assertFalse(OutboundUrlValidator::isPublicHttpUrl('http://example.com/api'));
$this->assertTrue(OutboundUrlValidator::isPublicHttpUrl('http://example.com/api', ['schemes' => ['http', 'https']]));
}
/**
* 화이트리스트 검증은 기본적으로 명시 포트를 거부한다.
*/
#[Test]
public function it_rejects_explicit_ports_for_whitelisted_hosts_by_default(): void
{
$this->assertFalse(OutboundUrlValidator::isHostAllowed('https://kssa.inicis.com:8080/auth', self::HOSTS));
$this->assertTrue(OutboundUrlValidator::isHostAllowed('https://kssa.inicis.com:8080/auth', self::HOSTS, ['allowPort' => true]));
}
/**
* host 단독 판정도 동일한 내부망 차단 규칙을 따른다.
*/
#[Test]
public function it_judges_bare_hosts_with_the_same_internal_rules(): void
{
$this->assertTrue(OutboundUrlValidator::isPublicHost('example.com'));
$this->assertFalse(OutboundUrlValidator::isPublicHost('localhost'));
$this->assertFalse(OutboundUrlValidator::isPublicHost('127.0.0.1'));
$this->assertFalse(OutboundUrlValidator::isPublicHost('169.254.169.254'));
$this->assertFalse(OutboundUrlValidator::isPublicHost(''));
}
/**
* 화이트리스트 통과 URL 목록.
*
* @return array<string, array{string}>
*/
public static function allowedHostUrlProvider(): array
{
return [
'표준 인증 URL' => ['https://kssa.inicis.com/auth/result'],
'두 번째 화이트리스트 host' => ['https://fcsa.inicis.com/auth/result'],
'대소문자 혼용 host' => ['https://KSSA.Inicis.COM/auth/result'],
'경로 없음' => ['https://kssa.inicis.com'],
'쿼리스트링 포함' => ['https://kssa.inicis.com/auth?txId=abc'],
];
}
/**
* 화이트리스트 차단 URL 목록 (우회 벡터 전수).
*
* @return array<string, array{string, string}>
*/
public static function blockedHostUrlProvider(): array
{
return [
'userinfo 로 내부 IP 위장' => ['https://kssa.inicis.com@127.0.0.1/', 'userinfo(@) 뒤가 실제 host'],
'userinfo 로 메타데이터 위장' => ['https://kssa.inicis.com@169.254.169.254/latest/meta-data/', 'userinfo(@) 뒤가 실제 host'],
'userinfo + 비밀번호' => ['https://kssa.inicis.com:pw@evil.example/', 'user:pass@host'],
'접미사 확장 도메인' => ['https://kssa.inicis.com.attacker.com/', '화이트리스트가 접두사일 뿐 host 불일치'],
'숫자 접미사 확장' => ['https://kssa.inicis.com.169.254.169.254.nip.io/', '접미사 확장'],
'하이픈 접미사' => ['https://kssa.inicis.com-evil.example/', '접미사 확장'],
'서브도메인 위장' => ['https://evil.example/kssa.inicis.com', '경로에만 포함'],
'http scheme' => ['http://kssa.inicis.com/auth', 'https 아님'],
'scheme 없음' => ['//kssa.inicis.com/auth', 'scheme 부재'],
'host 없는 상대경로' => ['/auth/result', 'host 부재'],
'빈 문자열' => ['', '입력 없음'],
'공백' => [' ', '입력 없음'],
'file scheme' => ['file:///etc/passwd', '허용 scheme 아님'],
'gopher scheme' => ['gopher://kssa.inicis.com/', '허용 scheme 아님'],
'CRLF 주입' => ["https://kssa.inicis.com/auth\r\nX-Injected: 1", '제어문자 포함'],
'완전 무관 host' => ['https://attacker.example/', '화이트리스트 불일치'],
];
}
/**
* 공개 인터넷 URL 목록.
*
* @return array<string, array{string}>
*/
public static function publicUrlProvider(): array
{
return [
'https 공개 도메인' => ['https://example.com/api/shipping'],
'http 공개 도메인' => ['http://api.example.co.kr/fee'],
'비표준 포트' => ['https://api.example.com:8443/fee'],
'공인 IP' => ['https://8.8.8.8/'],
'서브도메인' => ['https://shipping.api.example.com/v1/quote'],
];
}
/**
* 내부망 차단 URL 목록 (SSRF 표적 전수).
*
* @return array<string, array{string, string}>
*/
public static function internalUrlProvider(): array
{
return [
'클라우드 메타데이터' => ['http://169.254.169.254/latest/meta-data/', '링크로컬 메타데이터'],
'GCP 메타데이터 호스트' => ['http://metadata.google.internal/computeMetadata/v1/', '.internal 내부 도메인'],
'루프백 IPv4' => ['http://127.0.0.1:8080/admin', '루프백'],
'루프백 변형' => ['http://127.1/', '루프백 대역'],
'루프백 IPv6' => ['http://[::1]/', '루프백'],
'localhost' => ['http://localhost:9200/_cat/indices', 'localhost'],
'사설 10 대역' => ['http://10.0.0.5/internal', '사설 IP'],
'사설 172.16 대역' => ['http://172.16.0.10/internal', '사설 IP'],
'사설 192.168 대역' => ['http://192.168.1.1/router', '사설 IP'],
'.local 내부 도메인' => ['http://printer.local/', '.local'],
'.internal 내부 도메인' => ['http://vault.internal/v1/secret', '.internal'],
'.lan 내부 도메인' => ['http://nas.lan/', '.lan'],
'10진수 인코딩 IP' => ['http://2130706433/', '127.0.0.1 의 10진수 표기'],
'16진수 인코딩 IP' => ['http://0x7f000001/', '127.0.0.1 의 16진수 표기'],
'userinfo 위장' => ['https://example.com@127.0.0.1/', 'userinfo 뒤가 실제 host'],
'0.0.0.0' => ['http://0.0.0.0:3000/', '예약 대역'],
'CRLF 주입' => ["http://example.com/\r\nHost: internal", '제어문자 포함'],
'file scheme' => ['file:///etc/passwd', '허용 scheme 아님'],
'host 부재' => ['http:///etc/passwd', 'host 부재'],
];
}
}