Files
Gnuboard7/modules/_bundled/sirsoft-board/tests/Feature/User/SecretPostAttachmentAccessTest.php
T
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

310 lines
12 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 비밀글 첨부파일 접근 차단 테스트 (KVE-2026-1914 A-3)
*
* 첨부는 본문 게이팅(getFilteredContent)과 별개 경로라, 본문이 비밀 마스킹되어도
* 첨부 다운로드/미리보기는 게시글 비밀 상태를 검사하지 않아 해시/ID 만으로 원문
* 파일이 노출되던 결함을 검증합니다.
*
* 정책: 비밀글 첨부는 작성자 본인 또는 게시판 manager/posts.read-secret 보유자만 접근.
*
* 실제 공격자 프로필은 대개 미인증(게스트)이다. preview 라우트는 permission
* 미들웨어 없이 optional.sanctum 만 걸려(공개 썸네일 정책) 컨트롤러/서비스의 비밀
* 게이트만이 게스트를 막는다 — 그래서 게스트 축을 명시 고정한다. download 라우트는
* permission 미들웨어가 게스트를 401 로 선차단한다.
*
* 시나리오 축(viewer)·효과는 매니페스트 tests/scenarios/board-secret-content-gate.yaml 참조.
* 각 test 메서드의 `@scenario viewer=…` 마커가 축 조합을 커버한다(메서드당 단일 값).
*
* 효과 목록을 클래스 레벨에 몰아 적지 않는다 — 커버리지 룰은 마커 레벨을 구분하지 않으므로,
* 클래스 레벨 목록이 있으면 그 메서드를 지워도 효과가 "언급됨" 으로 집계돼 삭제가 무증상
* green 이 된다. 마커는 메서드에만 둔다.
*/
class SecretPostAttachmentAccessTest extends BoardTestCase
{
private User $regularUser;
private User $ownerUser;
private User $managerUser;
protected function getTestBoardSlug(): string
{
return 'secret-attach';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '비밀 첨부 테스트 게시판', 'en' => 'Secret Attachment Test Board'],
'is_active' => true,
'use_comment' => true,
'secret_mode' => 'enabled',
'blocked_keywords' => [],
];
}
protected function setUp(): void
{
parent::setUp();
$slug = $this->board->slug;
// 일반 사용자 (posts.read + attachments.download, 비밀 열람 권한 없음)
$this->regularUser = User::factory()->create();
$this->ownerUser = User::factory()->create();
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
foreach (['posts.read', 'attachments.download'] as $key) {
$perm = Permission::firstOrCreate(
['identifier' => "sirsoft-board.{$slug}.{$key}"],
['name' => ['ko' => $key, 'en' => $key], 'type' => 'user']
);
$userRole->permissions()->syncWithoutDetaching([$perm->id]);
}
$this->regularUser->roles()->attach($userRole->id);
$this->ownerUser->roles()->attach($userRole->id);
}
// manager 권한 사용자
$this->managerUser = User::factory()->create();
$managerRole = Role::firstOrCreate(
['identifier' => "{$slug}-manager"],
['name' => ['ko' => '게시판 매니저', 'en' => 'Board Manager']]
);
foreach (['posts.read', 'attachments.download', 'manager'] as $key) {
$perm = Permission::firstOrCreate(
['identifier' => "sirsoft-board.{$slug}.{$key}"],
['name' => ['ko' => $key, 'en' => $key], 'type' => 'user']
);
$managerRole->permissions()->syncWithoutDetaching([$perm->id]);
}
$this->managerUser->roles()->attach($managerRole->id);
}
private function createAttachment(int $postId, string $hash, bool $image = false): int
{
$ext = $image ? 'jpg' : 'pdf';
$mime = $image ? 'image/jpeg' : 'application/pdf';
return DB::table('board_attachments')->insertGetId([
'board_id' => $this->board->id,
'post_id' => $postId,
'original_filename' => "doc.{$ext}",
'stored_filename' => "{$hash}.{$ext}",
'hash' => $hash,
'mime_type' => $mime,
'size' => 1024,
'path' => "attachments/doc.{$ext}",
'collection' => 'attachments',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function downloadUrl(string $hash): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/attachment/{$hash}";
}
private function previewUrl(string $hash): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/attachment/{$hash}/preview";
}
private function secretPost(): int
{
return $this->createTestPost([
'title' => '비밀글 첨부',
'status' => 'published',
'is_secret' => true,
'user_id' => $this->ownerUser->id,
'author_name' => 'owner',
]);
}
// ==========================================
// 비밀글 첨부 차단
// ==========================================
/**
* @scenario viewer=regular
*
* @effects regular_user_cannot_download_secret_post_attachment
*/
public function test_regular_user_cannot_download_secret_post_attachment(): void
{
$postId = $this->secretPost();
$this->createAttachment($postId, 'secdlregAAAA');
$response = $this->actingAs($this->regularUser, 'sanctum')
->get($this->downloadUrl('secdlregAAAA'));
// attachments.download 권한은 있으나 비밀 게이트로 차단(403)
$response->assertStatus(403);
}
/**
* @scenario viewer=regular
*
* @effects regular_user_cannot_preview_secret_post_attachment
*/
public function test_regular_user_cannot_preview_secret_post_attachment(): void
{
$postId = $this->secretPost();
$this->createAttachment($postId, 'secprevregAA', image: true);
$response = $this->actingAs($this->regularUser, 'sanctum')
->get($this->previewUrl('secprevregAA'));
$response->assertStatus(403);
}
// ==========================================
// 미인증(게스트) 차단 — 실제 공격자 프로필
// ==========================================
/**
* 미인증(게스트)은 비밀글 첨부 미리보기를 볼 수 없다 (403).
*
* preview 라우트는 permission 미들웨어가 없어(optional.sanctum 만) 컨트롤러/서비스의
* 비밀 게이트만이 게스트를 막는다 — 해시만 쥔 미인증 공격자가 실제로 차단되는지 고정한다.
*
* @scenario viewer=guest
*
* @effects guest_cannot_preview_secret_post_attachment
*/
public function test_guest_cannot_preview_secret_post_attachment(): void
{
$postId = $this->secretPost();
$this->createAttachment($postId, 'secprevgstAA', image: true);
// actingAs 없음 = 미인증 게스트
$response = $this->get($this->previewUrl('secprevgstAA'));
$response->assertStatus(403);
}
/**
* 미인증(게스트)은 비밀글 첨부 다운로드를 할 수 없다 (401).
*
* download 라우트는 attachments.download permission 미들웨어가 걸려 있어 게스트를
* 컨트롤러 도달 전에 401(guest_permission_denied)로 선차단한다.
*
* @scenario viewer=guest
*
* @effects guest_cannot_download_secret_post_attachment
*/
public function test_guest_cannot_download_secret_post_attachment(): void
{
$postId = $this->secretPost();
$this->createAttachment($postId, 'secdlgstAAAA');
$response = $this->get($this->downloadUrl('secdlgstAAAA'));
$response->assertStatus(401);
}
/**
* 미인증(게스트)도 정상글(비밀 아님) 첨부 미리보기는 차단되지 않는다 (게이트 과차단 회귀 방지).
*
* 공개 썸네일 정책상 preview 는 공개다 — 비밀 게이트가 정상글 게스트 미리보기까지
* 막으면 안 된다. 실제 파일이 없어 404 여도 403(비밀 차단)은 아니어야 한다.
*
* @scenario viewer=guest
*
* @effects guest_can_preview_normal_post_attachment
*/
public function test_guest_can_preview_normal_post_attachment(): void
{
$postId = $this->createTestPost([
'title' => '정상글 첨부(게스트 미리보기)',
'status' => 'published',
'is_secret' => false,
]);
$this->createAttachment($postId, 'normgstAAAAA', image: true);
$response = $this->get($this->previewUrl('normgstAAAAA'));
$this->assertNotSame(403, $response->getStatusCode(), '정상글 첨부 미리보기는 게스트에게 비밀 차단되면 안 됩니다');
$this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
}
// ==========================================
// 작성자/manager 는 접근 가능 (회귀 방지)
// ==========================================
/**
* @scenario viewer=owner
*
* @effects owner_can_access_secret_post_attachment
*/
public function test_owner_can_access_secret_post_attachment(): void
{
$postId = $this->secretPost();
$this->createAttachment($postId, 'secdlownerAA');
$response = $this->actingAs($this->ownerUser, 'sanctum')
->get($this->downloadUrl('secdlownerAA'));
// 작성자 본인은 비밀 게이트 통과 (실제 파일 없어 404 여도 403 은 아님)
$this->assertNotSame(403, $response->getStatusCode(), '작성자 본인은 비밀글 첨부에 접근 가능해야 합니다');
$this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
}
/**
* @scenario viewer=manager
*
* @effects manager_can_access_secret_post_attachment
*/
public function test_manager_can_access_secret_post_attachment(): void
{
$postId = $this->secretPost();
$this->createAttachment($postId, 'secdlmgrAAAA');
$response = $this->actingAs($this->managerUser, 'sanctum')
->get($this->downloadUrl('secdlmgrAAAA'));
$this->assertNotSame(403, $response->getStatusCode(), 'manager 는 비밀글 첨부에 접근 가능해야 합니다');
$this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
}
// ==========================================
// 정상글 첨부는 그대로 (회귀 방지)
// ==========================================
/**
* @scenario viewer=regular
*
* @effects normal_post_attachment_still_public
*/
public function test_normal_post_attachment_still_public(): void
{
$postId = $this->createTestPost([
'title' => '정상글 첨부',
'status' => 'published',
'is_secret' => false,
]);
$this->createAttachment($postId, 'normsecAAAAA');
$response = $this->actingAs($this->regularUser, 'sanctum')
->get($this->downloadUrl('normsecAAAAA'));
$this->assertNotSame(403, $response->getStatusCode(), '정상글 첨부는 권한 차단되면 안 됩니다');
$this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
}
}