KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과 그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다. globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를 직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다. 서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이 그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고, 같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다. 아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩 걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
111 lines
4.3 KiB
PHP
111 lines
4.3 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Identity;
|
|
|
|
use App\Enums\IdentityVerificationStatus;
|
|
use App\Models\IdentityVerificationLog;
|
|
use Database\Seeders\RolePermissionSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* GET /api/identity/challenges/{id} 폴링 엔드포인트 테스트.
|
|
*
|
|
* 비동기 검증 흐름(Stripe Identity / 토스인증 push / 외부 redirect 콜백) 에서 클라이언트가
|
|
* verify 즉시 응답을 받지 못할 때 상태를 추적하는 폴링 경로 검증.
|
|
*
|
|
* @since engine-v1.46.0
|
|
*/
|
|
class IdentityChallengeShowTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Notification::fake();
|
|
// PermissionMiddleware 가 IDV request 라우트에 권한 가드 적용 — guest 역할 권한 sync 필수
|
|
$this->seed(RolePermissionSeeder::class);
|
|
}
|
|
|
|
public function test_show_returns_404_when_challenge_not_found(): void
|
|
{
|
|
$response = $this->getJson('/api/identity/challenges/00000000-0000-0000-0000-000000000000');
|
|
|
|
$response->assertStatus(404);
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
{
|
|
// start a challenge
|
|
$request = $this->postJson('/api/identity/challenges', [
|
|
'purpose' => 'sensitive_action',
|
|
'target' => ['email' => 'user@example.com'],
|
|
]);
|
|
$id = $request->json('data.id');
|
|
|
|
$response = $this->getJson("/api/identity/challenges/{$id}");
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonStructure([
|
|
'success',
|
|
'data' => ['id', 'status', 'render_hint', 'expires_at', 'public_payload'],
|
|
])
|
|
->assertJsonPath('data.id', $id);
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
{
|
|
$request = $this->postJson('/api/identity/challenges', [
|
|
'purpose' => 'sensitive_action',
|
|
'target' => ['email' => 'user@example.com'],
|
|
]);
|
|
$id = $request->json('data.id');
|
|
|
|
$response = $this->getJson("/api/identity/challenges/{$id}");
|
|
|
|
// 누적 시도 횟수(attempts)·코드 본체·target_hash·verification_token 등 노출 금지.
|
|
// 이 엔드포인트는 권한 가드 없는 공개 폴링용이므로, 남의 인증이 몇 번 실패했는지를
|
|
// 추론할 단서를 응답에 담지 않는다.
|
|
$response->assertJsonMissingPath('data.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
|
|
{
|
|
$request = $this->postJson('/api/identity/challenges', [
|
|
'purpose' => 'sensitive_action',
|
|
'target' => ['email' => 'user@example.com'],
|
|
]);
|
|
$id = $request->json('data.id');
|
|
|
|
// processing 상태로 강제 변경 (provider 가 비동기 검증 중인 흐름)
|
|
IdentityVerificationLog::whereKey($id)->update([
|
|
'status' => IdentityVerificationStatus::Processing->value,
|
|
]);
|
|
|
|
$response = $this->getJson("/api/identity/challenges/{$id}");
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.status', IdentityVerificationStatus::Processing->value);
|
|
}
|
|
}
|