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:
1 parent
f848086fd6
commit
c4beb116ad
46 files changed
+1297
-55
No files matched your search
@@ -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 | 아니오 | — | 테스트 계산에 사용할 샘플 주문 데이터 (무게/금액/수량 등) |
|
||||
|
||||
+2
-1
@@ -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
-1
@@ -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 응답에서 배송비 값을 추출합니다 (응답 형식별 분기).
|
||||
*
|
||||
|
||||
+97
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user