KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과 그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다. globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를 직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다. 서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이 그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고, 같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다. 아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩 걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
362 lines
14 KiB
PHP
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']);
|
|
}
|
|
}
|