20 KiB
보안 및 검증
이 문서는 그누보드7 프론트엔드 템플릿 시스템의 보안 및 검증 규칙을 설명합니다. 전체 보안 아키텍처 및 공격 방어 전략은 SECURITY.md 참조.
TL;DR (5초 요약)
1. 레이아웃 JSON: FormRequest + Custom Rule 10종 검증 (서버 사전 차단)
2. XSS 방지: React 자동 이스케이프 + dangerouslySetInnerHTML 금지 + HtmlContent만 허용
3. 표현식 보안: Optional Chaining + fallback 필수 ({{value ?? ''}})
4. 상태/설정 노출: _global에 민감정보 금지 + FiltersFrontendSchema로 G7Config 최소화
5. 인증: Bearer 토큰 전용 + 401 자동 갱신 + 로그아웃 상태 정리
6. 에셋 보안: 확장자 화이트리스트 + 디렉토리 탈출 방지
목차
- 레이아웃 JSON 서버 검증
- XSS 방지
- 표현식 평가 보안
- 인증/토큰 프론트엔드 보안
- 상태 관리 및 데이터 노출 보안
- 렌더링 오류 방어
- 템플릿 에셋 보안
- 의존성 검증
- 보안 체크리스트
- 관련 문서
레이아웃 JSON 서버 검증
레이아웃 JSON은 저장 전 서버에서 10가지 Custom Rule로 사전 검증됩니다. 프론트엔드 개발자는 이를 인지하되, 검증 로직을 프론트엔드에서 재구현할 필요가 없습니다.
검증 시점
| 시점 | FormRequest | 설명 |
|---|---|---|
| 레이아웃 생성 | StoreLayoutRequest |
기본 4개 Rule |
| 레이아웃 수정 | UpdateLayoutRequest |
기본 4개 Rule (sometimes) |
| 콘텐츠만 수정 | UpdateLayoutContentRequest |
가장 엄격 — 8개 Rule |
| 상속 레이아웃 생성/수정 | Store/UpdateLayoutInheritanceRequest |
상속 전용 3개 Rule |
Custom Rule 요약
| 검증 영역 | Rule | 핵심 |
|---|---|---|
| JSON 구조 | ValidLayoutStructure | 필수 필드, 깊이 10단계 제한, 타입 검증 |
| 컴포넌트 | ComponentExists | components.json 매니페스트 대조 |
| API 엔드포인트 | WhitelistedEndpoint | /api/(admin|auth|public)/ 패턴만 허용 |
| 외부 URL | NoExternalUrls | http, data, javascript 등 7개 위험 스킴 차단 |
| 상속 | ValidParentLayout | 순환 참조 방지, 상속 깊이 10 제한 |
| 슬롯 | ValidSlotStructure | 부모에서 정의된 슬롯만 허용 |
| 데이터소스 | ValidDataSourceMerge | 상속 체인 ID 고유성 |
| 권한 | ValidPermissionStructure | or/and 구조 + 깊이 3 제한 |
| 경로 | SafeTemplatePath | Path Traversal 13패턴 차단 |
| 파일 타입 | AllowedTemplateFileType | 14개 확장자 화이트리스트 |
XSS 방지
다층 방어 전략
| 계층 | 방어 방식 | 담당 |
|---|---|---|
| 저장 시 | 레이아웃 JSON 검증 | Custom Rule |
| 렌더링 시 | 자동 이스케이프 | React |
| URL 검증 | 외부 URL 차단 | NoExternalUrls Rule |
React 자동 이스케이프
React는 JSX에서 출력되는 모든 값을 자동으로 이스케이프합니다:
// React가 자동으로 HTML 엔티티로 변환
<div>{userInput}</div> // "<script>" → "<script>"
dangerouslySetInnerHTML 금지
필수: 텍스트 출력 시 React의 기본 렌더링 사용
금지: dangerouslySetInnerHTML 직접 사용
HtmlContent / HtmlEditor (HTML 렌더링이 필요한 경우)
HTML을 렌더링해야 하는 경우 (게시판 본문, 상품 설명 등) 반드시 지정된 컴포넌트만 사용합니다:
| 컴포넌트 | 용도 | 비고 |
|---|---|---|
HtmlContent |
HTML 콘텐츠 읽기 전용 렌더링 | 서버에서 sanitize된 데이터만 사용 |
HtmlEditor |
HTML 편집기 (WYSIWYG) | 내부적으로 안전한 렌더링 처리 |
// ✅ HtmlContent 컴포넌트로 안전한 HTML 렌더링
{
"type": "composite",
"name": "HtmlContent",
"props": {
"html": "{{post?.data?.content ?? ''}}"
}
}
// ❌ 임의 컴포넌트에서 dangerouslySetInnerHTML 사용 금지
상세: editors.md
Translation 키 보안
$t:key 값은 다국어 파일에서만 로드되며, 사용자 입력 키를 직접 사용할 수 없습니다.
표현식 평가 보안
엔진 파서 메커니즘
템플릿 엔진은 {{expression}} 내부를 JavaScript new Function() 기반으로 평가합니다.
보안 전제: 레이아웃 JSON은 서버에서 4단계 Custom Rule 검증을 거쳐 저장되므로, 악의적 표현식이 포함될 가능성은 서버 검증으로 사전 차단됩니다. new Function()은 관리자가 작성한 검증된 표현식만 실행합니다.
안전한 데이터 접근 (필수)
데이터 로드 전에 렌더링이 시작되므로, Optional Chaining + fallback이 없으면 런타임 에러가 발생합니다:
// ❌ 위험: 데이터 로드 전 undefined 에러
{ "text": "{{user.profile.name}}" }
// ✅ 안전: 단계적 확인 + fallback
{ "text": "{{user?.profile?.name ?? ''}}" }
필수 fallback 패턴
| 데이터 유형 | 패턴 | 예시 |
|---|---|---|
| 텍스트 | ?? '' |
{{user?.name ?? ''}} |
| 배열 | ?? [] |
{{options ?? []}} |
| 객체 | ?? {} |
{{config ?? {}}} |
| 숫자 | ?? 0 |
{{count ?? 0}} |
| API 응답 | ?.data |
{{products?.data?.data}} |
| 에러 응답 | .errors |
{{error.errors}} (error.data 아님) |
파서 모호성 회피
{{}} 내부에서 객체 리터럴 {}의 닫는 중괄호가 표현식 종료 }}와 충돌할 수 있습니다:
// ❌ 파서 모호성: {}}} — 어디가 표현식 끝인지 불명확
{ "value": "{{error.data?.errors ?? {}}}" }
// ✅ 안전: API 응답 구조에 맞는 정확한 경로 사용
{ "value": "{{error.errors}}" }
상세: data-binding.md
인증/토큰 프론트엔드 보안
인증 원칙
| 원칙 | 설명 |
|---|---|
| 토큰 전용 | Bearer 토큰만 사용 (세션 쿠키 의존 금지) |
| 자동 갱신 | 401 응답 시 AuthManager가 토큰 갱신 시도 |
| 실패 시 정리 | 갱신 실패 → 로그아웃 + 토큰 삭제 + 상태 초기화 + 로그인 페이지 리다이렉트 |
401 응답 처리 흐름
API 요청 → 401 응답
↓
AuthManager.refreshToken()
├── 성공 → 원래 요청 재시도
└── 실패 → logout()
├── 토큰 삭제
├── _global 인증 상태 초기화
└── 로그인 페이지 리다이렉트
로그아웃 시 클라이언트 필수 정리
- 토큰 삭제 (Authorization 헤더 제거)
- AuthManager 상태 초기화
- 서버의 세션 쿠키 자동 만료 (서버가 401 응답 시
Set-Cookie만료 헤더 전송)
상세: auth-system.md, 백엔드 인증
상태 관리 및 데이터 노출 보안
_global 상태 민감 정보 금지
_global 상태는 브라우저 DevTools(React DevTools, 콘솔)로 누구나 조회 가능합니다.
❌ _global에 저장 금지: API 토큰, 비밀번호, 개인정보, 내부 시스템 경로
✅ _global 적합 대상: UI 상태 (사이드바 열림, 테마), 공개 설정값, 인증 여부 (boolean)
initGlobal 서버 주입 범위 제한
initGlobal로 서버에서 주입하는 데이터는 최소한으로 제한합니다:
// ✅ 필요한 최소 데이터만 주입
{
"initGlobal": {
"siteName": "{{settings.site_name}}",
"isDebug": "{{settings.debug_mode}}"
}
}
// ❌ 민감 데이터 주입 금지
{
"initGlobal": {
"dbPassword": "{{settings.db_password}}",
"apiSecret": "{{settings.api_key}}"
}
}
window.G7Config 설정 노출 제어
서버 설정이 window.G7Config를 통해 브라우저에 불필요하게 노출되지 않도록 defaults.json의 frontend_schema 섹션에서 필드 레벨로 제어합니다.
FiltersFrontendSchema 트레이트 (app/Traits/FiltersFrontendSchema.php)
코어/모듈/플러그인 설정의 프론트엔드 노출 필터링을 담당하는 공통 트레이트:
| 메서드 | 역할 |
|---|---|
loadFrontendSchema($path) |
defaults.json에서 frontend_schema 섹션 로드 |
filterByFrontendSchema($settings, $schema) |
expose: true인 카테고리/필드만 필터링 |
필터링 규칙:
| 조건 | 동작 |
|---|---|
카테고리 expose: false |
해당 카테고리 전체 미노출 |
카테고리 expose: true + fields 미정의 |
카테고리 전체 노출 (하위 호환) |
카테고리 expose: true + fields: {} (빈 객체) |
아무 필드도 노출하지 않음 (안전 기본값) |
필드 expose: false |
해당 필드만 미노출 |
필드 sensitive: true |
해당 필드 미노출 (비밀번호, 토큰 등) |
노출 차단 카테고리
| 카테고리 | expose | 사유 |
|---|---|---|
security |
false |
로그인 시도 제한, 비밀번호 정책 등 공격자 정보 제공 위험 |
mail |
false |
SMTP password, API secret 포함 |
seo |
false |
프론트엔드 미참조 (관리자 환경설정은 API 데이터 기반) |
drivers |
false |
S3 secret, Redis password 등 인프라 자격증명 포함 |
defaults.json 설정 예시
{
"frontend_schema": {
"general": {
"expose": true,
"fields": {
"site_name": { "type": "string", "sensitive": false },
"site_url": { "type": "string", "sensitive": false, "expose": false },
"admin_email": { "type": "string", "sensitive": false, "expose": false }
}
},
"security": {
"expose": false,
"_comment": "보안 정책은 프론트엔드에서 미참조 — 노출 시 공격자에게 정보 제공 위험"
},
"mail": {
"expose": false,
"_comment": "mail 설정은 password 포함으로 프론트엔드에 노출하지 않음"
}
}
}
확장(모듈/플러그인)의 설정 노출 제어
모듈/플러그인도 각자의 defaults.json에 frontend_schema 섹션을 정의하여 동일한 패턴으로 노출을 제어합니다:
// modules/_bundled/vendor-module/config/defaults.json
{
"defaults": { },
"frontend_schema": {
"some_category": {
"expose": true,
"fields": {
"public_field": { "type": "string", "sensitive": false },
"secret_field": { "type": "string", "sensitive": true }
}
},
"internal_category": {
"expose": false
}
}
}
ModuleSettingsService와PluginSettingsService는FiltersFrontendSchema트레이트를 사용- 각 확장의
defaults.json에서frontend_schema를 로드하여filterByFrontendSchema()로 필터링 frontend_schema가 없는 플러그인은 하위 호환을 위해 기존 동작 유지
globalHeaders 민감 헤더 관리
데이터 소스의 globalHeaders에 민감 헤더를 설정할 때는 pattern으로 범위를 제한합니다:
// ✅ 특정 API 경로에만 민감 헤더 적용
{
"globalHeaders": [
{
"pattern": "/api/admin/*",
"headers": { "X-Admin-Token": "{{_global.adminToken}}" }
}
]
}
// ❌ 모든 요청에 민감 헤더 전송 (외부 요청에도 노출 위험)
{
"globalHeaders": [
{
"pattern": "*",
"headers": { "X-Secret-Key": "{{_global.secretKey}}" }
}
]
}
관련 코드
| 파일 | 역할 |
|---|---|
app/Traits/FiltersFrontendSchema.php |
공통 필터링 트레이트 |
app/Services/SettingsService.php |
코어 getFrontendSettings() |
app/Services/ModuleSettingsService.php |
모듈 getAllActiveSettings() |
app/Services/PluginSettingsService.php |
플러그인 getAllActiveSettings() |
app/Http/View/Composers/TemplateComposer.php |
Admin 뷰 설정 바인딩 |
app/Http/View/Composers/UserTemplateComposer.php |
User 뷰 설정 바인딩 |
config/settings/defaults.json |
코어 설정 스키마 정의 |
렌더링 오류 방어
백엔드에서 잘못된 데이터가 전달되더라도 프론트엔드가 완전히 깨지지 않도록 방어 로직이 필요합니다.
문제 상황
Laravel API에서 빈 객체 {}가 반환되면 React에서 다음 오류가 발생합니다:
Error #31: Objects are not valid as a React child
원인: Laravel Resource의 $this->when()이 MissingValue 객체를 반환하고, 커스텀 메서드에서는 이 객체가 필터링되지 않아 빈 객체로 JSON에 포함됨
방어 전략
| 계층 | 방어 방식 | 위치 |
|---|---|---|
| 백엔드 | 삼항 연산자 사용 | Resource 커스텀 메서드 |
| 컴포넌트 | safeRenderValue() | DataGrid 등 |
| 렌더러 | ErrorBoundary | DynamicRenderer |
1. safeRenderValue() 패턴
셀 값을 안전하게 렌더링 가능한 형태로 변환합니다:
const safeRenderValue = (value: any): React.ReactNode => {
// null, undefined는 빈 문자열로
if (value === null || value === undefined) {
return '';
}
// 원시 타입은 그대로 반환
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
// 배열인 경우 각 요소를 안전하게 변환 후 조인
if (Array.isArray(value)) {
return value.map((v) => safeRenderValue(v)).join(', ');
}
// 객체인 경우
if (typeof value === 'object') {
// React 엘리먼트는 그대로 반환
if (React.isValidElement(value)) {
return value;
}
// MissingValue나 빈 객체 처리
if (Object.keys(value).length === 0) {
return '';
}
// 일반 객체는 JSON 문자열로 변환 시도
try {
return JSON.stringify(value);
} catch {
return '[Object]';
}
}
return String(value);
};
적용 대상: 데이터를 직접 렌더링하는 모든 컴포넌트
2. ComponentErrorBoundary 패턴
컴포넌트 렌더링 중 오류가 발생해도 전체 페이지가 깨지지 않도록 합니다:
class ComponentErrorBoundary extends Component<Props, State> {
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render(): React.ReactNode {
if (this.state.hasError) {
return (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-400">
<div className="font-semibold">컴포넌트 로드 실패</div>
<div className="text-xs">
{this.props.componentName && `[${this.props.componentName}] `}
데이터를 표시할 수 없습니다.
</div>
{this.state.error && (
<details className="mt-2">
<summary>상세 정보</summary>
<div>{this.state.error.message}</div>
</details>
)}
</div>
);
}
return this.props.children;
}
}
적용 위치: DynamicRenderer.tsx에서 각 컴포넌트를 ErrorBoundary로 래핑
컴포넌트 개발 시 체크리스트
- API 데이터를 직접 렌더링하는 경우
safeRenderValue()사용 - 빈 객체
{}가 들어와도 에러가 발생하지 않는지 확인 - null, undefined 값 처리가 되어 있는지 확인
관련 백엔드 규정
백엔드에서의 원인과 해결책은 다음 문서 참조:
템플릿 에셋 보안
템플릿의 빌드된 에셋(JS, CSS, 폰트, 이미지)은 API를 통해 동적으로 제공됩니다.
보안 메커니즘
| 단계 | 검증 | 방어 대상 |
|---|---|---|
| 1 | 템플릿 활성화 여부 확인 | 비활성 템플릿 에셋 접근 차단 |
| 2 | 파일 확장자 화이트리스트 | 실행 파일(.php, .sh) 접근 차단 |
| 3 | realpath() + str_starts_with() |
디렉토리 탈출 공격 방지 (../../etc/passwd) |
| 4 | MIME 타입 명시적 설정 | 콘텐츠 스니핑 방지 |
허용 확장자 (화이트리스트)
| 카테고리 | 확장자 |
|---|---|
| 스크립트 | js, mjs, js.map |
| 스타일 | css, css.map |
| 폰트 | woff, woff2, ttf, otf, eot |
| 이미지 | png, jpg, jpeg, svg, webp, gif |
| 데이터 | json |
금지 확장자 (절대 추가 금지)
| 카테고리 | 확장자 |
|---|---|
| PHP 실행 파일 | .php, .phar |
| 실행 스크립트 | .sh, .bat, .exe |
| 설정 파일 | .env, .htaccess, .conf |
| 데이터베이스 파일 | .sql, .db |
| 압축 파일 | .zip, .tar, .gz |
의존성 검증
검증 대상
template.json의 dependencies 섹션에 정의된 모듈/플러그인
검증 시점
| 시점 | 동작 |
|---|---|
| 템플릿 활성화 시 | 의존성 설치 여부 확인 |
| 미설치 의존성 발견 | 활성화 차단 |
template.json 예시
{
"name": "sirsoft-admin_basic",
"version": "1.0.0",
"dependencies": {
"modules": ["sirsoft-core"],
"plugins": ["sirsoft-auth"]
}
}
API 의존성 검증
템플릿 활성화 API 호출 시:
dependencies.modules설치 확인dependencies.plugins설치 확인- 모두 설치된 경우에만 활성화 진행
- 미설치 항목이 있으면 오류 반환
보안 체크리스트
레이아웃 JSON 작성 시
- 모든 데이터 바인딩에 Optional Chaining + fallback 적용 (
{{value ?? ''}}) - 외부 URL이 포함되지 않았는가?
- 사용한 컴포넌트가
components.json에 등록되어 있는가? dangerouslySetInnerHTML사용이 없는가? (HTML 필요 시 HtmlContent 사용)- 파서 모호성 패턴 (
{}}})이 없는가? - 검증 메시지가 다국어(
__())로 처리되는가?
상태 관리 시
_global에 민감 정보(토큰, 비밀번호, 개인정보)를 저장하지 않았는가?initGlobal서버 주입 데이터가 최소한인가?- 새 설정 필드 추가 시
frontend_schema에expose여부를 정의했는가? sensitive: true필드(비밀번호, 토큰)가 노출되지 않는가?- globalHeaders의 민감 헤더가 pattern으로 범위 제한되었는가?
컴포넌트 개발 시
- API 데이터를 직접 렌더링할 때
safeRenderValue()사용했는가? - 빈 객체
{}, null, undefined 처리가 되어 있는가? - ErrorBoundary로 래핑되어 있는가?
인증 관련
- Bearer 토큰만 사용하는가? (세션 쿠키 의존 금지)
- 401 응답 시 토큰 갱신 및 실패 시 로그아웃이 처리되는가?
- 로그아웃 시 클라이언트 상태가 완전히 정리되는가?
코드 리뷰 시
dangerouslySetInnerHTML사용 여부- 검증 메시지 하드코딩 여부
- Custom Rule 적용 여부
- 모듈/플러그인
defaults.json에frontend_schema정의 여부
관련 문서
보안 전체
- SECURITY.md - 전체 보안 아키텍처, 공격 방어 전략
- template-security.md - 템플릿 시스템 보안 정책, 에셋 서빙 규칙
프론트엔드
- data-binding.md - 데이터 바인딩 문법 및 표현식
- auth-system.md - 프론트엔드 인증 시스템 (AuthManager)
- state-management.md - 상태 관리 규칙
- editors.md - HtmlEditor/HtmlContent 컴포넌트
- data-sources-advanced.md - globalHeaders 등 데이터 소스 고급 기능
백엔드
- authentication.md - Sanctum 토큰 인증
- validation.md - FormRequest 패턴
- api-resources.md - API 리소스 규칙
- middleware.md - 미들웨어 등록 규칙
확장 시스템
- permissions.md - 권한 시스템
- layout-json.md - 레이아웃 JSON 스키마
- data-sources.md - API 엔드포인트 규칙