Files
HeuJung 9cf9c3ff8c fix(core,board,ecommerce,payments,basic): KVE-2026 보안 게이트 6묶음 + 자격증명 전송로 정합
KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과
그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다.

globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를
직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다.
서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이
그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고,
같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다.

아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩
걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
2026-09-06 00:42:29 +09:00

362 lines
14 KiB
PHP

<?php
namespace Tests\Unit\Support;
use App\Support\OutboundProxy;
use Tests\TestCase;
/**
* 아웃바운드 프록시 적용 판정을 고정합니다.
*
* 이 판정은 코어가 바깥으로 내보내는 모든 HTTP 요청의 경로를 정합니다. 잘못 열리면 결제 승인
* 요청까지 운영자가 지정한 제3의 서버를 경유하게 되고, 그 사실이 예외나 로그로 드러나지
* 않습니다 — 요청은 정상 성공하고 상대편에 보이는 출발지 IP 만 달라집니다.
*
* 그래서 관리자 화면에서 입력칸을 감추는 것은 게이트가 아닙니다. 저장 API 를 직접 호출하면
* 값은 그대로 저장되므로, 실질 게이트는 이 판정 하나입니다.
*
* 축 조합은 각 테스트 메서드의 @scenario 주석이 담당한다.
*/
class OutboundProxyTest extends TestCase
{
/**
* 디버그 모드가 꺼져 있으면 저장값이 남아 있어도 적용하지 않습니다.
*
* @scenario debug_mode=off, proxy_value=valid, bypass_list=empty
*
* @effects proxy_not_applied_when_debug_mode_off
*/
public function test_proxy_is_not_applied_when_debug_mode_is_off(): void
{
$resolved = OutboundProxy::resolve([
'mode' => false,
'outbound_proxy' => 'socks5h://127.0.0.1:1080',
'outbound_proxy_bypass' => ['internal.example'],
]);
$this->assertNull(
$resolved,
'디버그 모드가 꺼진 상태에서 프록시가 적용되었습니다 — 화면 조건부 렌더링은 저장 API 직접 호출을 막지 못하므로 이 판정이 유일한 게이트입니다.'
);
}
/**
* mode 키 자체가 없는 설정도 미적용으로 판정합니다.
*
* @scenario debug_mode=off, proxy_value=valid, bypass_list=empty
*
* @effects proxy_not_applied_when_debug_mode_off
*/
public function test_proxy_is_not_applied_when_mode_key_is_absent(): void
{
$this->assertNull(
OutboundProxy::resolve(['outbound_proxy' => 'http://proxy.internal:3128']),
'mode 키 부재를 디버그 모드 ON 으로 해석했습니다 — 판정은 fail-closed 여야 합니다.'
);
}
/**
* 디버그 모드가 켜져 있고 주소가 유효하면 http/https 양쪽에 적용합니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
*
* @effects proxy_applied_when_debug_mode_on
*/
public function test_proxy_is_applied_for_both_schemes_when_debug_mode_is_on(): void
{
$resolved = OutboundProxy::resolve([
'mode' => true,
'outbound_proxy' => 'socks5h://127.0.0.1:1080',
]);
$this->assertSame('socks5h://127.0.0.1:1080', $resolved['http']);
$this->assertSame('socks5h://127.0.0.1:1080', $resolved['https']);
// `no` 는 비어 있지 않다 — 자기 호스트·루프백은 운영자 입력과 무관하게 항상 들어간다.
// 그 계약은 test_site_own_host_is_always_bypassed 이하가 따로 잠근다.
$this->assertIsArray($resolved['no']);
}
/**
* 값이 비어 있으면 프록시를 쓰지 않는 상태로 판정합니다.
*
* @scenario debug_mode=on, proxy_value=empty, bypass_list=empty
*
* @effects proxy_applied_when_debug_mode_on
*/
public function test_empty_proxy_value_disables_proxy(): void
{
$this->assertNull(OutboundProxy::resolve(['mode' => true, 'outbound_proxy' => '']));
$this->assertNull(OutboundProxy::resolve(['mode' => true, 'outbound_proxy' => ' ']));
$this->assertNull(OutboundProxy::resolve(['mode' => true]));
}
/**
* 허용 목록에 없는 스킴과 호스트 없는 주소는 적용하지 않습니다.
*
* @scenario debug_mode=on, proxy_value=invalid, bypass_list=empty
*
* @effects proxy_rejects_disallowed_scheme
*
* @dataProvider invalidProxyUrls
*/
public function test_invalid_proxy_url_is_not_applied(string $url, string $why): void
{
$this->assertNull(
OutboundProxy::resolve(['mode' => true, 'outbound_proxy' => $url]),
$why
);
$this->assertFalse(OutboundProxy::isValidUrl($url), $why);
}
/**
* 적용 불가 주소 목록.
*
* @return array<string, array{0: string, 1: string}>
*/
public static function invalidProxyUrls(): array
{
return [
'스킴 없음' => ['127.0.0.1:1080', '스킴 없는 주소가 통과했습니다 — cURL 이 프록시로 해석하지 못합니다.'],
'파일 스킴' => ['file:///etc/passwd', 'file 스킴이 통과했습니다.'],
'ftp 스킴' => ['ftp://proxy.internal:21', '허용 목록에 없는 스킴이 통과했습니다.'],
'호스트 없음' => ['http://', '호스트 없는 주소가 통과했습니다.'],
'빈 문자열' => ['', '빈 값이 유효 주소로 판정됐습니다.'],
];
}
/**
* 허용 스킴은 모두 적용 가능해야 합니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
*
* @effects proxy_applied_when_debug_mode_on
*/
public function test_every_allowed_scheme_is_accepted(): void
{
foreach (OutboundProxy::ALLOWED_SCHEMES as $scheme) {
$this->assertTrue(
OutboundProxy::isValidUrl($scheme.'://proxy.internal:1080'),
"허용 목록의 스킴 {$scheme} 이 거부됐습니다 — 목록과 판정이 어긋나면 저장은 되는데 적용되지 않습니다."
);
}
}
/**
* 예외 목록은 공백 제거·빈 항목 제거·중복 제거 후 순번을 다시 매깁니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
*
* @effects proxy_bypass_list_normalized
*/
public function test_bypass_list_is_normalized(): void
{
$resolved = OutboundProxy::resolve([
'mode' => true,
'outbound_proxy' => 'http://proxy.internal:3128',
'outbound_proxy_bypass' => [' g7.dev ', '', 'g7.dev', 'localhost', 42, null],
]);
// 운영자 입력분: 공백 제거·빈 항목 제거·중복 제거가 그대로 적용된다.
$this->assertSame(1, count(array_keys($resolved['no'], 'g7.dev', true)), '중복이 남았습니다.');
$this->assertSame(1, count(array_keys($resolved['no'], 'localhost', true)), '자동 항목과 운영자 항목이 겹쳐 중복이 남았습니다.');
$this->assertSame(
range(0, count($resolved['no']) - 1),
array_keys($resolved['no']),
'예외 목록 키가 비연속입니다 — JSON 직렬화 시 객체가 되어 목록으로 읽히지 않습니다.'
);
}
/**
* 예외 목록이 배열이 아니면 빈 목록으로 취급합니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
*
* @effects proxy_bypass_list_normalized
*/
public function test_non_array_bypass_list_becomes_empty(): void
{
$resolved = OutboundProxy::resolve([
'mode' => true,
'outbound_proxy' => 'http://proxy.internal:3128',
'outbound_proxy_bypass' => 'g7.dev',
]);
// 배열이 아닌 값은 통째로 버린다 — 문자열을 한 항목으로 해석하지 않는다.
$this->assertNotContains('g7.dev', $resolved['no']);
}
/**
* 적용 중인 프록시는 curl 옵션 형태로도 제공됩니다.
*
* `Http::` 파사드를 쓰지 못하는 호출 지점(외부 SDK 규약상 curl 핸들을 직접 다뤄야 하는
* 경우)이 같은 프록시를 타려면 이 통로가 필요합니다. 이 통로가 없으면 그 경로만 조용히
* 직접 나가고, 요청은 정상 성공하므로 아무 신호도 남지 않습니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
*
* @effects proxy_exposed_as_curl_options
*/
public function test_resolved_proxy_is_exposed_as_curl_options(): void
{
config(['g7.outbound_proxy' => OutboundProxy::resolve([
'mode' => true,
'outbound_proxy' => 'socks5h://127.0.0.1:1080',
'outbound_proxy_bypass' => ['g7.dev', 'localhost'],
])]);
$options = OutboundProxy::curlOptions();
$this->assertSame('socks5h://127.0.0.1:1080', $options[CURLOPT_PROXY]);
// 자동 항목(자기 호스트·루프백)과 운영자 항목이 같은 쉼표 목록으로 전달된다.
$this->assertStringContainsString('g7.dev', $options[CURLOPT_NOPROXY]);
$this->assertStringContainsString('127.0.0.1', $options[CURLOPT_NOPROXY]);
}
/**
* 프록시 미적용 상태에서는 빈 배열이라 curl_setopt_array 에 그대로 넘겨도 무해합니다.
*
* @scenario debug_mode=off, proxy_value=valid, bypass_list=empty
*
* @effects proxy_exposed_as_curl_options
*/
public function test_curl_options_are_empty_when_proxy_is_not_applied(): void
{
config(['g7.outbound_proxy' => OutboundProxy::resolve([
'mode' => false,
'outbound_proxy' => 'socks5h://127.0.0.1:1080',
])]);
$this->assertSame([], OutboundProxy::curlOptions());
}
/**
* 저장 전 연결 테스트와 실제 적용이 같은 조립·정규화를 거칩니다.
*
* 이 테스트의 목적은 "저장 전에 확인한다" 는 기능의 전제를 지키는 것입니다. 조립을 두 곳에서
* 각각 하면 예외 목록의 공백·빈 항목·중복 처리가 어긋나, 운영자가 확인한 구성과 저장 후
* 실제로 적용되는 구성이 달라집니다. 두 구성 모두 정상 동작하므로 어긋남 자체는 아무런
* 오류도 남기지 않습니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
*
* @effects proxy_bypass_list_normalized
*/
public function test_connection_test_and_applied_config_share_the_same_assembly(): void
{
$url = ' socks5h://127.0.0.1:1080 ';
$bypass = [' g7.dev ', '', 'g7.dev', 'localhost'];
// 실제 적용 경로 (SettingsServiceProvider → config('g7.outbound_proxy'))
$applied = OutboundProxy::resolve([
'mode' => true,
'outbound_proxy' => $url,
'outbound_proxy_bypass' => $bypass,
]);
// 저장 전 연결 테스트 경로 (OutboundProxyTester)
$tested = OutboundProxy::options($url, $bypass);
$this->assertSame($applied, $tested);
$this->assertContains('g7.dev', $tested['no']);
$this->assertSame('socks5h://127.0.0.1:1080', $tested['https']);
}
/**
* 사이트 자기 호스트는 운영자가 적지 않아도 항상 프록시를 우회합니다.
*
* 아웃바운드 프록시는 바깥으로 나가는 트래픽의 출발지를 지정하려는 장치입니다. 자기
* 자신에게 되돌아오는 요청(SEO 렌더러의 데이터소스 조회, API 문서 생성기의 엔드포인트
* 탐침)까지 프록시로 내보내면, 프록시가 응답하지 않을 때 그 호출이 연결 실패 시각까지
* 매달립니다. 실패는 삼켜지므로 예외도 오류 화면도 없이 저장 요청만 느려집니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
*
* @effects self_host_always_bypasses_proxy
*/
public function test_site_own_host_is_always_bypassed(): void
{
config(['app.url' => 'https://shop.example.com']);
$resolved = OutboundProxy::resolve([
'mode' => true,
'outbound_proxy' => 'socks5h://127.0.0.1:1080',
'outbound_proxy_bypass' => [],
]);
$this->assertContains(
'shop.example.com',
$resolved['no'],
'자기 호스트가 프록시 우회 목록에 없습니다 — 사이트가 자기 자신에게 거는 요청까지 프록시로 나갑니다.'
);
}
/**
* 루프백 호스트도 항상 우회합니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
*
* @effects self_host_always_bypasses_proxy
*/
public function test_loopback_hosts_are_always_bypassed(): void
{
$resolved = OutboundProxy::resolve([
'mode' => true,
'outbound_proxy' => 'socks5h://127.0.0.1:1080',
'outbound_proxy_bypass' => [],
]);
foreach (['localhost', '127.0.0.1', '::1'] as $host) {
$this->assertContains(
$host,
$resolved['no'],
"루프백 호스트 {$host} 가 우회 목록에 없습니다."
);
}
}
/**
* 운영자가 지정한 예외는 자동 항목과 함께 보존됩니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
*
* @effects self_host_always_bypasses_proxy
*/
public function test_operator_bypass_entries_survive_alongside_self_hosts(): void
{
config(['app.url' => 'https://shop.example.com']);
$resolved = OutboundProxy::resolve([
'mode' => true,
'outbound_proxy' => 'socks5h://127.0.0.1:1080',
'outbound_proxy_bypass' => ['internal.example', 'localhost'],
]);
$this->assertContains('internal.example', $resolved['no']);
$this->assertContains('shop.example.com', $resolved['no']);
$this->assertSame(
array_values(array_unique($resolved['no'])),
$resolved['no'],
'자동 항목과 운영자 항목이 겹쳐 중복이 남았습니다.'
);
}
/**
* 예외 목록이 배열이 아니어도 자동 항목은 유지됩니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
*
* @effects self_host_always_bypasses_proxy
*/
public function test_self_hosts_survive_a_non_array_bypass_value(): void
{
$resolved = OutboundProxy::resolve([
'mode' => true,
'outbound_proxy' => 'socks5h://127.0.0.1:1080',
'outbound_proxy_bypass' => 'g7.dev',
]);
$this->assertContains('127.0.0.1', $resolved['no']);
}
}