Skip to Content
PatternsSecurity Patterns

Security Patterns

애플리케이션의 보안을 강화하고 취약점을 방지하는 패턴입니다.

개요

보안은 다음과 같은 경우에 필요합니다:

  • 인증/인가: 사용자 신원 확인과 권한 관리
  • XSS 방지: Cross-Site Scripting 공격 차단
  • CSRF 방지: Cross-Site Request Forgery 공격 차단
  • SQL Injection 방지: 데이터베이스 공격 차단
  • 민감 데이터 보호: 암호화와 안전한 저장

기본 패턴

1. XSS (Cross-Site Scripting) 방지

사용자 입력을 안전하게 처리하여 XSS 공격을 방지합니다.

// src/utils/sanitize.ts import DOMPurify from "dompurify"; // HTML 새니타이징 export function sanitizeHtml(dirty: string): string { return DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"], ALLOWED_ATTR: ["href", "title", "target"], }); } // URL 검증 export function isValidUrl(url: string): boolean { try { const parsed = new URL(url); // http, https만 허용 return ["http:", "https:"].includes(parsed.protocol); } catch { return false; } } // 안전한 리다이렉트 export function safeRedirect(url: string, fallback: string = "/"): string { if (!url) return fallback; // 상대 URL만 허용 (같은 도메인) if (url.startsWith("/") && !url.startsWith("//")) { return url; } // 허용된 도메인 체크 const allowedDomains = ["example.com", "app.example.com"]; try { const parsed = new URL(url); if (allowedDomains.includes(parsed.hostname)) { return url; } } catch { // Invalid URL } return fallback; } // src/components/SafeHtmlContent.tsx interface SafeHtmlContentProps { content: string; className?: string; } export function SafeHtmlContent({ content, className }: SafeHtmlContentProps) { const sanitized = sanitizeHtml(content); return ( <div className={className} dangerouslySetInnerHTML={{ __html: sanitized }} /> ); } // src/components/SafeLink.tsx interface SafeLinkProps { href: string; children: React.ReactNode; external?: boolean; } export function SafeLink({ href, children, external = false }: SafeLinkProps) { if (!isValidUrl(href)) { console.warn("Invalid URL:", href); return <span>{children}</span>; } return ( <a href={href} target={external ? "_blank" : undefined} rel={external ? "noopener noreferrer" : undefined} > {children} </a> ); }

2. CSRF (Cross-Site Request Forgery) 방지

CSRF 토큰을 사용하여 공격을 방지합니다.

// src/utils/csrf.ts import { v4 as uuidv4 } from "uuid"; const CSRF_TOKEN_KEY = "csrf_token"; // CSRF 토큰 생성 export function generateCsrfToken(): string { const token = uuidv4(); sessionStorage.setItem(CSRF_TOKEN_KEY, token); return token; } // CSRF 토큰 가져오기 export function getCsrfToken(): string | null { return sessionStorage.getItem(CSRF_TOKEN_KEY); } // CSRF 토큰 검증 export function validateCsrfToken(token: string): boolean { const storedToken = getCsrfToken(); return storedToken !== null && storedToken === token; } // src/hooks/useCsrfToken.ts export function useCsrfToken() { const [token] = useState(() => { let csrfToken = getCsrfToken(); if (!csrfToken) { csrfToken = generateCsrfToken(); } return csrfToken; }); return token; } // src/components/CsrfProtectedForm.tsx export function CsrfProtectedForm() { const csrfToken = useCsrfToken(); const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget); try { const response = await fetch("/api/protected", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, }, body: JSON.stringify(Object.fromEntries(formData)), }); if (!response.ok) { throw new Error("Request failed"); } const data = await response.json(); console.log("Success:", data); } catch (error) { console.error("Error:", error); } }; return ( <form onSubmit={handleSubmit}> <input type="hidden" name="csrf_token" value={csrfToken} /> <input type="text" name="username" required /> <button type="submit">제출</button> </form> ); } // src/middleware/csrf.ts (서버 사이드) export async function csrfMiddleware(req: Request): Promise<Response | null> { if (["POST", "PUT", "DELETE", "PATCH"].includes(req.method)) { const token = req.headers.get("X-CSRF-Token"); const sessionToken = req.cookies.get("csrf_token"); if (!token || !sessionToken || token !== sessionToken) { return new Response("Invalid CSRF token", { status: 403 }); } } return null; // Continue }

3. 안전한 인증 토큰 관리

JWT 토큰을 안전하게 저장하고 관리합니다.

// src/utils/auth-storage.ts const ACCESS_TOKEN_KEY = "access_token"; const REFRESH_TOKEN_KEY = "refresh_token"; // 토큰 저장 (httpOnly 쿠키 권장, 예시는 localStorage) export function setTokens(accessToken: string, refreshToken: string) { // ⚠️ 프로덕션에서는 httpOnly 쿠키 사용 권장 // 여기서는 데모 목적으로 localStorage 사용 sessionStorage.setItem(ACCESS_TOKEN_KEY, accessToken); localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); } export function getAccessToken(): string | null { return sessionStorage.getItem(ACCESS_TOKEN_KEY); } export function getRefreshToken(): string | null { return localStorage.getItem(REFRESH_TOKEN_KEY); } export function clearTokens() { sessionStorage.removeItem(ACCESS_TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); } // JWT 디코딩 (검증은 서버에서!) export function decodeJwt(token: string): any { try { const base64Url = token.split(".")[1]; const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); const jsonPayload = decodeURIComponent( atob(base64) .split("") .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) .join("") ); return JSON.parse(jsonPayload); } catch { return null; } } // 토큰 만료 체크 export function isTokenExpired(token: string): boolean { const decoded = decodeJwt(token); if (!decoded || !decoded.exp) return true; const currentTime = Math.floor(Date.now() / 1000); return decoded.exp < currentTime; } // src/utils/api-client.ts export class SecureApiClient { private baseURL: string; private refreshPromise: Promise<void> | null = null; constructor(baseURL: string) { this.baseURL = baseURL; } async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> { const accessToken = getAccessToken(); // 토큰 만료 체크 if (accessToken && isTokenExpired(accessToken)) { await this.refreshAccessToken(); } const response = await fetch(`${this.baseURL}${endpoint}`, { ...options, headers: { ...options.headers, Authorization: `Bearer ${getAccessToken()}`, }, }); // 401 에러 시 토큰 갱신 시도 if (response.status === 401) { await this.refreshAccessToken(); // 재시도 const retryResponse = await fetch(`${this.baseURL}${endpoint}`, { ...options, headers: { ...options.headers, Authorization: `Bearer ${getAccessToken()}`, }, }); if (!retryResponse.ok) { throw new Error("Request failed after token refresh"); } return retryResponse.json(); } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response.json(); } private async refreshAccessToken(): Promise<void> { // 동시에 여러 요청이 들어올 경우 한 번만 갱신 if (this.refreshPromise) { return this.refreshPromise; } this.refreshPromise = (async () => { try { const refreshToken = getRefreshToken(); if (!refreshToken) { throw new Error("No refresh token"); } const response = await fetch(`${this.baseURL}/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); if (!response.ok) { throw new Error("Token refresh failed"); } const { accessToken, refreshToken: newRefreshToken } = await response.json(); setTokens(accessToken, newRefreshToken); } catch (error) { clearTokens(); window.location.href = "/login"; throw error; } finally { this.refreshPromise = null; } })(); return this.refreshPromise; } }

고급 패턴

1. Content Security Policy (CSP)

CSP 헤더를 설정하여 XSS 공격을 차단합니다.

// next.config.js (Next.js) const cspHeader = ` default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://images.example.com; font-src 'self' https://fonts.googleapis.com; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests; `; module.exports = { async headers() { return [ { source: "/(.*)", headers: [ { key: "Content-Security-Policy", value: cspHeader.replace(/\n/g, ""), }, { key: "X-Frame-Options", value: "DENY", }, { key: "X-Content-Type-Options", value: "nosniff", }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin", }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()", }, ], }, ]; }, }; // src/utils/csp-nonce.ts (서버 컴포넌트) import { headers } from "next/headers"; import crypto from "crypto"; export function generateNonce(): string { return crypto.randomBytes(16).toString("base64"); } // app/layout.tsx export default function RootLayout({ children, }: { children: React.ReactNode; }) { const nonce = generateNonce(); return ( <html lang="ko"> <head> <meta httpEquiv="Content-Security-Policy" content={`script-src 'nonce-${nonce}' 'strict-dynamic';`} /> </head> <body> {children} <script nonce={nonce}>{`console.log('Allowed script')`}</script> </body> </html> ); }

2. Rate Limiting으로 Brute Force 공격 방지

요청 빈도를 제한하여 무차별 대입 공격을 방지합니다.

// src/utils/rate-limiter.ts interface RateLimitConfig { windowMs: number; // 시간 윈도우 (밀리초) maxRequests: number; // 최대 요청 수 } class RateLimiter { private requests: Map<string, number[]> = new Map(); constructor(private config: RateLimitConfig) {} isAllowed(identifier: string): boolean { const now = Date.now(); const windowStart = now - this.config.windowMs; // 기존 요청 기록 가져오기 let timestamps = this.requests.get(identifier) || []; // 시간 윈도우 밖의 요청 제거 timestamps = timestamps.filter((ts) => ts > windowStart); // 제한 체크 if (timestamps.length >= this.config.maxRequests) { return false; } // 새 요청 기록 timestamps.push(now); this.requests.set(identifier, timestamps); return true; } getRemainingRequests(identifier: string): number { const now = Date.now(); const windowStart = now - this.config.windowMs; const timestamps = this.requests.get(identifier) || []; const validTimestamps = timestamps.filter((ts) => ts > windowStart); return Math.max(0, this.config.maxRequests - validTimestamps.length); } getResetTime(identifier: string): number { const timestamps = this.requests.get(identifier) || []; if (timestamps.length === 0) return 0; const oldestTimestamp = Math.min(...timestamps); return oldestTimestamp + this.config.windowMs; } reset(identifier: string) { this.requests.delete(identifier); } } // src/middleware/rate-limit.ts const loginLimiter = new RateLimiter({ windowMs: 15 * 60 * 1000, // 15분 maxRequests: 5, // 최대 5회 시도 }); export async function rateLimitMiddleware( req: Request, identifier: string ): Promise<Response | null> { if (!loginLimiter.isAllowed(identifier)) { const resetTime = loginLimiter.getResetTime(identifier); const waitTime = Math.ceil((resetTime - Date.now()) / 1000); return new Response( JSON.stringify({ error: "Too many requests", message: `너무 많은 요청이 발생했습니다. ${waitTime}초 후에 다시 시도해주세요.`, retryAfter: waitTime, }), { status: 429, headers: { "Content-Type": "application/json", "Retry-After": waitTime.toString(), }, } ); } return null; } // src/app/api/login/route.ts export async function POST(req: Request) { const { email, password } = await req.json(); // IP 기반 rate limiting const ip = req.headers.get("x-forwarded-for") || "unknown"; const rateLimitResponse = await rateLimitMiddleware(req, ip); if (rateLimitResponse) return rateLimitResponse; // 이메일 기반 rate limiting (추가 보호) const emailRateLimitResponse = await rateLimitMiddleware(req, email); if (emailRateLimitResponse) return emailRateLimitResponse; // 로그인 로직 const user = await authenticateUser(email, password); if (!user) { return Response.json({ error: "Invalid credentials" }, { status: 401 }); } // 성공 시 rate limit 초기화 loginLimiter.reset(ip); loginLimiter.reset(email); return Response.json({ user, token: generateToken(user) }); }

3. 입력 검증과 타입 안전성

Zod를 사용하여 런타임 타입 검증을 수행합니다.

// src/schemas/user.schema.ts import { z } from "zod"; // 비밀번호 복잡도 검증 const passwordSchema = z .string() .min(8, "비밀번호는 최소 8자 이상이어야 합니다") .regex(/[A-Z]/, "대문자를 최소 1개 포함해야 합니다") .regex(/[a-z]/, "소문자를 최소 1개 포함해야 합니다") .regex(/[0-9]/, "숫자를 최소 1개 포함해야 합니다") .regex(/[^A-Za-z0-9]/, "특수문자를 최소 1개 포함해야 합니다"); // 회원가입 스키마 export const signupSchema = z .object({ email: z .string() .min(1, "이메일을 입력해주세요") .email("올바른 이메일 형식이 아닙니다") .transform((email) => email.toLowerCase()), password: passwordSchema, confirmPassword: z.string(), name: z .string() .min(2, "이름은 최소 2자 이상이어야 합니다") .max(50, "이름은 최대 50자까지 입력 가능합니다") .regex(/^[가-힣a-zA-Z\s]+$/, "이름은 한글 또는 영문만 입력 가능합니다"), phone: z .string() .regex(/^01[0-9]-[0-9]{3,4}-[0-9]{4}$/, "올바른 전화번호 형식이 아닙니다") .optional(), agreeToTerms: z.literal(true, { errorMap: () => ({ message: "이용약관에 동의해주세요" }), }), }) .refine((data) => data.password === data.confirmPassword, { message: "비밀번호가 일치하지 않습니다", path: ["confirmPassword"], }); export type SignupFormData = z.infer<typeof signupSchema>; // 로그인 스키마 export const loginSchema = z.object({ email: z.string().email("올바른 이메일 형식이 아닙니다"), password: z.string().min(1, "비밀번호를 입력해주세요"), }); export type LoginFormData = z.infer<typeof loginSchema>; // 상품 생성 스키마 export const productSchema = z.object({ name: z.string().min(1).max(100), description: z.string().max(1000), price: z.number().positive().int(), category: z.enum(["electronics", "clothing", "food", "books"]), stock: z.number().nonnegative().int(), images: z.array(z.string().url()).max(5), tags: z.array(z.string()).max(10).optional(), }); export type Product = z.infer<typeof productSchema>; // API 응답 검증 export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) => z.object({ success: z.boolean(), data: dataSchema.optional(), error: z .object({ message: z.string(), code: z.string().optional(), }) .optional(), }); // 사용 예시 // src/app/api/signup/route.ts export async function POST(req: Request) { try { const body = await req.json(); // 입력 검증 const validatedData = signupSchema.parse(body); // 데이터베이스 작업 const user = await createUser(validatedData); return Response.json({ success: true, data: { id: user.id, email: user.email }, }); } catch (error) { if (error instanceof z.ZodError) { return Response.json( { success: false, error: { message: "입력값이 올바르지 않습니다", issues: error.issues, }, }, { status: 400 } ); } return Response.json( { success: false, error: { message: "서버 오류가 발생했습니다" }, }, { status: 500 } ); } }

4. 민감 데이터 암호화

클라이언트에서 민감 데이터를 암호화합니다.

// src/utils/encryption.ts import CryptoJS from "crypto-js"; const ENCRYPTION_KEY = process.env.NEXT_PUBLIC_ENCRYPTION_KEY || "default-key"; // AES 암호화 export function encrypt(data: string): string { return CryptoJS.AES.encrypt(data, ENCRYPTION_KEY).toString(); } // AES 복호화 export function decrypt(encryptedData: string): string { const bytes = CryptoJS.AES.decrypt(encryptedData, ENCRYPTION_KEY); return bytes.toString(CryptoJS.enc.Utf8); } // 해시 생성 (비밀번호는 서버에서 bcrypt 사용 권장) export function hash(data: string): string { return CryptoJS.SHA256(data).toString(); } // 보안 랜덤 문자열 생성 export function generateSecureRandom(length: number = 32): string { const array = new Uint8Array(length); crypto.getRandomValues(array); return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( "" ); } // src/utils/secure-storage.ts export class SecureStorage { // 암호화하여 저장 static setItem(key: string, value: string) { const encrypted = encrypt(value); localStorage.setItem(key, encrypted); } // 복호화하여 가져오기 static getItem(key: string): string | null { const encrypted = localStorage.getItem(key); if (!encrypted) return null; try { return decrypt(encrypted); } catch { return null; } } // 삭제 static removeItem(key: string) { localStorage.removeItem(key); } // 객체 저장 static setObject<T>(key: string, value: T) { const json = JSON.stringify(value); this.setItem(key, json); } // 객체 가져오기 static getObject<T>(key: string): T | null { const json = this.getItem(key); if (!json) return null; try { return JSON.parse(json) as T; } catch { return null; } } } // 사용 예시 SecureStorage.setItem("sensitive-data", "secret-value"); const value = SecureStorage.getItem("sensitive-data");

Best Practices

✅ 권장사항

  1. 최소 권한 원칙: 필요한 최소한의 권한만 부여
  2. 입력 검증: 모든 사용자 입력을 검증하고 새니타이징
  3. HTTPS 사용: 모든 통신을 암호화
  4. 보안 헤더: CSP, X-Frame-Options 등 설정
  5. 민감 데이터 암호화: 저장 및 전송 시 암호화
  6. 정기적인 보안 감사: 의존성 취약점 스캔
  7. 에러 메시지 최소화: 공격자에게 정보 노출 방지

⚠️ 피해야 할 것

  1. 클라이언트 검증만 의존: 서버 검증 필수
  2. 평문 비밀번호 저장: 반드시 해싱
  3. 민감 정보 로그 출력: 디버깅 시 주의
  4. 오래된 의존성: 정기적인 업데이트
  5. 하드코딩된 비밀키: 환경 변수 사용
  6. 과도한 권한: 필요 이상의 권한 부여
  7. 에러 스택 노출: 프로덕션에서 상세 에러 숨김

보안 체크리스트

// 보안 자가 진단 체크리스트 const securityChecklist = { authentication: [ "JWT 토큰을 httpOnly 쿠키에 저장", "비밀번호 복잡도 요구사항 적용", "Rate limiting으로 brute force 방지", "2FA (이중 인증) 지원", "비밀번호 재설정 프로세스 보안", ], authorization: [ "RBAC (역할 기반 접근 제어) 구현", "API 엔드포인트마다 권한 체크", "프론트엔드와 백엔드 모두 권한 검증", "최소 권한 원칙 적용", ], dataProtection: [ "HTTPS 사용", "민감 데이터 암호화", "SQL Injection 방지 (Prepared Statements)", "XSS 방지 (입력 새니타이징)", "CSRF 토큰 사용", ], infrastructure: [ "CSP 헤더 설정", "보안 헤더 설정 (X-Frame-Options 등)", "의존성 취약점 정기 스캔", "환경 변수로 비밀키 관리", "로그에 민감 정보 제외", ], };

제품별 보안 전략

Foundation (기본 프레임워크)

// Foundation용 보안 유틸리티 export const SecurityUtils = { sanitize: sanitizeHtml, validateUrl: isValidUrl, safeRedirect, encrypt, decrypt, generateNonce, isTokenExpired, };

전체 코드 보기: Foundation Security Utils

iCignal (분석 플랫폼)

// iCignal용 데이터 접근 제어 export function useDataAccess(datasetId: string) { const { user } = useAuth(); const canView = useMemo(() => { return ( user.permissions.includes("view:analytics") && user.datasets.includes(datasetId) ); }, [user, datasetId]); return { canView }; }

전체 코드 보기: iCignal Data Access Control

Cals (예약 시스템)

// Cals용 예약 권한 관리 export function useBookingPermissions(bookingId: string) { const { user } = useAuth(); const { data: booking } = useQuery(["booking", bookingId], () => fetchBooking(bookingId) ); const canEdit = booking?.userId === user.id || user.role === "admin"; const canCancel = booking?.status === "confirmed" && canEdit; return { canEdit, canCancel }; }

전체 코드 보기: Cals Booking Permissions


테스트 및 실행

CodeSandbox에서 실행

Security Patterns 예제 실행하기 

로컬 실행

# 1. 의존성 취약점 스캔 pnpm audit # 2. 의존성 업데이트 pnpm update # 3. 보안 테스트 pnpm test:security # 4. OWASP ZAP으로 보안 스캔 docker run -t owasp/zap2docker-stable zap-baseline.py -t http://localhost:3000

관련 패턴

Last updated on