Files
HeuJung 71abdeaf60 fix(security): KVE-2026-1914/1915/1919 remediation 전건
위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던
결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을
내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고
같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다.

- 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거
 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는
 DB 쓰기에 선행하여 거부 시 상태 불변.
- 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체.
 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static
 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형.
- secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙
 소유권·비밀·발행 상태 검사 통일.
- 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를
 런타임·저장측·정적검사 3층 동형화.
- 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
2026-08-17 01:45:09 +09:00

508 lines
18 KiB
PHP

<?php
namespace Tests\Feature\Api\Identity;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationLog;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
/**
* IDV 라우트 권한 가드 테스트.
*
* permission:user,core.identity.* 미들웨어 + scope=self (verify/cancel) 가드 동작 검증.
* - 게스트: guest 역할에 IDV 권한 부여되어 request/verify/cancel 모두 통과 (Mode B 가입 흐름)
* - 일반 user: 본인 challenge 만 verify/cancel 가능 (scope=self)
* - admin: 다른 사용자 challenge 도 다룰 수 있음 (scope=null)
* - 관리자 전용 엔드포인트(/api/admin/identity/*): 멤버 토큰은 403, 관리자 토큰은 200
*/
class PermissionGuardTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Notification::fake();
$this->seed(RolePermissionSeeder::class);
}
public function test_public_purposes_endpoint_is_accessible_without_auth(): void
{
$response = $this->getJson('/api/identity/purposes');
$response->assertStatus(200);
}
public function test_public_providers_endpoint_is_accessible_without_auth(): void
{
$response = $this->getJson('/api/identity/providers');
$response->assertStatus(200);
}
/**
* @scenario actor_role=guest, challenge_owner=none, endpoint=request
*
* @effects guest_can_request_challenge
*/
public function test_challenge_request_works_for_unauthenticated_guests(): void
{
// IDV 의 핵심 설계: 가입 전 Mode B 에서도 challenge 발급 가능해야 하므로 optional.sanctum
$response = $this->postJson('/api/identity/challenges', [
'purpose' => 'signup',
'target' => ['email' => 'guest@example.com'],
]);
$response->assertStatus(201);
}
public function test_member_cannot_access_admin_policies_index(): void
{
$member = User::factory()->create();
$token = $member->createToken('test')->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/admin/identity/policies');
// 일반 사용자는 admin 미들웨어 통과 불가 → 403
$response->assertStatus(403);
}
public function test_member_cannot_access_admin_logs_index(): void
{
$member = User::factory()->create();
$token = $member->createToken('test')->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/admin/identity/logs');
$response->assertStatus(403);
}
public function test_admin_with_policies_permission_can_access_policies_index(): void
{
$admin = $this->createAdmin();
$token = $admin->createToken('test')->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/admin/identity/policies');
$response->assertStatus(200);
}
public function test_admin_with_logs_permission_can_access_logs_index(): void
{
$admin = $this->createAdmin();
$token = $admin->createToken('test')->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/admin/identity/logs');
$response->assertStatus(200);
}
/**
* Admin 역할을 부여한 User 를 생성합니다.
*/
private function createAdmin(): User
{
$admin = User::factory()->create(['is_super' => true]);
$adminRole = Role::where('identifier', 'admin')->first();
if ($adminRole) {
$admin->roles()->attach($adminRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
}
return $admin->fresh();
}
public function test_unauthenticated_admin_endpoints_return_401(): void
{
$this->getJson('/api/admin/identity/policies')->assertStatus(401);
$this->getJson('/api/admin/identity/logs')->assertStatus(401);
$this->getJson('/api/admin/identity/providers')->assertStatus(401);
}
/**
* @scenario actor_role=guest, challenge_owner=other, endpoint=verify
*
* @effects guest_can_verify_any_challenge, missing_challenge_returns_404_after_guard_pass
*/
public function test_guest_can_verify_challenge(): void
{
// guest 역할에 core.identity.verify 권한 부여됨 — Mode B 가입 흐름
// challenge 자체는 미생성이라 ModelNotFoundException → 404. 권한 가드 통과 후 라우트 모델 바인딩이 처리.
$response = $this->postJson('/api/identity/challenges/00000000-0000-0000-0000-000000000000/verify', [
'code' => '123456',
]);
$response->assertStatus(404); // 401(권한 거부) 아닌 404(모델 미존재) — 가드는 통과
}
/**
* @scenario actor_role=guest, challenge_owner=other, endpoint=cancel
*
* @effects guest_can_cancel_any_challenge, permission_middleware_resolves_challenge_via_route_model_binding
*/
public function test_guest_can_cancel_challenge(): void
{
// guest 의 cancel 도 동일 — 권한 가드 통과 + 모델 미존재로 404
$response = $this->postJson('/api/identity/challenges/00000000-0000-0000-0000-000000000000/cancel');
$response->assertStatus(404);
}
/**
* @scenario actor_role=user_with_perm, challenge_owner=self, endpoint=cancel
*
* @effects user_with_perm_can_cancel_own_challenge
*/
public function test_user_can_cancel_own_challenge(): void
{
$user = $this->createUserWithUserRole();
$token = $user->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($user);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/cancel");
$response->assertStatus(200);
}
/**
* @scenario actor_role=user_with_perm, challenge_owner=other, endpoint=cancel
*
* @effects user_with_perm_cannot_cancel_others_challenge_403
*/
public function test_user_cannot_cancel_others_challenge(): void
{
$owner = $this->createUserWithUserRole();
$intruder = $this->createUserWithUserRole();
$token = $intruder->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($owner);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/cancel");
// PermissionMiddleware 의 scope=self 가드 — 본인 user_id 불일치 → 403
$response->assertStatus(403);
}
/**
* @scenario actor_role=admin, challenge_owner=other, endpoint=cancel
*
* @effects admin_can_cancel_any_challenge_via_scope_null
*/
public function test_admin_can_cancel_any_challenge(): void
{
$owner = $this->createUserWithUserRole();
$admin = $this->createAdmin();
$token = $admin->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($owner);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/cancel");
// admin 역할은 all_leaf + scope=null → 다른 사용자 challenge 도 cancel 가능
$response->assertStatus(200);
}
/**
* @scenario actor_role=user_without_perm, challenge_owner=self, endpoint=cancel
*
* @effects user_without_perm_gets_403_on_cancel
*/
public function test_user_without_identity_permission_gets_403_on_cancel(): void
{
$user = $this->createUserWithUserRole();
$token = $user->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($user);
// user 역할에서 IDV cancel 권한만 제거 → 가드 차단
$userRole = Role::where('identifier', 'user')->first();
$cancelPerm = Permission::where('identifier', 'core.identity.cancel')->first();
$userRole->permissions()->detach($cancelPerm->id);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/cancel");
$response->assertStatus(403);
}
/**
* @scenario actor_role=user_with_perm, challenge_owner=none, endpoint=request
*
* @effects user_with_perm_can_request_challenge
*/
public function test_user_with_permission_can_request_challenge(): void
{
$user = $this->createUserWithUserRole();
$token = $user->createToken('test')->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/identity/challenges', [
'purpose' => 'sensitive_action',
'target' => ['email' => $user->email],
]);
$response->assertStatus(201);
}
/**
* @scenario actor_role=user_with_perm, challenge_owner=self, endpoint=verify
*
* @effects user_with_perm_can_verify_own_challenge
*/
public function test_user_can_verify_own_challenge(): void
{
$user = $this->createUserWithUserRole();
$token = $user->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($user);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/verify", [
'code' => '123456',
]);
// scope=self 가드 통과 — 권한 거부(403)가 아니어야 한다
$this->assertNotSame(403, $response->getStatusCode());
}
/**
* @scenario actor_role=user_with_perm, challenge_owner=other, endpoint=verify
*
* @effects user_with_perm_cannot_verify_others_challenge_403
*/
public function test_user_cannot_verify_others_challenge(): void
{
$owner = $this->createUserWithUserRole();
$intruder = $this->createUserWithUserRole();
$token = $intruder->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($owner);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/verify", [
'code' => '123456',
]);
// 남의 challenge 는 코드가 맞아도 scope=self 가드에서 먼저 차단된다
$response->assertStatus(403);
}
/**
* @scenario actor_role=user_without_perm, challenge_owner=none, endpoint=request
*
* @effects user_without_perm_gets_403_on_request
*/
public function test_user_without_permission_gets_403_on_request(): void
{
$user = $this->createUserWithUserRole();
$token = $user->createToken('test')->plainTextToken;
$this->detachIdentityPermission('core.identity.request');
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/identity/challenges', [
'purpose' => 'sensitive_action',
'target' => ['email' => $user->email],
]);
$response->assertStatus(403);
}
/**
* @scenario actor_role=user_without_perm, challenge_owner=self, endpoint=verify
*
* @effects user_without_perm_gets_403_on_verify
*/
public function test_user_without_permission_gets_403_on_verify_own(): void
{
$user = $this->createUserWithUserRole();
$token = $user->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($user);
$this->detachIdentityPermission('core.identity.verify');
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/verify", [
'code' => '123456',
]);
// 본인 challenge 여도 권한 자체가 없으면 403
$response->assertStatus(403);
}
/**
* @scenario actor_role=user_without_perm, challenge_owner=other, endpoint=verify
*
* @effects user_without_perm_gets_403_on_verify
*/
public function test_user_without_permission_gets_403_on_verify_others(): void
{
$owner = $this->createUserWithUserRole();
$intruder = $this->createUserWithUserRole();
$token = $intruder->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($owner);
$this->detachIdentityPermission('core.identity.verify');
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/verify", [
'code' => '123456',
]);
$response->assertStatus(403);
}
/**
* @scenario actor_role=user_without_perm, challenge_owner=other, endpoint=cancel
*
* @effects user_without_perm_gets_403_on_cancel
*/
public function test_user_without_permission_gets_403_on_cancel_others(): void
{
$owner = $this->createUserWithUserRole();
$intruder = $this->createUserWithUserRole();
$token = $intruder->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($owner);
$this->detachIdentityPermission('core.identity.cancel');
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/cancel");
$response->assertStatus(403);
}
/**
* @scenario actor_role=admin, challenge_owner=none, endpoint=request
*
* @effects user_with_perm_can_request_challenge
*/
public function test_admin_can_request_challenge(): void
{
$admin = $this->createAdmin();
$token = $admin->createToken('test')->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/identity/challenges', [
'purpose' => 'sensitive_action',
'target' => ['email' => $admin->email],
]);
$response->assertStatus(201);
}
/**
* @scenario actor_role=admin, challenge_owner=self, endpoint=verify
*
* @effects admin_can_verify_any_challenge_via_scope_null
*/
public function test_admin_can_verify_own_challenge(): void
{
$admin = $this->createAdmin();
$token = $admin->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($admin);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/verify", [
'code' => '123456',
]);
$this->assertNotSame(403, $response->getStatusCode());
}
/**
* @scenario actor_role=admin, challenge_owner=other, endpoint=verify
*
* @effects admin_can_verify_any_challenge_via_scope_null
*/
public function test_admin_can_verify_any_challenge(): void
{
$owner = $this->createUserWithUserRole();
$admin = $this->createAdmin();
$token = $admin->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($owner);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/verify", [
'code' => '123456',
]);
// admin 은 scope=null 이라 남의 challenge 도 가드를 통과한다
$this->assertNotSame(403, $response->getStatusCode());
}
/**
* @scenario actor_role=admin, challenge_owner=self, endpoint=cancel
*
* @effects admin_can_cancel_any_challenge_via_scope_null
*/
public function test_admin_can_cancel_own_challenge(): void
{
$admin = $this->createAdmin();
$token = $admin->createToken('test')->plainTextToken;
$challenge = $this->createChallengeFor($admin);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/identity/challenges/{$challenge->id}/cancel");
$response->assertStatus(200);
}
/**
* user 역할에서 지정 IDV 권한을 떼어냅니다 (권한 미보유 상태 재현).
*/
private function detachIdentityPermission(string $identifier): void
{
$userRole = Role::where('identifier', 'user')->first();
$permission = Permission::where('identifier', $identifier)->first();
$userRole->permissions()->detach($permission->id);
}
/**
* user 역할이 attach 된 사용자를 생성합니다.
*
* RolePermissionSeeder 가 시드한 user 역할 + IDV 권한(scope=self) 부여.
*/
private function createUserWithUserRole(): User
{
$user = User::factory()->create();
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$user->roles()->attach($userRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
}
return $user->fresh();
}
/**
* 지정된 사용자 소유의 검증 가능한 challenge 를 생성합니다.
*/
private function createChallengeFor(User $user): IdentityVerificationLog
{
return IdentityVerificationLog::create([
'provider_id' => 'g7:core.mail',
'purpose' => 'sensitive_action',
'channel' => 'email',
'user_id' => $user->id,
'target_hash' => hash('sha256', $user->email),
'status' => IdentityVerificationStatus::Sent->value,
'attempts' => 0,
'max_attempts' => 5,
'metadata' => ['code_hash' => Hash::make('123456')],
'expires_at' => now()->addMinutes(10),
]);
}
}