Files
Gnuboard7/docs/extension/template-security.md
T
HeuJung 72f46a6468 fix(core): 레이아웃 첨부 URL 을 사이트 상대 경로로 발급하고 저장 게이트가 자기 주소를 외부로 보지 않게 수정
편집기 헤더 「로고 이미지」에 파일을 올린 뒤 저장하면 「HTTPS 프로토콜 URL은
허용되지 않습니다」 422 로 거부되던 결함. 프록시 서빙 URL 이 절대 형태로 발급되어
NoExternalUrls 가 서버 자신의 주소를 외부로 차단했고, 배경 이미지는 style 로 들어가
스캔 밖이어서 로고(propValue)에서만 드러났다.

- proxyUrl 을 사이트 상대 경로로 발급 (발급 시점 도메인·스킴에 묶이지 않는다)
- SiteAssetHosts 신설: app.url host + 운영자가 선언한 공개 자산 디스크 host 를
 브라우저와 같은 정규화 뒤 등가 비교. 요청 Host 헤더는 근거로 쓰지 않고,
 흉내 host·protocol-relative·위험 스킴은 종전대로 차단
- 회귀 테스트 4클래스(71 passed) + 시나리오 매니페스트 + Playwright 2케이스,
 공개 문서(SECURITY·security·storage-driver·api)와 규정 표 갱신

함께 담긴 릴리즈 정비:
- 7.0.11 미출시 섹션의 릴리즈 날짜를 09-09 로 갱신 (코어·board·page·admin_basic·basic·ja 언어팩 3종)
- 배포본에 실리는 주석·독블록·시나리오의 내부 호칭을 중립 표현으로 정리하고,
 동봉 vendor 자산의 우연한 토큰은 누출 검사에서 경로 규칙으로만 제외
- 버전 bump 검사: github_url 없는 번들을 언어팩과 같은 origin/release 페이로드
 기준선으로 판정(상시 경고 제거), 코어 버전 판독이 env 래핑을 읽도록 보정
2026-09-09 09:20:29 +09:00

12 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 차단 (사이트 자기 host 와 「공개 자산 스토리지」로 선언한 디스크의 host 는 외부가 아니다)
컴포넌트 존재 여부 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) 운영에서는 참조 자체가 없다.

이 주입이 실제로 먹으려면 확장의 vite config 가 그 환경변수를 읽어야 한다. sourcemap: true 리터럴 하드코딩은 주입을 조용히 무시해 --production 빌드에도 .map 과 dangling 참조가 배포본에 남는다. 확장 vite config 의 소스맵 선언은 다음 형태만 사용한다:

sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),

판정 지점은 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

관련 문서