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 에 등재했다.

아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩
걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
This commit is contained in:
HeuJung
2026-09-06 00:42:29 +09:00
parent 8f072895ff
commit 9cf9c3ff8c
104 changed files with 4522 additions and 412 deletions
@@ -242,21 +242,22 @@ class UserControllerDeleteTest extends TestCase
}
// ========================================================================
// 삭제 실패 시 에러 상세 메시지 노출 (:error placeholder 치환)
// 삭제 실패 응답 — placeholder 미노출 + 예외 원문 비노출
// ========================================================================
/**
* 회귀 — 삭제 실패 시 토스트 message 에 `:error` placeholder 가 그대로 노출되지 않고,
* 구체적 실패 사유가 치환되어 사용자에게 보여야 한다.
* 회귀 — 삭제 실패 시 응답 message 에 `:error` placeholder 가 그대로 남지 않고,
* 동시에 예외 원문(내부 사정)이 사용자 응답에 실리지 않아야 한다.
*
* 사례: 플러그인 FK 제약 등으로 UserService::deleteUser 가 ValidationException 을
* 던질 때, UserController::destroy 의 422 경로가 message 용 치환값을 전달하지 않아
* 토스트에 `사용자 삭제에 실패했습니다: :error` 가 그대로 노출됨 (#415).
* 이력: #415 는 토스트에 `사용자 삭제에 실패했습니다: :error` 가 그대로 노출되던 것을
* 고치면서 구체 사유를 message 에 실었다. 이후 보안 정정(#577)이 그 방향을 뒤집어,
* UserService::deleteUser 는 원본 예외를 로그로만 남기고 응답에는 일반 안내만 싣는다.
* 두 요구는 양립한다 — placeholder 가 남지 않으면서 원문도 새지 않으면 된다.
*
* 검증: 응답 message 에 `:error` 가 남지 않고 구체 사유가 포함되며, errors.general 에도
* 동일 상세가 담긴다 (에러 상세 표시 기능 유지 — 메시지를 숨기지 않음).
* 검증: message 에 `:error` 가 없고, 훅이 던진 예외 원문도 응답 어디에도 없다.
* 그리고 사용자가 읽을 수 있는 일반 안내 문구가 온다.
*/
public function test_delete_failure_message_substitutes_error_detail_not_raw_placeholder(): void
public function test_delete_failure_message_has_no_placeholder_and_no_raw_exception_detail(): void
{
$target = User::factory()->create(['is_super' => false]);
@@ -280,14 +281,20 @@ class UserControllerDeleteTest extends TestCase
$response->assertJsonPath('success', false);
$message = (string) $response->json('message');
$body = (string) $response->getContent();
// 핵심: placeholder 가 그대로 노출되면 안 됨
// 핵심 1: placeholder 가 그대로 노출되면 안 된다 (#415 회귀 방지)
$this->assertStringNotContainsString(':error', $message, 'message 에 미치환 :error 가 남으면 안 된다');
// 핵심: 실패 사유가 사용자에게 보여야 함 (기능 유지)
$this->assertStringContainsString($reason, $message, '구체적 실패 사유가 message 에 노출되어야 한다');
$this->assertStringNotContainsString(':error', $body, '응답 어디에도 미치환 :error 가 남으면 안 된다');
// errors.general 에도 동일 상세가 담긴다
$this->assertStringContainsString($reason, (string) $response->json('errors.general.0'));
$this->assertStringNotContainsString(':error', (string) $response->json('errors.general.0'));
// 핵심 2: 예외 원문(내부 사정)이 응답에 실리면 안 된다 (#577 보안 정정)
$this->assertStringNotContainsString(
$reason,
$body,
'예외 원문이 응답에 실리면 안 된다 — 원본은 로그로만 남긴다'
);
// 사용자가 읽을 수 있는 일반 안내가 온다
$this->assertSame(__('user.delete_failed'), $message);
}
}
@@ -38,6 +38,7 @@ class IdentityChallengeShowTest extends TestCase
/**
* @scenario source=identity-challenge-status-field-exposure axis=field:id,field:status,field:public_payload
*
* @effects public_safe_fields_present_in_response
*/
public function test_show_returns_public_status_fields(): void
@@ -61,6 +62,7 @@ class IdentityChallengeShowTest extends TestCase
/**
* @scenario source=identity-challenge-status-field-exposure axis=field:attempts,field:max_attempts,field:target_hash
*
* @effects attempts_absent_from_response, max_attempts_absent_from_response, target_hash_absent_from_response, verification_token_absent_from_response, metadata_absent_from_response
*/
public function test_show_does_not_expose_sensitive_fields(): void
@@ -73,13 +75,18 @@ class IdentityChallengeShowTest extends TestCase
$response = $this->getJson("/api/identity/challenges/{$id}");
// 시도 횟수(attempts / max_attempts)·코드 본체·target_hash·verification_token 등 노출 금지.
// 이 엔드포인트는 권한 가드 없는 공개 폴링용이므로 남은 시도 횟수를 추론할 단서를 응답에 담지 않는다.
// 누적 시도 횟수(attempts)·코드 본체·target_hash·verification_token 등 노출 금지.
// 이 엔드포인트는 권한 가드 없는 공개 폴링용이므로, 남의 인증이 몇 번 실패했는지를
// 추론할 단서를 응답에 담지 않는다.
$response->assertJsonMissingPath('data.attempts')
->assertJsonMissingPath('data.max_attempts')
->assertJsonMissingPath('data.target_hash')
->assertJsonMissingPath('data.verification_token')
->assertJsonMissingPath('data.metadata');
// 상한(max_attempts)은 정책 상수라 노출한다 — 화면이 남은 횟수를 표시하려면 필요하고,
// 상수 자체로는 그 인증 건이 몇 번 실패했는지가 드러나지 않는다.
// (Service::getStatus 의 판정과 같은 계약. 한쪽만 바뀌면 이 단언이 먼저 깨진다.)
$this->assertIsInt($response->json('data.max_attempts'));
}
public function test_show_reflects_status_transitions(): void
+66 -2
View File
@@ -156,6 +156,63 @@ class BinaryPathPolicyTest extends TestCase
$this->assertTrue(installer_is_composer_binary_path('/usr/local/php99/bin/composer'));
}
/**
* 검증 지점과 실행 지점이 같은 정책 파일을 쓰는지 확인합니다.
*
* 정책이 한 파일에 있어도 어느 한 진입점이 그 파일을 require 하지 않으면
* "저장은 막히는데 실행은 통과" 같은 어긋난 상태가 남고, 그 어긋남은 오류를
* 남기지 않는다. 그래서 파일 참조 자체를 계약으로 고정한다 (KVE-2026-2043).
*/
#[Test]
public function validation_and_execution_entrypoints_share_the_same_policy_file(): void
{
$projectRoot = dirname(__DIR__, 3);
$entrypoints = [
'api/check-configuration.php' => $projectRoot.'/public/install/api/check-configuration.php',
'includes/task-runner.php' => $projectRoot.'/public/install/includes/task-runner.php',
'includes/request-handler.php' => $projectRoot.'/public/install/includes/request-handler.php',
];
foreach ($entrypoints as $label => $path) {
$this->assertFileExists($path, "정책 소비 진입점이 사라졌다: {$label}");
$this->assertStringContainsString(
'binary-path-policy.php',
(string) file_get_contents($path),
"{$label} 이 공용 정책을 로드하지 않는다 — 자체 판정으로 갈라지면 우회로가 된다"
);
}
}
/**
* UNC·임의 phar 는 (PHP, Composer) 쌍 해석에서도 거부된다.
*
* 쌍 해석은 자리별 규칙을 재사용하므로 여기서도 같은 결론이 나와야 한다 —
* 한쪽만 막히면 그 경로가 우회로가 된다.
*/
#[Test]
public function the_pair_resolver_rejects_unc_and_arbitrary_phar(): void
{
$this->assertNull(
installer_resolve_php_composer_pair('\\\\server\\share\\php.exe /usr/local/bin/composer'),
'UNC PHP 경로가 쌍 해석에서 통과했다'
);
$this->assertNull(
installer_resolve_php_composer_pair('/usr/bin/php \\\\server\\share\\composer.phar'),
'UNC Composer 경로가 쌍 해석에서 통과했다'
);
$this->assertNull(
installer_resolve_php_composer_pair('/usr/bin/php /tmp/evil.phar'),
'임의 이름 phar 가 쌍 해석에서 통과했다'
);
// 정상 조합은 그대로 동작한다 (회귀 방지).
$this->assertSame(
['php' => '/usr/bin/php', 'composer' => '/usr/local/bin/composer.phar'],
installer_resolve_php_composer_pair('/usr/bin/php /usr/local/bin/composer.phar')
);
}
/**
* 통과해야 하는 토큰 목록.
*
@@ -171,7 +228,6 @@ class BinaryPathPolicyTest extends TestCase
'Plesk 경로' => ['/opt/plesk/php/8.2/bin/php', '패널 환경'],
'Windows 드라이브' => ['C:\\php\\php.exe', 'Windows'],
'Windows 슬래시' => ['C:/php/php.exe', 'Windows 정방향 슬래시'],
'UNC 경로' => ['\\\\server\\share\\php.exe', '네트워크 공유'],
'점 포함 디렉토리' => ['/usr/local/php.d/bin/php', '단일 점은 상위 참조가 아님'],
];
}
@@ -200,6 +256,12 @@ class BinaryPathPolicyTest extends TestCase
'따옴표' => ['/usr/bin/"php"', '셸 메타문자'],
'개행' => ["/usr/bin/php\nid", '제어문자'],
'NUL 바이트' => ["/usr/bin/php\x00", '제어문자'],
// KVE-2026-2043: UNC 경로는 공격자가 지정한 원격 SMB 공유의 파일을 실행하게 만든다.
'UNC 경로' => ['\\\\server\\share\\php.exe', '원격 공유 실행 — KVE-2026-2043'],
'UNC 정방향 슬래시' => ['//server/share/php.exe', '원격 공유 실행'],
'phar 스트림 래퍼' => ['phar:///tmp/evil.phar/run', 'stream wrapper'],
'http 스트림 래퍼' => ['http://evil.example.com/x', 'stream wrapper'],
'file 스트림 래퍼' => ['file:///usr/bin/php', 'stream wrapper'],
];
}
@@ -215,7 +277,9 @@ class BinaryPathPolicyTest extends TestCase
'절대경로 composer' => ['/usr/local/bin/composer', true, '일반 설치'],
'composer2' => ['/usr/local/bin/composer2', true, '버전 붙은 이름'],
'composer.phar' => ['/opt/composer.phar', true, 'phar 아카이브'],
'임의 이름 phar' => ['/opt/tools/mytool.phar', true, 'phar 는 이름 무관 허용'],
// KVE-2026-2043: 임의 이름 .phar 허용은 업로드된 아카이브를 그대로 실행시킨다.
'임의 이름 phar' => ['/opt/tools/mytool.phar', false, '이름이 composer 계열이 아닌 phar'],
'composerN.phar' => ['/opt/composer2.phar', true, '버전 붙은 composer phar'],
'composer.bat' => ['C:\\laragon\\bin\\composer\\composer.bat', true, 'Windows 배치'],
'composer.exe' => ['C:\\tools\\composer.exe', true, 'Windows 실행 파일'],
'임의 스크립트' => ['/tmp/evil.py', false, '인터프리터가 실행할 임의 스크립트 — 이번 취약점'],
@@ -0,0 +1,153 @@
<?php
namespace Tests\Unit\Installer;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* 인스톨러 .env 값 직렬화 테스트 (KVE-2026-2042).
*
* 회귀 배경: `.env` 는 줄 단위 형식이라 사용자 입력에 개행이 섞이면 그 뒤가 새로운
* 환경변수 줄로 해석된다. 기존 escapeEnvValue() 는 개행을 조용히 지웠고 그마저도
* 일부 값(호스트·DB명·앱 이름·업데이트 URL 등)에는 적용되지 않아, 설치 창이 열려 있는
* 동안 임의 환경변수를 주입할 수 있었다.
*
* 정책: 직렬화기는 CR/LF/NUL 을 만나면 예외를 던진다(조용한 삭제 금지 — 삭제하면
* 운영자가 자기가 입력한 값과 다른 값이 저장된 것을 알 수 없다). 값 전달 경로가
* 하나이므로 새 필드가 추가돼도 같은 관문을 지난다.
*
* 정책 파일은 의존성이 없어야 한다 — 인스톨러 본 흐름과 워커 양쪽에서 단독으로
* require 되므로, 이 테스트도 BASE_PATH·lang 스텁 없이 파일만 로드한다.
*/
class EnvValueSerializationTest extends TestCase
{
public static function setUpBeforeClass(): void
{
require_once dirname(__DIR__, 3).'/public/install/includes/env-value.php';
}
/**
* 정상 값은 큰따옴표로 감싸 반환된다.
*/
#[Test]
public function it_quotes_a_normal_value(): void
{
$this->assertSame('"g7"', serializeEnvValue('g7'));
$this->assertSame('"127.0.0.1"', serializeEnvValue('127.0.0.1'));
}
/**
* 빈 값은 빈 따옴표로 반환된다 (기존 동작 보존).
*/
#[Test]
public function it_returns_empty_quotes_for_an_empty_value(): void
{
$this->assertSame('""', serializeEnvValue(''));
}
/**
* 큰따옴표와 백슬래시는 이스케이프된다 (기존 동작 보존).
*/
#[Test]
public function it_escapes_quotes_and_backslashes(): void
{
$this->assertSame('"a\\\\b"', serializeEnvValue('a\\b'));
$this->assertSame('"say \\"hi\\""', serializeEnvValue('say "hi"'));
}
/**
* 줄바꿈·NUL 이 섞인 값은 예외로 거부된다 (조용한 삭제 금지).
*
* @param string $value 주입 시도 값
* @param string $reason 거부 사유 (실패 메시지용)
*/
#[Test]
#[DataProvider('lineBreakInjectionProvider')]
public function it_rejects_values_containing_line_breaks_or_nul(string $value, string $reason): void
{
$this->expectException(InvalidArgumentException::class);
serializeEnvValue($value);
}
/**
* 개행 주입 벡터.
*
* @return array<string, array{0: string, 1: string}>
*/
public static function lineBreakInjectionProvider(): array
{
return [
'LF 로 새 변수 줄 주입' => ["g7\nAPP_DEBUG=true", 'LF'],
'CR 로 새 변수 줄 주입' => ["g7\rAPP_DEBUG=true", 'CR'],
'CRLF 로 새 변수 줄 주입' => ["g7\r\nAPP_DEBUG=true", 'CRLF'],
'NUL 바이트' => ["g7\0APP_DEBUG=true", 'NUL'],
'선두 개행' => ["\nAPP_DEBUG=true", 'leading LF'],
];
}
/**
* 개행 검사는 값 전체를 본다 — 따옴표 안에 숨겨도 거부된다.
*/
#[Test]
public function it_rejects_line_breaks_even_inside_quotes(): void
{
$this->expectException(InvalidArgumentException::class);
serializeEnvValue("\"g7\"\nAPP_DEBUG=true");
}
/**
* 하위호환 별칭 `escapeEnvValue` 의 두 정의가 모두 단일 관문에 위임한다.
*
* 종전 구현은 개행을 **조용히 지웠다**. 그 동작이 어느 한쪽에라도 남아 있으면 다음
* 호출자가 그 이름을 불러 같은 결함을 다시 들여온다 — 실제로 과거 KVE 수정들이
* "한쪽만 고쳐서" 재수정된 이력이 있다.
*
* 함수를 실행해 대조하지 않고 소스를 검사하는 이유: 두 정의는 BASE_PATH 상수를
* 요구하는 파일에 있고, 그 상수를 여기서 박으면 인접 Installer 테스트의 안전 가드가
* 걸려 그 테스트들이 조용히 skip 된다(초록인데 검사는 멈춘 상태가 된다).
*/
#[Test]
public function both_legacy_alias_definitions_delegate_to_the_single_gate(): void
{
$projectRoot = dirname(__DIR__, 3);
$definitions = [
'includes/functions.php' => $projectRoot.'/public/install/includes/functions.php',
'includes/installer-runtime.php' => $projectRoot.'/public/install/includes/installer-runtime.php',
];
foreach ($definitions as $label => $path) {
$source = (string) file_get_contents($path);
$this->assertMatchesRegularExpression(
'/function escapeEnvValue\(string \$value\): string\s*\{[^}]*return serializeEnvValue\(\$value\);/s',
$source,
"{$label} 의 escapeEnvValue 가 단일 관문에 위임하지 않는다"
);
$this->assertStringNotContainsString(
'str_replace(["\r", "\n"], \'\', $value)',
$source,
"{$label} 에 개행을 조용히 지우는 경로가 남아 있다 — 그 이름을 부르면 결함이 재유입된다"
);
}
}
/**
* 개행이 없는 값은 검사기가 통과시킨다.
*/
#[Test]
public function the_line_break_checker_accepts_clean_values(): void
{
$this->assertTrue(installer_env_value_is_single_line('https://github.com/gnuboard/g7'));
$this->assertTrue(installer_env_value_is_single_line(''));
$this->assertFalse(installer_env_value_is_single_line("a\nb"));
$this->assertFalse(installer_env_value_is_single_line("a\rb"));
$this->assertFalse(installer_env_value_is_single_line("a\0b"));
}
}
@@ -312,12 +312,13 @@ ENV;
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
$this->assertStringContainsString('DB_WRITE_HOST=real-host.example.com', $merged);
$this->assertStringContainsString('DB_WRITE_PORT=3307', $merged);
$this->assertStringContainsString('DB_WRITE_DATABASE=real_db', $merged);
$this->assertStringContainsString('DB_WRITE_USERNAME=real_user', $merged);
// 값은 전부 serializeEnvValue 를 지나 따옴표로 감싸진다 (개행 주입 차단 관문 일원화).
$this->assertStringContainsString('DB_WRITE_HOST="real-host.example.com"', $merged);
$this->assertStringContainsString('DB_WRITE_PORT="3307"', $merged);
$this->assertStringContainsString('DB_WRITE_DATABASE="real_db"', $merged);
$this->assertStringContainsString('DB_WRITE_USERNAME="real_user"', $merged);
$this->assertStringContainsString('DB_WRITE_PASSWORD="real_pass"', $merged);
$this->assertStringContainsString('DB_PREFIX=real_', $merged);
$this->assertStringContainsString('DB_PREFIX="real_"', $merged);
// Read 라인도 write 와 동기화되어 치환되어야 함
$this->assertStringNotContainsString('DB_WRITE_HOST=127.0.0.1', $merged);
@@ -350,10 +351,10 @@ ENV;
$this->assertStringContainsString("DB_READ_HOST=\n", $merged);
$this->assertStringContainsString("DB_READ_DATABASE=\n", $merged);
$this->assertStringContainsString("DB_READ_USERNAME=\n", $merged);
$this->assertStringNotContainsString('DB_READ_HOST=w-host', $merged);
$this->assertStringNotContainsString('DB_READ_DATABASE=wdb', $merged);
$this->assertStringNotContainsString('DB_READ_HOST="w-host"', $merged);
$this->assertStringNotContainsString('DB_READ_DATABASE="wdb"', $merged);
// write 라인은 정상 기록
$this->assertStringContainsString('DB_WRITE_HOST=w-host', $merged);
$this->assertStringContainsString('DB_WRITE_HOST="w-host"', $merged);
}
public function test_merge_uses_separate_read_when_specified(): void
@@ -369,8 +370,8 @@ ENV;
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
$this->assertStringContainsString('DB_READ_HOST=r-host', $merged);
$this->assertStringContainsString('DB_READ_DATABASE=rdb', $merged);
$this->assertStringContainsString('DB_READ_HOST="r-host"', $merged);
$this->assertStringContainsString('DB_READ_DATABASE="rdb"', $merged);
}
public function test_merge_appends_db_lines_when_absent_from_template(): void
@@ -392,8 +393,8 @@ ENV;
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
// .env.example 에 라인이 없는 비표준 케이스 — 끝에 추가
$this->assertStringContainsString('DB_WRITE_HOST=h', $merged);
$this->assertStringContainsString('DB_WRITE_DATABASE=d', $merged);
$this->assertStringContainsString('DB_WRITE_HOST="h"', $merged);
$this->assertStringContainsString('DB_WRITE_DATABASE="d"', $merged);
}
public function test_merge_preserves_db_lines_when_runtime_has_no_db(): void
@@ -2,6 +2,7 @@
namespace Tests\Unit\Installer;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ValidationApi;
@@ -484,29 +485,25 @@ class InstallerSecurityHardeningTest extends TestCase
}
// ========================================================================
// Medium-1 — escapeEnvValue 개행 제거
// Medium-1 — escapeEnvValue 개행 거부
// ========================================================================
//
// 종전에는 개행을 조용히 지웠다(Medium-1). KVE-2026-2042 정정으로 직렬화기는 개행을
// 만나면 예외를 던진다 — 지우면 운영자가 입력한 값과 저장된 값이 달라지는데 그 사실이
// 화면에 나타나지 않기 때문이다. 하위호환 별칭 escapeEnvValue 도 같은 관문에 위임한다.
public function test_escape_env_value_strips_newlines(): void
public function test_escape_env_value_rejects_newlines(): void
{
$payload = "secret\nINJECTED=true";
$result = escapeEnvValue($payload);
$this->expectException(InvalidArgumentException::class);
// 핵심 보안 속성: 결과에 개행 문자가 없어야 한다 (라인 주입 차단).
// INJECTED=true 가 따옴표 내부 일부로 포함되는 것은 문제 아님 — .env 파서는
// 따옴표 닫힘 전까지 단일 값으로만 해석.
$this->assertStringNotContainsString("\n", $result, 'LF 가 결과에 포함되면 안 됨');
$this->assertStringNotContainsString("\r", $result);
// 결과는 따옴표로 시작/종료 (라인 주입이 성립하려면 따옴표가 닫힌 뒤 개행이 와야 하나 개행이 제거됨)
$this->assertStringStartsWith('"', $result);
$this->assertStringEndsWith('"', $result);
escapeEnvValue("secret\nINJECTED=true");
}
public function test_escape_env_value_strips_crlf(): void
public function test_escape_env_value_rejects_crlf(): void
{
$result = escapeEnvValue("a\r\nb");
$this->expectException(InvalidArgumentException::class);
$this->assertSame('"ab"', $result);
escapeEnvValue("a\r\nb");
}
public function test_escape_env_value_preserves_normal_password(): void
@@ -0,0 +1,154 @@
<?php
namespace Tests\Unit\Installer;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Step 3 저장 시점의 .env 값 사전 거부 회귀 테스트 (KVE-2026-2042).
*
* `app_name` / `app_url` / `core_update_github_url` 은 서버측 검증 없이 `$_POST` 에서
* 그대로 `.env` 로 흘렀다. `.env` 는 줄 단위 형식이라 값에 개행을 섞으면 그 뒤가 새로운
* 환경변수 줄로 해석되어, 설치 창이 열려 있는 동안 임의 환경변수를 주입할 수 있었다.
*
* 직렬화기(serializeEnvValue)가 최종 관문이지만 그 단계의 실패는 설치 진행 중 예외로
* 드러나 어느 입력이 문제인지 알기 어렵다. 그래서 저장 시점에도 필드별로 거부한다.
*
* 저장/리다이렉트 분기에 도달하지 않도록 DB 검증을 일부러 실패시켜 상태 파일·세션을
* 건드리지 않는다.
*
* @scenario vector=handleStep3Post, payload_class=env_line_injection, expected_outcome=return_invalid_message
*
* @effects step3_post_rejects_env_line_breaks_before_save
*/
class InstallerStep3EnvValueGuardTest extends TestCase
{
public static function setUpBeforeClass(): void
{
require_once __DIR__.'/stubs/lang_stub.php';
$projectRoot = dirname(__DIR__, 3);
if (! defined('BASE_PATH')) {
define('BASE_PATH', $projectRoot);
}
if (! defined('SUPPORTED_LANGUAGES')) {
define('SUPPORTED_LANGUAGES', ['ko' => '한국어', 'en' => 'English']);
}
require_once $projectRoot.'/public/install/includes/functions.php';
require_once $projectRoot.'/public/install/includes/binary-path-policy.php';
require_once $projectRoot.'/public/install/includes/request-handler.php';
}
protected function tearDown(): void
{
$_POST = [];
parent::tearDown();
}
/**
* Step 3 POST 를 실행하고 에러 배열을 돌려줍니다.
*
* @param array<string, string> $post POST 본문 (DB 검증은 일부러 실패시킨다)
* @return array<string, string> 에러 배열
*/
private function runStep3(array $post): array
{
$_POST = $post + [
'db_write_host' => '',
'db_write_database' => '',
'db_write_username' => '',
'admin_email' => 'not-an-email',
'admin_language' => 'ko',
'admin_password' => '',
'admin_password_confirm' => '',
];
$formData = [];
$errors = [];
handleStep3Post('ko', $formData, $errors);
return $errors;
}
/**
* 개행이 섞인 .env 대상 필드는 저장 시점에 거부된다.
*
* @param string $field 필드명
* @param string $payload 주입 시도 값
*/
#[Test]
#[DataProvider('lineBreakFieldProvider')]
public function it_rejects_line_breaks_in_env_bound_fields(string $field, string $payload): void
{
$errors = $this->runStep3([$field => $payload]);
$this->assertArrayHasKey(
$field,
$errors,
"{$field} 에 개행이 섞였는데 저장 시점에 통과했다 — .env 에 임의 변수 줄이 주입된다"
);
}
/**
* 개행 주입 벡터 (필드 × CR/LF).
*
* @return array<string, array{0: string, 1: string}>
*/
public static function lineBreakFieldProvider(): array
{
return [
'app_name + LF' => ['app_name', "그누보드7\nAPP_DEBUG=true"],
'app_name + CR' => ['app_name', "그누보드7\rAPP_DEBUG=true"],
'app_url + LF' => ['app_url', "https://example.com\nAPP_DEBUG=true"],
'app_url + CRLF' => ['app_url', "https://example.com\r\nAPP_DEBUG=true"],
'github_url + LF' => ['core_update_github_url', "https://github.com/gnuboard/g7\nAPP_DEBUG=true"],
'app_name + NUL' => ['app_name', "그누보드7\0APP_DEBUG=true"],
];
}
/**
* 스킴 없는 URL 은 거부된다.
*/
#[Test]
public function it_rejects_url_fields_without_an_http_scheme(): void
{
$errors = $this->runStep3(['app_url' => 'javascript:alert(1)']);
$this->assertArrayHasKey('app_url', $errors);
}
/**
* 정상 값은 이 게이트에 걸리지 않는다 (회귀 방지).
*/
#[Test]
public function it_accepts_normal_values(): void
{
$errors = $this->runStep3([
'app_name' => '그누보드7',
'app_url' => 'https://example.com',
'core_update_github_url' => 'https://github.com/gnuboard/g7',
]);
$this->assertArrayNotHasKey('app_name', $errors);
$this->assertArrayNotHasKey('app_url', $errors);
$this->assertArrayNotHasKey('core_update_github_url', $errors);
}
/**
* 값이 비어 있으면 URL 형태 검사는 건너뛴다 (선택 입력 보존).
*/
#[Test]
public function it_skips_url_shape_check_for_empty_values(): void
{
$errors = $this->runStep3([
'app_url' => '',
'core_update_github_url' => '',
]);
$this->assertArrayNotHasKey('app_url', $errors);
$this->assertArrayNotHasKey('core_update_github_url', $errors);
}
}
@@ -0,0 +1,163 @@
<?php
namespace Tests\Unit\Repositories;
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationLog;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Tests\TestCase;
/**
* IdentityVerificationLogRepository 의 검증 토큰 조회 만료 검증.
*
* 회귀 배경(KVE-2026-2029): findVerifiedForToken() 이 status/consumed_at 만 보고
* expires_at 술어를 갖지 않아, 만료된 본인인증 토큰이 무기한 유효하게 취급됐다.
* 이 저장소를 공유하는 소비자(정책 미들웨어·정책 서비스·회원가입/비밀번호재설정
* 리스너·IdvTokenRule)가 전부 같은 결함을 물려받았으므로 저장소 단일 지점에서 닫는다.
*
* @group identity
* @group unit
*/
class IdentityVerificationLogRepositoryTest extends TestCase
{
use DatabaseTransactions;
private IdentityVerificationLogRepositoryInterface $repository;
protected function setUp(): void
{
parent::setUp();
$this->repository = app(IdentityVerificationLogRepositoryInterface::class);
}
/**
* 검증 완료 로그를 생성합니다.
*
* @param array<string, mixed> $overrides 덮어쓸 속성
*/
private function makeVerifiedLog(array $overrides = []): IdentityVerificationLog
{
return IdentityVerificationLog::create(array_merge([
'provider_id' => 'test-provider',
'purpose' => 'sensitive_action',
'channel' => 'email',
'user_id' => null,
'target_hash' => hash('sha256', 'kve2029-'.Str::random(8)),
'status' => IdentityVerificationStatus::Verified->value,
'attempts' => 1,
'max_attempts' => 5,
'verification_token' => 'kve2029-'.Str::random(32),
'expires_at' => Carbon::now()->addMinutes(10),
'verified_at' => Carbon::now()->subMinute(),
'consumed_at' => null,
], $overrides));
}
/**
* 미만료 검증 토큰은 조회된다 (정상 흐름 불변).
*/
public function test_unexpired_verified_token_is_returned(): void
{
$log = $this->makeVerifiedLog();
$found = $this->repository->findVerifiedForToken(
$log->verification_token,
'sensitive_action'
);
$this->assertNotNull($found);
$this->assertSame($log->id, $found->id);
}
/**
* 만료된 검증 토큰은 조회되지 않는다 (KVE-2026-2029).
*/
public function test_expired_verified_token_is_not_returned(): void
{
$log = $this->makeVerifiedLog([
'expires_at' => Carbon::now()->subMinute(),
]);
$found = $this->repository->findVerifiedForToken(
$log->verification_token,
'sensitive_action'
);
$this->assertNull($found);
}
/**
* 만료 경계: 1초 전은 통과, 1초 후는 차단.
*/
public function test_expiry_boundary_is_closed_strictly(): void
{
$valid = $this->makeVerifiedLog([
'expires_at' => Carbon::now()->addSecond(),
]);
$expired = $this->makeVerifiedLog([
'expires_at' => Carbon::now()->subSecond(),
]);
$this->assertNotNull(
$this->repository->findVerifiedForToken($valid->verification_token, 'sensitive_action')
);
$this->assertNull(
$this->repository->findVerifiedForToken($expired->verification_token, 'sensitive_action')
);
}
/**
* expires_at 이 비어 있는 토큰은 조회되지 않는다 (엄격 차단).
*
* 보안 토큰은 모든 provider 가 challenge 생성 시 expires_at 을 세팅한다.
* NULL 은 정상 발급 산물이 아니므로 허용 분기를 두지 않는다.
*/
public function test_token_without_expiry_is_not_returned(): void
{
$log = $this->makeVerifiedLog([
'expires_at' => null,
]);
$found = $this->repository->findVerifiedForToken(
$log->verification_token,
'sensitive_action'
);
$this->assertNull($found);
}
/**
* 이미 소비된 토큰은 조회되지 않는다 (기존 동작 보존).
*/
public function test_consumed_token_is_not_returned(): void
{
$log = $this->makeVerifiedLog([
'consumed_at' => Carbon::now(),
]);
$found = $this->repository->findVerifiedForToken(
$log->verification_token,
'sensitive_action'
);
$this->assertNull($found);
}
/**
* 목적(purpose)이 다르면 조회되지 않는다 (기존 동작 보존).
*/
public function test_token_with_other_purpose_is_not_returned(): void
{
$log = $this->makeVerifiedLog();
$found = $this->repository->findVerifiedForToken(
$log->verification_token,
'signup'
);
$this->assertNull($found);
}
}
+116 -13
View File
@@ -69,11 +69,12 @@ class OutboundProxyTest extends TestCase
'outbound_proxy' => 'socks5h://127.0.0.1:1080',
]);
$this->assertSame([
'http' => 'socks5h://127.0.0.1:1080',
'https' => 'socks5h://127.0.0.1:1080',
'no' => [],
], $resolved);
$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']);
}
/**
@@ -156,9 +157,11 @@ class OutboundProxyTest extends TestCase
'outbound_proxy_bypass' => [' g7.dev ', '', 'g7.dev', 'localhost', 42, null],
]);
$this->assertSame(['g7.dev', 'localhost'], $resolved['no']);
// 운영자 입력분: 공백 제거·빈 항목 제거·중복 제거가 그대로 적용된다.
$this->assertSame(1, count(array_keys($resolved['no'], 'g7.dev', true)), '중복이 남았습니다.');
$this->assertSame(1, count(array_keys($resolved['no'], 'localhost', true)), '자동 항목과 운영자 항목이 겹쳐 중복이 남았습니다.');
$this->assertSame(
[0, 1],
range(0, count($resolved['no']) - 1),
array_keys($resolved['no']),
'예외 목록 키가 비연속입니다 — JSON 직렬화 시 객체가 되어 목록으로 읽히지 않습니다.'
);
@@ -179,7 +182,8 @@ class OutboundProxyTest extends TestCase
'outbound_proxy_bypass' => 'g7.dev',
]);
$this->assertSame([], $resolved['no']);
// 배열이 아닌 값은 통째로 버린다 — 문자열을 한 항목으로 해석하지 않는다.
$this->assertNotContains('g7.dev', $resolved['no']);
}
/**
@@ -201,10 +205,12 @@ class OutboundProxyTest extends TestCase
'outbound_proxy_bypass' => ['g7.dev', 'localhost'],
])]);
$this->assertSame([
CURLOPT_PROXY => 'socks5h://127.0.0.1:1080',
CURLOPT_NOPROXY => 'g7.dev,localhost',
], OutboundProxy::curlOptions());
$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]);
}
/**
@@ -252,7 +258,104 @@ class OutboundProxyTest extends TestCase
$tested = OutboundProxy::options($url, $bypass);
$this->assertSame($applied, $tested);
$this->assertSame(['g7.dev', 'localhost'], $tested['no']);
$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']);
}
}
+5
View File
@@ -13,6 +13,10 @@ description: |
- 개별 요청의 withOptions(['proxy' => ...]) 가 전역 옵션보다 우선
- Http:: 파사드를 쓰지 못하는 curl 직접 호출은 OutboundProxy::curlOptions() 로 편입
- 저장 전 연결 테스트는 저장값이 아니라 제출값을 검사하고 출발지 IP 를 보고
- 사이트 자기 호스트(config app.url)와 루프백은 운영자 입력과 무관하게 항상 우회 —
자기 자신에게 되돌아오는 요청(SEO 데이터소스 조회, API 문서 엔드포인트 탐침)까지
프록시로 내보내면 프록시 무응답 시 그 호출이 연결 실패 시각까지 매달리고, 실패가
폴백으로 삼켜져 예외 없이 저장 요청만 느려진다
축 설계: 판정에 실제로 영향을 주는 입력만 축으로 둔다. 검증 지점(단위/저장/적용/화면)은
같은 조합을 여러 계층에서 확인하는 것이므로 축이 아니라 테스트 파일의 분담이다.
@@ -34,6 +38,7 @@ effects:
- proxy_applied_when_debug_mode_on
- proxy_rejects_disallowed_scheme
- proxy_bypass_list_normalized
- self_host_always_bypasses_proxy
- proxy_setting_persists_to_debug_category
- proxy_setting_rejects_invalid_url
- proxy_applied_to_global_http_options