Files
Gnuboard7/docs/extension/template-security.md
T
HeuJung d6e1f98a4f fix(core): 확장 오토로드 캐시 공집합 덮어쓰기로 인한 전역 500 + 소스맵 노출 차단 복원
오토로드 캐시(psr4)가 공집합으로 덮어써져 모든 모듈/플러그인 컨트롤러가
해석 불가능해지고 확장 화면 전체가 500 이 됐다. 이 매핑은 "DB 설치 목록 ×
디스크 활성 디렉토리" 교집합이라 DB 쪽만 일시적으로 비어도(테이블 부재·
설치 중간 실패·testing DB) 정상 매핑이 통째로 소실된다. 코어 라우트는 200
인데 확장 라우트만 전부 500 인 비대칭이 이 결함의 진단 단서다.

- 디스크에 확장 디렉토리가 남아 있는데 매핑이 0건이면 기록하지 않고 보존.
 정상적인 0건(신규 설치·마지막 확장 삭제)은 디스크도 함께 비어 통과한다
- generateAutoloadFile 에 testing 실 캐시 경로 쓰기 차단. updateComposerAutoload
 에만 있던 가드를 생성기 자체로 옮겨 어느 경로로 들어와도 막히게 했다
- extension:update-autoload 가 훅 캐시 재생성 전에 런타임 오토로드를 재등록.
 미재등록 시 깨진 매핑으로 훅 캐시가 modules/plugins 0건으로 생성되어
 확장 훅이 조용히 사라지고, 복구에 커맨드 2회 실행이 필요했다

소스맵 서빙은 에서 차단했으나 이번 브랜치에서 화이트리스트에 재추가되어
운영에서 원본 코드 전문(sourcesContent)이 노출될 수 있었다. 이 화이트리스트가
확장 에셋 서빙의 유일한 방어선이다. dev 빌드의 sourceMappingURL 404 를 피하면서
운영을 막도록 local 환경에서만 허용하도록 분기하고, 산출물을 production 으로
재빌드해 docroot 에 남아 있던 소스맵을 제거했다. --production 빌드는 소스맵을
생성하지 않을 뿐 기존 파일을 지우지 않는다.

CoreUpdateCommandSpawnFailureTest 는 in-process fallback 이 실제 업그레이드
스텝을 돌려 코어 역할·권한·메뉴를 커밋하는데 RefreshDatabase 가 없어, 이후
모든 테스트 클래스가 중복 키로 깨졌다(단독 실행 시 미재현).
2026-07-23 13:04:59 +09:00

11 KiB

템플릿 보안 정책

목적: 템플릿 시스템의 보안 정책 및 API 서빙 규칙 가이드


목차

  1. 레이아웃 JSON 검증
  2. 의존성 검증
  3. XSS 방지
  4. 템플릿 API 서빙 규칙
  5. 파일 확장자 화이트리스트

1. 레이아웃 JSON 검증

검증 계층

FormRequest (StoreLayoutRequest)
    ↓
Custom Rule: ValidLayoutStructure
    ↓
Custom Rule: WhitelistedEndpoint
    ↓
Custom Rule: NoExternalUrls
    ↓
Custom Rule: ComponentExists

검증 항목

항목 설명
JSON 구조 유효성 올바른 JSON 형식인지 검증
최대 중첩 깊이 10단계 제한
엔드포인트 화이트리스트 /api/(admin|auth|public)/ 패턴만 허용
외부 URL 금지 외부 도메인 URL 차단
컴포넌트 존재 여부 components.json 기준으로 검증

2. 의존성 검증

템플릿 설치 시 체크

  • 필수 의존성 템플릿 설치 여부
  • 순환 의존성 방지
  • 버전 호환성 (Semantic Versioning)

예시

// template.json
{
  "dependencies": {
    "sirsoft-component-library": "^1.0.0"
  }
}

3. XSS 방지

데이터 바인딩

  • 모든 {{data.field}} 값은 자동 이스케이프
  • dangerouslySetInnerHTML 사용 금지
  • HTML 삽입 시 서버 검증 필수

Translation

  • $t:key 값은 다국어 파일에서만 로드
  • 사용자 입력 키 금지

4. 템플릿 API 서빙 규칙

중요: 템플릿 에셋은 API를 통해 제공
✅ 필수: 파일 복사 대신 API 엔드포인트 사용

원칙

  • 템플릿 설치 시 빌드된 파일(/templates/[vendor-template]/dist/)은 그대로 유지
  • /public/build/templates/ 디렉토리에 파일 복사하지 않음
  • API 엔드포인트를 통해 동적으로 템플릿 에셋 제공
  • 보안: 활성화된 템플릿의 에셋만 접근 가능

API 엔드포인트

// routes/api.php
Route::get('/templates/{identifier}/assets/{file}', [TemplateAssetController::class, 'serve'])
    ->where('file', '.*')
    ->name('api.templates.assets');

Controller 구현 (TemplateAssetController)

<?php

namespace App\Http\Controllers\Api\Public;

use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Services\Template\TemplateService;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;

class TemplateAssetController extends PublicBaseController
{
    public function __construct(
        private TemplateService $templateService
    ) {}

    /**
     * 템플릿 에셋 파일 제공
     *
     * @param string $identifier 템플릿 식별자
     * @param string $file 파일 경로 (예: dist/components.js)
     * @return BinaryFileResponse|Response
     */
    public function serve(string $identifier, string $file): BinaryFileResponse|Response
    {
        // 1. 템플릿 활성화 여부 확인
        if (!$this->templateService->isTemplateActive($identifier)) {
            return $this->notFound('messages.template.not_active');
        }

        // 2. 파일 경로 검증 (보안)
        $allowedExtensions = ['js', 'js.map', 'css', 'css.map', 'woff', 'woff2', 'ttf', 'eot', 'svg', 'png', 'jpg', 'jpeg', 'gif'];
        $extension = pathinfo($file, PATHINFO_EXTENSION);

        if (!in_array($extension, $allowedExtensions)) {
            return $this->forbidden('messages.template.invalid_file_type');
        }

        // 3. 실제 파일 경로 생성
        $basePath = base_path("templates/{$identifier}");
        $filePath = realpath("{$basePath}/{$file}");

        // 4. 디렉토리 탈출 방지
        if (!$filePath || !str_starts_with($filePath, $basePath)) {
            return $this->forbidden('messages.template.invalid_path');
        }

        // 5. 파일 존재 확인
        if (!file_exists($filePath) || !is_file($filePath)) {
            return $this->notFound('messages.template.file_not_found');
        }

        // 6. MIME 타입 설정
        $mimeTypes = [
            'js' => 'application/javascript',
            'css' => 'text/css',
            'map' => 'application/json',
            'woff' => 'font/woff',
            'woff2' => 'font/woff2',
            'ttf' => 'font/ttf',
            'eot' => 'application/vnd.ms-fontobject',
            'svg' => 'image/svg+xml',
            'png' => 'image/png',
            'jpg' => 'image/jpeg',
            'jpeg' => 'image/jpeg',
            'gif' => 'image/gif',
        ];

        $mimeType = $mimeTypes[$extension] ?? 'application/octet-stream';

        // 7. 캐싱 헤더 추가
        return response()->file($filePath, [
            'Content-Type' => $mimeType,
            'Cache-Control' => 'public, max-age=31536000, immutable',
            'Access-Control-Allow-Origin' => '*',
        ]);
    }
}

Service 메서드

<?php

namespace App\Services\Template;

class TemplateService
{
    /**
     * 템플릿 활성화 여부 확인
     */
    public function isTemplateActive(string $identifier): bool
    {
        return Cache::remember(
            "template.active.{$identifier}",
            3600,
            fn() => Template::where('identifier', $identifier)
                ->where('is_active', true)
                ->exists()
        );
    }
}

Blade 사용 예시

<!-- resources/views/app.blade.php -->
<!DOCTYPE html>
<html>
<head>
    <!-- 활성 템플릿 컴포넌트 로드 -->
    @if($activeTemplate)
        <script src="{{ route('api.templates.assets', [
            'identifier' => $activeTemplate->identifier,
            'file' => 'dist/components.js'
        ]) }}" defer></script>
    @endif
</head>
<body>
    <!-- 앱 컨텐츠 -->
</body>
</html>

보안 규칙

규칙 설명
✅ 활성화된 템플릿만 접근 가능 비활성 템플릿 에셋 접근 차단
✅ 화이트리스트 기반 확장자 검증 허용된 확장자만 제공
✅ 디렉토리 탈출 공격 방지 realpath(), str_starts_with() 사용
✅ MIME 타입 명시적 설정 Content-Type 헤더 지정
✅ 적절한 캐싱 헤더 Cache-Control, immutable
❌ 비활성 템플릿 에셋 접근 금지 활성화 여부 검증 필수
❌ 임의의 파일 경로 접근 금지 경로 검증 필수

장점

  1. 보안 강화: 활성화된 템플릿만 접근 가능
  2. 디스크 공간 절약: 파일 복사 불필요
  3. 동적 제어: 템플릿 비활성화 시 즉시 접근 차단
  4. 캐싱 최적화: HTTP 캐싱으로 성능 유지
  5. 유지보수 용이: 단일 소스(dist 디렉토리)만 관리

5. 파일 확장자 화이트리스트

중요: 템플릿 에셋 파일 확장자 제한
✅ 필수: 화이트리스트 기반 확장자 검증

허용 확장자 목록

스크립트 파일

확장자 설명
js JavaScript
mjs ES Module JavaScript

스타일 파일

확장자 설명
css Cascading Style Sheets

소스맵 (map) — 로컬 개발 환경 전용

소스맵에는 원본 TS/TSX 전문(sourcesContent)이 담기고, 이 확장자 화이트리스트가 확장 에셋 서빙의 유일한 방어선이다. 따라서 map 은 local 환경에서만 허용 목록에 덧붙고, 그 외 환경에서는 어떤 경우에도 서빙하지 않는다.

로컬에서 허용하는 이유는 dev 빌드 산출물의 //# sourceMappingURL 이 개별 에셋 서빙 URL 을 가리키기 때문이다 — 차단하면 브라우저 콘솔에 404 가 쌓인다. --production 빌드는 소스맵을 생성하지 않으므로(G7_BUILD_SOURCEMAP=0) 운영에서는 참조 자체가 없다.

판정 지점은 Allowed{Module,Plugin,Template}FileType::getAllowedExtensions() 한 곳이며, validate() 도 이 게터를 참조한다(상수 직접 참조 금지 — 환경 분기가 우회된다).

public/build/** 는 docroot 라 이 화이트리스트를 거치지 않는다. --production 재빌드는 기존 .map 을 지우지 않으므로 직접 삭제해야 한다.

폰트 파일

확장자 설명
woff Web Open Font Format
woff2 Web Open Font Format 2
ttf TrueType Font
otf OpenType Font
eot Embedded OpenType

이미지 파일

확장자 설명
png Portable Network Graphics
jpg JPEG Image
jpeg JPEG Image
svg Scalable Vector Graphics
webp WebP Image
gif Graphics Interchange Format

데이터 파일

확장자 설명
json JSON 데이터 (components.json, template.json 등)

TemplateService 구현

// app/Services/TemplateService.php

/**
 * 허용된 파일 확장자 화이트리스트
 */
private const ALLOWED_EXTENSIONS = [
    'js', 'mjs', 'css', 'json',
    'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif',
    'woff', 'woff2', 'ttf', 'otf', 'eot',
];

/**
 * 파일 확장자 검증
 */
private function validateFileExtension(string $filePath): bool
{
    $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));

    // .js.map, .css.map 처리
    if (str_ends_with($filePath, '.map')) {
        $extension = 'map';
    }

    return in_array($extension, self::ALLOWED_EXTENSIONS);
}

/**
 * MIME 타입 매핑
 */
private function getMimeType(string $extension): string
{
    $mimeTypes = [
        'js' => 'application/javascript',
        'mjs' => 'application/javascript',
        'css' => 'text/css',
        'json' => 'application/json',
        'map' => 'application/json',
        'png' => 'image/png',
        'jpg' => 'image/jpeg',
        'jpeg' => 'image/jpeg',
        'svg' => 'image/svg+xml',
        'webp' => 'image/webp',
        'gif' => 'image/gif',
        'woff' => 'font/woff',
        'woff2' => 'font/woff2',
        'ttf' => 'font/ttf',
        'otf' => 'font/otf',
        'eot' => 'application/vnd.ms-fontobject',
    ];

    return $mimeTypes[$extension] ?? 'application/octet-stream';
}

보안 규칙

규칙 설명
✅ 화이트리스트에 명시된 확장자만 허용 목록 외 차단
✅ 대소문자 구분 없이 검증 strtolower() 사용
✅ 소스맵은 local 에서만 허용 운영에서 서빙 시 원본 코드 전문 노출
실행 파일 확장자 금지 .php, .sh, .exe 등
❌ 서버 설정 파일 금지 .htaccess, .env 등
❌ 화이트리스트 외 모든 확장자 차단 기본 거부 정책

금지 확장자 예시 (절대 추가 금지)

카테고리 확장자
PHP 실행 파일 .php, .phar
실행 스크립트 .sh, .bat, .exe
설정 파일 .env, .htaccess, .conf
데이터베이스 파일 .sql, .db
압축 파일 (보안 위험) .zip, .tar, .gz

관련 문서