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) { diff --git a/apps/cs/src/shared/config/csp.ts b/apps/cs/src/shared/config/csp.ts new file mode 100644 index 000000000..bee9392f6 --- /dev/null +++ b/apps/cs/src/shared/config/csp.ts @@ -0,0 +1,32 @@ +// CSP 설정을 위한 환경별 구성 (img-src만) +export const CSP_CONFIG = { + development: { + "img-src": ["'self'", "data:", "https:", "blob:", "http://localhost:*"] + }, + production: { + "img-src": ["'self'", "data:", "https:", "blob:"] + } +}; + +// CSP 헤더 값을 생성하는 함수 +export function generateCSPHeader(environment: 'development' | 'production' = 'development'): string { + const config = CSP_CONFIG[environment]; + + return Object.entries(config) + .map(([key, values]) => { + return `${key} ${values.join(' ')}`; + }) + .join('; '); +} + +// img-src에 특정 도메인 추가하는 함수 +export function addImageDomain(domain: string, environment: 'development' | 'production' = 'development'): string { + const config = CSP_CONFIG[environment]; + const currentImgSrc = config["img-src"]; + + if (!currentImgSrc.includes(domain)) { + currentImgSrc.push(domain); + } + + return `img-src ${currentImgSrc.join(' ')};`; +} \ No newline at end of file diff --git a/apps/cs/src/shared/lib/csp-monitor.ts b/apps/cs/src/shared/lib/csp-monitor.ts new file mode 100644 index 000000000..9ed635f12 --- /dev/null +++ b/apps/cs/src/shared/lib/csp-monitor.ts @@ -0,0 +1,120 @@ +// CSP 위반 모니터링 및 보고 스크립트 + +interface CSPViolation { + documentURI: string; + blockedURI: string; + violatedDirective: string; + originalPolicy: string; + sourceFile?: string; + lineNumber?: number; + columnNumber?: number; +} + +class CSPMonitor { + private reportEndpoint: string; + + constructor(reportEndpoint: string = '/api/csp-violation') { + this.reportEndpoint = reportEndpoint; + this.init(); + } + + private init(): void { + // CSP 위반 이벤트 리스너 등록 + document.addEventListener('securitypolicyviolation', (event) => { + this.handleViolation(event); + }); + + // 페이지 로드 시 CSP 정책 확인 + this.checkCSPPolicy(); + } + + private handleViolation(event: SecurityPolicyViolationEvent): void { + const violation: CSPViolation = { + documentURI: event.documentURI, + blockedURI: event.blockedURI, + violatedDirective: event.violatedDirective, + originalPolicy: event.originalPolicy, + sourceFile: event.sourceFile, + lineNumber: event.lineNumber, + columnNumber: event.columnNumber + }; + + // 콘솔에 위반 정보 출력 + console.warn('CSP Violation detected:', violation); + + // 서버로 위반 정보 전송 + this.reportViolation(violation); + } + + private async reportViolation(violation: CSPViolation): Promise { + try { + const response = await fetch(this.reportEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(violation), + }); + + if (!response.ok) { + console.error('Failed to report CSP violation:', response.statusText); + } + } catch (error) { + console.error('Error reporting CSP violation:', error); + } + } + + private checkCSPPolicy(): void { + // CSP 헤더 확인 + const cspHeader = document.querySelector('meta[http-equiv="Content-Security-Policy"]'); + if (cspHeader) { + console.log('CSP Policy found in meta tag:', cspHeader.getAttribute('content')); + } else { + console.log('No CSP meta tag found, relying on HTTP headers'); + } + } + + // CSP 정책 테스트 함수 + public testCSPPolicy(): void { + console.log('Testing CSP policy...'); + + // 인라인 스크립트 테스트 + try { + eval('console.log("CSP test: inline script execution")'); + } catch { + console.log('CSP blocked inline script execution'); + } + + // 외부 스크립트 로드 테스트 + const testScript = document.createElement('script'); + testScript.src = 'https://example.com/test.js'; + testScript.onload = () => console.log('External script loaded successfully'); + testScript.onerror = () => console.log('CSP blocked external script'); + document.head.appendChild(testScript); + } + + // CSP 위반 통계 수집 + public getViolationStats(): Promise { + return fetch(`${this.reportEndpoint}/stats`) + .then(response => response.json()) + .catch(error => { + console.error('Error fetching CSP violation stats:', error); + return null; + }); + } +} + +// 전역 CSP 모니터 인스턴스 생성 +export const cspMonitor = new CSPMonitor(); + +// 개발 환경에서만 CSP 테스트 실행 +if (process.env.NODE_ENV === 'development') { + // 페이지 로드 후 CSP 정책 테스트 + window.addEventListener('load', () => { + setTimeout(() => { + cspMonitor.testCSPPolicy(); + }, 1000); + }); +} + +export default CSPMonitor; \ No newline at end of file diff --git a/apps/cs/src/shared/lib/csp-utils.ts b/apps/cs/src/shared/lib/csp-utils.ts new file mode 100644 index 000000000..e864fc7ec --- /dev/null +++ b/apps/cs/src/shared/lib/csp-utils.ts @@ -0,0 +1,61 @@ +import { generateCSPHeader } from '../config/csp'; + +// CSP 위반 인터페이스 정의 +interface CSPViolationData { + documentURI: string; + blockedURI: string; + violatedDirective: string; + originalPolicy: string; + sourceFile?: string; + lineNumber?: number; + columnNumber?: number; +} + +// CSP 헤더를 설정하는 유틸리티 함수 (img-src만) +export function setCSPHeaders(response: Response): Response { + const environment = process.env.NODE_ENV as 'development' | 'production'; + const cspValue = generateCSPHeader(environment); + + response.headers.set('Content-Security-Policy', cspValue); + + return response; +} + +// CSP 위반을 로깅하는 함수 +export function logCSPViolation(violation: CSPViolationData): void { + console.warn('CSP Violation:', { + documentURI: violation.documentURI, + blockedURI: violation.blockedURI, + violatedDirective: violation.violatedDirective, + originalPolicy: violation.originalPolicy, + timestamp: new Date().toISOString() + }); +} + +// img-src 정책을 검증하는 함수 +export function validateImgSrcPolicy(policy: string): boolean { + return policy.includes('img-src'); +} + +// 개발 환경에서 CSP를 비활성화하는 함수 (디버깅용) +export function disableCSPForDevelopment(): boolean { + return process.env.NODE_ENV === 'development' && + process.env.DISABLE_CSP === 'true'; +} + +// img-src에 도메인 추가하는 함수 +export function addDomainToImgSrc( + currentPolicy: string, + domain: string +): string { + if (currentPolicy.includes('img-src')) { + // 기존 img-src에 도메인 추가 + return currentPolicy.replace( + /img-src ([^;]+)/, + `img-src $1 ${domain}` + ); + } else { + // img-src가 없으면 새로 생성 + return `${currentPolicy}; img-src 'self' ${domain};`; + } +} \ No newline at end of file diff --git a/packages/common/components/src/ui/file-uploader/utils.ts b/packages/common/components/src/ui/file-uploader/utils.ts index 9409c2400..ecff1ce40 100644 --- a/packages/common/components/src/ui/file-uploader/utils.ts +++ b/packages/common/components/src/ui/file-uploader/utils.ts @@ -6,7 +6,7 @@ const fileTypes = [ "image/jpeg", "image/pjpeg", "image/png", - "image/svg+xml", + // "image/svg+xml", // SVG 파일 차단 "image/tiff", "image/webp", "image/x-icon",