diff --git a/apps/cs/app/api/csp-violation/route.ts b/apps/cs/app/api/csp-violation/route.ts new file mode 100644 index 000000000..9c4fdd702 --- /dev/null +++ b/apps/cs/app/api/csp-violation/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logCSPViolation } from '@/shared/lib/csp-utils'; + +export async function POST(request: NextRequest) { + try { + const violation = await request.json(); + + // CSP 위반 로깅 + logCSPViolation(violation); + + // 추가적인 보안 분석이나 알림 로직을 여기에 추가할 수 있습니다 + // 예: Slack 알림, 이메일 알림, 데이터베이스 저장 등 + + return NextResponse.json({ + success: true, + message: 'CSP violation logged successfully' + }); + } catch (error) { + console.error('Error processing CSP violation:', error); + return NextResponse.json( + { success: false, message: 'Failed to process CSP violation' }, + { status: 500 } + ); + } +} + +export async function GET() { + return NextResponse.json({ + message: 'CSP violation reporting endpoint is active' + }); +} \ No newline at end of file diff --git a/apps/cs/docs/csp-setup.md b/apps/cs/docs/csp-setup.md new file mode 100644 index 000000000..0bd7e5714 --- /dev/null +++ b/apps/cs/docs/csp-setup.md @@ -0,0 +1,124 @@ +# Content Security Policy (CSP) - img-src 설정 가이드 + +## 개요 +이 문서는 CS 앱에서 Content Security Policy의 img-src 지시어를 설정하고 관리하는 방법을 설명합니다. + +## 설정된 CSP 정책 + +### 기본 img-src 정책 +```javascript +img-src 'self' data: https: blob:; +``` + +### 환경별 설정 +- **개발 환경**: `img-src 'self' data: https: blob: http://localhost:*;` +- **프로덕션 환경**: `img-src 'self' data: https: blob:;` + +## 구현 방법 + +### 1. next.config.ts에서 설정 (권장) +```typescript +async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "Content-Security-Policy", + value: "img-src 'self' data: https: blob:;" + } + ] + } + ]; +} +``` + +### 2. 미들웨어에서 설정 +```typescript +function addSecurityHeaders(response: NextResponse): NextResponse { + response.headers.set("Content-Security-Policy", "img-src 'self' data: https: blob:;"); + return response; +} +``` + +## img-src 지시어 설명 + +### 허용된 소스 +- `'self'`: 같은 출처(도메인)의 이미지 허용 +- `data:`: Data URL 이미지 허용 (base64 인코딩된 이미지) +- `https:`: HTTPS 프로토콜의 모든 도메인에서 이미지 허용 +- `blob:`: Blob URL 이미지 허용 (파일 업로드 등) + +### 추가 도메인 허용 +```javascript +// 특정 도메인 허용 +img-src 'self' data: https: blob: https://images.example.com; + +// 여러 도메인 허용 +img-src 'self' data: https: blob: https://cdn.example.com https://images.example.com; +``` + +## 문제 해결 + +### 일반적인 img-src 위반 + +1. **외부 이미지 로드 실패** + - 해결: 해당 도메인을 img-src에 추가 + - 예: `https://example.com`을 img-src에 추가 + +2. **Data URL 이미지 차단** + - 해결: `data:`가 img-src에 포함되어 있는지 확인 + +3. **Blob URL 이미지 차단** + - 해결: `blob:`이 img-src에 포함되어 있는지 확인 + +### 디버깅 방법 + +1. **브라우저 개발자 도구 확인** + - Console 탭에서 CSP 위반 메시지 확인 + - Network 탭에서 차단된 이미지 확인 + +2. **CSP 위반 로그 확인** + - 서버 로그에서 CSP 위반 보고 확인 + - `/api/csp-violation` 엔드포인트 로그 확인 + +## 유틸리티 함수 사용 + +### 도메인 추가 +```typescript +import { addImageDomain } from '@/shared/config/csp'; + +// 개발 환경에 도메인 추가 +const newPolicy = addImageDomain('https://example.com', 'development'); +``` + +### 정책 검증 +```typescript +import { validateImgSrcPolicy } from '@/shared/lib/csp-utils'; + +const isValid = validateImgSrcPolicy('img-src self data: https:;'); +``` + +## 보안 권장사항 + +1. **최소 권한 원칙** + - 필요한 도메인만 허용 + - 불필요한 도메인 제거 + +2. **정기적인 정책 검토** + - 사용하지 않는 도메인 제거 + - 새로운 이미지 소스 추가 시 검토 + +3. **HTTPS 사용 권장** + - 가능한 한 HTTPS 도메인만 허용 + - HTTP는 개발 환경에서만 사용 + +## 환경 변수 + +- `DISABLE_CSP=true` - 개발 환경에서 CSP 비활성화 +- `NODE_ENV` - 환경별 정책 적용 + +## 추가 리소스 + +- [MDN CSP img-src](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/img-src) +- [CSP Evaluator](https://csp-evaluator.withgoogle.com/) \ No newline at end of file diff --git a/apps/cs/middleware.ts b/apps/cs/middleware.ts index 38d32c79c..3a79c1deb 100644 --- a/apps/cs/middleware.ts +++ b/apps/cs/middleware.ts @@ -13,6 +13,12 @@ const serverApiUrl = const linkBackendApiHost = process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST ?? "http://localhost:8080"; +// CSP 헤더 설정 함수 (img-src만) +function addSecurityHeaders(response: NextResponse): NextResponse { + response.headers.set("Content-Security-Policy", "img-src 'self' data: https: blob:;"); + return response; +} + export function middleware(req: NextRequest) { const pathname = req.nextUrl.pathname; const searchParams = req.nextUrl.searchParams; @@ -33,7 +39,8 @@ export function middleware(req: NextRequest) { logMessages.push("\n--------------------------------\n"); console.log(logMessages.join("\n")); - return NextResponse.rewrite(new URL(newUrl)); + const response = NextResponse.rewrite(new URL(newUrl)); + return addSecurityHeaders(response); } if (pathname.startsWith("/attach-service")) { @@ -49,9 +56,10 @@ export function middleware(req: NextRequest) { logMessages.push("\n--------------------------------\n"); console.log(logMessages.join("\n")); - return NextResponse.rewrite(new URL(newUrl), { + const response = NextResponse.rewrite(new URL(newUrl), { headers: requestHeaders, }); + return addSecurityHeaders(response); } if (pathname.startsWith("/api/link")) { @@ -73,18 +81,22 @@ export function middleware(req: NextRequest) { logMessages.push("\n--------------------------------\n"); console.log(logMessages.join("\n")); - return NextResponse.rewrite(new URL(newUrl), { + const response = NextResponse.rewrite(new URL(newUrl), { headers: requestHeaders, }); + return addSecurityHeaders(response); } if (pathname === "/") { - return NextResponse.redirect(new URL("/dashboard", req.url)); + const response = NextResponse.redirect(new URL("/dashboard", req.url)); + return addSecurityHeaders(response); } logMessages.push("\n--------------------------------\n"); console.log(logMessages.join("\n")); - return NextResponse.next(); + + const response = NextResponse.next(); + return addSecurityHeaders(response); } export const config = { diff --git a/apps/cs/next.config.ts b/apps/cs/next.config.ts index ad1ec7949..d75960377 100644 --- a/apps/cs/next.config.ts +++ b/apps/cs/next.config.ts @@ -5,6 +5,19 @@ const subDomain = const nextConfig: NextConfig = { output: "standalone", + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "Content-Security-Policy", + value: "img-src 'self' data: https: blob:;", + }, + ], + }, + ]; + }, async rewrites() { return { beforeFiles: [ diff --git a/apps/cs/src/pages/image/ui/image-form.tsx b/apps/cs/src/pages/image/ui/image-form.tsx index 7430a21e1..d4d213875 100644 --- a/apps/cs/src/pages/image/ui/image-form.tsx +++ b/apps/cs/src/pages/image/ui/image-form.tsx @@ -204,8 +204,19 @@ function ImageForm({ form, imgSn, ...props }: ImageFormProps) { onUpload={(e) => { const files = e.target.files; if (!files) return; - setAttachmentFileCode(""); - setFile(files[0] ?? null); + + const file = files[0]; + if (file) { + // SVG 파일 차단 + if (file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg')) { + alert('SVG 파일은 업로드할 수 없습니다. JPG, JPEG, PNG 파일만 업로드 가능합니다.'); + e.target.value = ''; // 파일 선택 초기화 + return; + } + + setAttachmentFileCode(""); + setFile(file); + } }} customPreview={ <> @@ -236,7 +247,7 @@ function ImageForm({ form, imgSn, ...props }: ImageFormProps) {