CSP 처리

This commit is contained in:
2025-08-06 12:41:00 +09:00
parent a618710214
commit fa56510e52
9 changed files with 413 additions and 9 deletions
+31
View File
@@ -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'
});
}
+124
View File
@@ -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/)
+17 -5
View File
@@ -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 = {
+13
View File
@@ -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: [
+14 -3
View File
@@ -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) {
<ul className="ml-10 list-disc">
<li>권장 사이즈: 1920x500</li>
<li>파일 최대 크기: 2MB</li>
<li>확장자: jpg, jpeg, png 가능</li>
<li>확장자: jpg, jpeg, png 가능(SVG 파일 제외)</li>
</ul>
</div>
</>
+32
View File
@@ -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(' ')};`;
}
+120
View File
@@ -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<void> {
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<unknown> {
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;
+61
View File
@@ -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};`;
}
}
@@ -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",