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) {
- 권장 사이즈: 1920x500
- 파일 최대 크기: 2MB
- - 확장자: jpg, jpeg, png 가능
+ - 확장자: jpg, jpeg, png 가능(SVG 파일 제외)
>
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/package.json b/package.json
index f468722e7..9d62db0b5 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"build": "turbo run build --filter=!dm",
"build:common": "turbo run build --filter=@common/*",
"dev": "turbo run dev",
+ "build:sn": "turbo run build --filter=sn",
"dev:cs": "turbo run dev --filter=cs",
"dev:sn": "turbo run dev --filter=sn",
"dev:wr": "turbo run dev --filter=wr",
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",