CSP 처리
This commit is contained in:
@@ -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'
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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(' ')};`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user