Files
Gnuboard7/tests/Feature/Plugin/PluginAssetServingTest.php
HeuJung 90f154ecc0 fix(security): 배포본 소스맵 노출 차단 + 관리자 설정 탭 지연로딩 + 동반 결함 수정
## 소스맵 노출 차단 (공개 이슈 )

배포본에 .map 이 포함돼 원본 TS/TSX 전문(sourcesContent, 한글 주석 포함)이
그대로 노출됐다. 생성·서빙·저장소 유입 3계층을 모두 막는다.

- 빌드 config 15곳(코어 3 + _bundled 12): sourcemap 하드코딩 제거.
 G7_BUILD_SOURCEMAP 판정식으로 교체 — 미설정(로컬 npm run build)이면 생성해
 개발 디버깅 경험을 보존하고, 0 이면 미생성.
- Build{Core,Module,Plugin,Template}Command: 죽어 있던 --production 플래그 복구.
 $productionMode 를 콘솔 문자열에만 쓰고 npm 프로세스에 env 를 넘기지 않아
 산출물이 개발 빌드와 동일했다. runNpmCommand 에 $env 를 추가해 주입
 (--watch 에는 미주입). Symfony Process 는 부모 환경에 병합하므로 PATH 유실 없음.
- Allowed{Module,Plugin}FileType / {Module,Plugin}Service::getMimeType:
 허용 확장자 map 제거 — 익명 200 서빙 차단(템플릿은 원래 미허용, 3종 정책 일치).
- ExtensionBundleService: CSS 번들의 /*# sourceMappingURL=... */ strip 추가.
 CSS 는 블록 주석이라 JS 의 //# 패턴으로는 검출되지 않던 구멍.
- 추적 .map 13개 삭제 + .gitignore 전역 *.map 규칙(경로 한정 시 새 확장에서 누락).

실측: 산출 JS 의 sourceMappingURL 13 → 0, 추적 .map 13 → 0,
확장 디렉토리 npm run build 는 여전히 map 생성(개발 경험 회귀 없음).

## 관리자 환경설정 탭 지연 로딩

코어/이커머스/게시판 환경설정 화면이 진입 즉시 전 탭의 데이터소스를 한꺼번에
호출했다. 각 데이터소스에 활성 탭 조건(if)을 걸어 해당 탭에서만 fetch 하도록
변경하고, 결제 플러그인 3종의 테스트모드 상태 조회도 주문설정 탭으로 한정.

## 동반 결함 수정 (무관 결함 — 재발 방지 목적으로 같은 세션 처리)

- 나이스페이먼츠: 체크아웃 캐시 프리페치가 라우트를 가리지 않아 플러그인 번들이
 로드되는 모든 페이지(홈/게시판/로그인)에서 주문서를 조회 → 비회원 매번 422.
 형제 플러그인(kginicis)의 기존 가드 패턴을 적용해 체크아웃 경로로 한정.
- admin smoke 8건: 대부분 구현이 아니라 테스트 결함이었다 — URL 오타(단수 board),
 존재하지 않는 권한키, 앱 로케일이 en 인데 한국어 라벨 로케이터, DataGrid 버튼을
 먼저 잡던 휴리스틱 로케이터. 레이아웃에는 sticky className / 안정 id 를 부여.
- PHPDoc ↔ Pint 충돌 3건: 설명 없는 @param 은 no_superfluous_phpdoc_tags 가
 삭제해 audit phpdoc-public-method 와 무한 충돌. 한국어 설명을 붙여 양쪽 충족
 (리스너 3종의 설명 누락 메서드도 전수 보강).
- Log::spy 500 3건: spy 가 Log::channel('activity') 를 null 로 만들고
 logActivity 가 null->info 로 \Error → 같은 줄 catch(\Exception) 은 못 잡아
 통과. 운영에선 채널이 항상 로거이므로 제품 무결 → 테스트에 activity 채널
 보존 헬퍼 추가.
- NhnKcpApiServiceTest 5건: 스텁이 Unix 바이너리만 만들어 Windows 분기
 (pp_cli_exe.exe)에서만 실패. #!/bin/sh 는 Windows 실행 불가 → 파일 내 기존
 컨벤션대로 skip 가드.

## 테스트

- 소스맵 범위 130 passed (신규: BuildCommandSourcemapEnv, NoSourcemapArtifacts,
 ExtensionBundleService CSS strip, Allowed*FileType 반전, 자산 서빙 거부)
- 결제 3플러그인 505 passed / 0 failed (skipped 9 = Windows 전용 CLI, 사유 명시)
- 나이스페이먼츠 프론트 38 passed (가드 제거 시 red 실증 후 복원)
- E2E: production-sourcemap-exposure.spec.ts 6건 + 시나리오 매니페스트

버전: sirsoft-admin_basic 1.0.2 → 1.0.3, sirsoft-board 1.0.1 → 1.0.2
(각 manifest/package/lock/composer 동기화). 코어 7.0.4 는 미출시 누적.
2026-07-14 12:50:23 +09:00

451 lines
14 KiB
PHP

<?php
namespace Tests\Feature\Plugin;
use App\Enums\ExtensionStatus;
use App\Models\Plugin;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PluginAssetServingTest extends TestCase
{
use RefreshDatabase;
private Plugin $activePlugin;
private string $testPluginPath;
protected function setUp(): void
{
parent::setUp();
// 테스트용 활성화된 플러그인 생성
$this->activePlugin = Plugin::factory()->create([
'identifier' => 'test-plugin',
'status' => ExtensionStatus::Active->value,
]);
// 테스트용 플러그인 디렉토리 생성
$this->testPluginPath = base_path('plugins/test-plugin');
if (! file_exists($this->testPluginPath.'/dist/js')) {
mkdir($this->testPluginPath.'/dist/js', 0755, true);
}
if (! file_exists($this->testPluginPath.'/dist/css')) {
mkdir($this->testPluginPath.'/dist/css', 0755, true);
}
}
protected function tearDown(): void
{
// 테스트용 파일 및 디렉토리 정리
if (file_exists($this->testPluginPath)) {
$this->deleteDirectory($this->testPluginPath);
}
parent::tearDown();
}
/**
* 디렉토리 재귀 삭제 헬퍼 메서드
*/
private function deleteDirectory(string $dir): bool
{
if (! file_exists($dir)) {
return true;
}
if (! is_dir($dir)) {
return unlink($dir);
}
foreach (scandir($dir) as $item) {
if ($item == '.' || $item == '..') {
continue;
}
if (! $this->deleteDirectory($dir.DIRECTORY_SEPARATOR.$item)) {
return false;
}
}
return rmdir($dir);
}
/**
* 활성화된 플러그인의 JS 파일 서빙 성공
*/
public function test_serves_js_file_from_active_plugin(): void
{
// Arrange
$jsPath = $this->testPluginPath.'/dist/js/plugin.iife.js';
file_put_contents($jsPath, 'console.log("test plugin");');
// Act
$response = $this->get('/api/plugins/assets/test-plugin/dist/js/plugin.iife.js');
// Assert
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'application/javascript');
}
/**
* 활성화된 플러그인의 CSS 파일 서빙 성공
*/
public function test_serves_css_file_from_active_plugin(): void
{
// Arrange
$cssPath = $this->testPluginPath.'/dist/css/plugin.css';
file_put_contents($cssPath, '.plugin { color: green; }');
// Act
$response = $this->get('/api/plugins/assets/test-plugin/dist/css/plugin.css');
// Assert
$response->assertStatus(200);
$this->assertTrue(
str_starts_with($response->headers->get('Content-Type'), 'text/css'),
'Content-Type should start with text/css'
);
}
/**
* 비활성화 플러그인 접근 시 404 반환
*/
public function test_returns_404_for_inactive_plugin(): void
{
// Arrange
Plugin::factory()->create([
'identifier' => 'inactive-plugin',
'status' => ExtensionStatus::Inactive->value,
]);
// Act
$response = $this->get('/api/plugins/assets/inactive-plugin/dist/js/plugin.iife.js');
// Assert
$this->assertContains($response->status(), [302, 404]);
}
/**
* 존재하지 않는 파일 접근 시 404 반환
*/
public function test_returns_404_for_nonexistent_file(): void
{
// Act
$response = $this->get('/api/plugins/assets/test-plugin/dist/js/nonexistent.js');
// Assert
$this->assertContains($response->status(), [302, 404]);
}
/**
* Path Traversal 공격 차단 - 기본 패턴 (../)
*/
public function test_blocks_path_traversal_attack(): void
{
// Act
$response = $this->get('/api/plugins/assets/test-plugin/../../.env');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* Path Traversal 공격 차단 - 백슬래시 패턴 (..\)
*/
public function test_blocks_path_traversal_with_backslash(): void
{
// Act
$response = $this->get('/api/plugins/assets/test-plugin/..%5c..%5cconfig.php');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* Path Traversal 공격 차단 - URL 인코딩 패턴
*/
public function test_blocks_url_encoded_path_traversal(): void
{
$patterns = [
'%2e%2e%2f',
'%2e%2e/',
'%2e%2e%5c',
'..%2f',
'..%5c',
'.%2e/',
];
foreach ($patterns as $pattern) {
$response = $this->get("/api/plugins/assets/test-plugin/{$pattern}secret.txt");
$this->assertContains($response->status(), [302, 422], "Pattern {$pattern} should be blocked");
}
}
/**
* 절대 경로 차단 - Windows 경로
*/
public function test_blocks_windows_absolute_path(): void
{
// Act
$response = $this->get('/api/plugins/assets/test-plugin/C:%5cWindows%5cSystem32%5cconfig.ini');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* 절대 경로 차단 - Linux 경로
*/
public function test_blocks_linux_absolute_path(): void
{
// Act
$response = $this->get('/api/plugins/assets/test-plugin//etc/passwd');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* NULL 바이트 공격 차단
*/
public function test_blocks_null_byte_attack(): void
{
// Act
$response = $this->get('/api/plugins/assets/test-plugin/malicious.php%00.js');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* 불허 파일 타입 차단 - PHP
*/
public function test_blocks_disallowed_file_types(): void
{
// Arrange
$phpPath = $this->testPluginPath.'/malicious.php';
file_put_contents($phpPath, '<?php echo "hack"; ?>');
// Act
$response = $this->get('/api/plugins/assets/test-plugin/malicious.php');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* 불허 파일 타입 차단 - 실행 파일들
*/
public function test_blocks_various_disallowed_file_types(): void
{
$disallowedExtensions = ['exe', 'sh', 'bat', 'dll', 'so', 'py', 'rb', 'pl', 'asp', 'aspx', 'jsp', 'cgi'];
foreach ($disallowedExtensions as $ext) {
$response = $this->get("/api/plugins/assets/test-plugin/malicious.{$ext}");
$this->assertContains($response->status(), [302, 422], "Extension .{$ext} should be blocked");
}
}
/**
* 허용된 파일 타입 확인
*/
public function test_allows_all_whitelisted_file_types(): void
{
$allowedFiles = [
'app.js' => 'application/javascript',
'style.css' => 'text/css',
'data.json' => 'application/json',
'image.png' => 'image/png',
'photo.jpg' => 'image/jpeg',
'icon.svg' => 'image/svg+xml',
'font.woff' => 'font/woff',
'font.woff2' => 'font/woff2',
];
foreach ($allowedFiles as $filename => $expectedMimeType) {
$filePath = $this->testPluginPath.'/dist/'.$filename;
file_put_contents($filePath, 'test content');
$response = $this->get("/api/plugins/assets/test-plugin/dist/{$filename}");
$response->assertStatus(200, "File {$filename} should be allowed");
$actualMimeType = $response->headers->get('Content-Type');
if ($filename === 'style.css') {
$this->assertTrue(
str_starts_with($actualMimeType, $expectedMimeType),
"MIME type for {$filename} should start with {$expectedMimeType}"
);
} else {
$this->assertEquals(
$expectedMimeType,
$actualMimeType,
"MIME type for {$filename} should be {$expectedMimeType}"
);
}
}
}
/**
* 캐싱 헤더 정상 설정
*/
public function test_sets_caching_headers(): void
{
// Arrange
$jsPath = $this->testPluginPath.'/dist/js/plugin.iife.js';
file_put_contents($jsPath, 'console.log("test");');
// Act
$response = $this->get('/api/plugins/assets/test-plugin/dist/js/plugin.iife.js');
// Assert
$response->assertStatus(200);
$cacheControl = $response->headers->get('Cache-Control');
$this->assertNotNull($cacheControl, 'Cache-Control header should be present');
}
/**
* ETag 헤더 생성 확인
*/
public function test_generates_etag_header(): void
{
// Arrange
$jsPath = $this->testPluginPath.'/dist/js/plugin.iife.js';
file_put_contents($jsPath, 'console.log("test");');
// Act
$response = $this->get('/api/plugins/assets/test-plugin/dist/js/plugin.iife.js');
// Assert
$response->assertStatus(200);
$etag = $response->headers->get('ETag');
$this->assertNotNull($etag, 'ETag header should be present');
$this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $etag, 'ETag should be a 32-character MD5 hash');
}
/**
* ETag 매칭 시 304 Not Modified 응답
*/
public function test_returns_304_when_etag_matches(): void
{
// Arrange
$jsPath = $this->testPluginPath.'/dist/js/plugin.iife.js';
file_put_contents($jsPath, 'console.log("test");');
$firstResponse = $this->get('/api/plugins/assets/test-plugin/dist/js/plugin.iife.js');
$etag = $firstResponse->headers->get('ETag');
// Act
$response = $this->withHeaders([
'If-None-Match' => $etag,
])->get('/api/plugins/assets/test-plugin/dist/js/plugin.iife.js');
// Assert
$response->assertStatus(304);
$this->assertEmpty($response->getContent(), '304 response should have empty body');
}
/**
* 프로덕션 환경에서 immutable 캐싱 정책 적용
*/
public function test_applies_immutable_caching_in_production(): void
{
// Arrange
app()['env'] = 'production';
$jsPath = $this->testPluginPath.'/dist/js/plugin.iife.js';
file_put_contents($jsPath, 'console.log("test");');
// Act
$response = $this->get('/api/plugins/assets/test-plugin/dist/js/plugin.iife.js');
// Assert
$response->assertStatus(200);
$cacheControl = $response->headers->get('Cache-Control');
$this->assertStringContainsString('immutable', $cacheControl, 'Production should use immutable caching');
$this->assertStringContainsString('public', $cacheControl);
$this->assertStringContainsString('max-age=31536000', $cacheControl);
}
/**
* 개발 환경에서 no-cache 정책 적용
*/
public function test_applies_no_cache_in_development(): void
{
// Arrange
app()['env'] = 'local';
$jsPath = $this->testPluginPath.'/dist/js/plugin.iife.js';
file_put_contents($jsPath, 'console.log("test");');
// Act
$response = $this->get('/api/plugins/assets/test-plugin/dist/js/plugin.iife.js');
// Assert
$response->assertStatus(200);
$cacheControl = $response->headers->get('Cache-Control');
$this->assertStringContainsString('no-cache', $cacheControl, 'Development should use no-cache');
}
/**
* 플러그인 라우트 접근 가능 테스트
*/
public function test_plugin_route_is_accessible(): void
{
// Arrange
$jsPath = $this->testPluginPath.'/dist/js/plugin.iife.js';
file_put_contents($jsPath, 'console.log("test");');
// Act
$response = $this->get('/api/plugins/assets/test-plugin/dist/js/plugin.iife.js');
// Assert
$response->assertStatus(200);
}
/**
* 중첩 경로 처리 테스트
*/
public function test_handles_nested_path(): void
{
// Arrange
$nestedPath = $this->testPluginPath.'/dist/js/components/Button.js';
mkdir(dirname($nestedPath), 0755, true);
file_put_contents($nestedPath, 'export default Button;');
// Act
$response = $this->get('/api/plugins/assets/test-plugin/dist/js/components/Button.js');
// Assert
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'application/javascript');
}
/**
* Source Map 파일 서빙 거부 테스트
*
* 소스맵은 원본 코드 전문을 담고 있어 배포 환경에서 노출되면 안 된다.
* 파일이 디스크에 실재하더라도(= 파일 부재로 인한 실패가 아님) 허용 확장자
* 목록에서 제외되어 서빙이 거부되어야 한다.
*/
public function test_rejects_sourcemap_file(): void
{
// Arrange: 맵 파일이 실제로 존재하는 상태를 만든다
$mapPath = $this->testPluginPath.'/dist/js/plugin.iife.js.map';
file_put_contents($mapPath, '{"version":3,"sources":[],"mappings":""}');
$this->assertFileExists($mapPath);
// Act: JSON 클라이언트 (에러 본문 확인용)
$json = $this->getJson('/api/plugins/assets/test-plugin/dist/js/plugin.iife.js.map');
// Assert: 허용 확장자 검증에서 거부 — 맵 내용이 절대 응답에 실리지 않는다
$json->assertStatus(422);
$json->assertJsonValidationErrors('path');
$this->assertStringNotContainsString('mappings', $json->getContent());
// Act: 브라우저 요청 (Accept 헤더 없음) — 서빙되지 않음을 확인
$browser = $this->get('/api/plugins/assets/test-plugin/dist/js/plugin.iife.js.map');
// Assert: 200 서빙이 아니어야 한다 (검증 실패 → 리다이렉트)
$this->assertNotSame(200, $browser->getStatusCode());
}
}