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
✅ 권장사항
- 최소 권한 원칙: 필요한 최소한의 권한만 부여
- 입력 검증: 모든 사용자 입력을 검증하고 새니타이징
- HTTPS 사용: 모든 통신을 암호화
- 보안 헤더: CSP, X-Frame-Options 등 설정
- 민감 데이터 암호화: 저장 및 전송 시 암호화
- 정기적인 보안 감사: 의존성 취약점 스캔
- 에러 메시지 최소화: 공격자에게 정보 노출 방지
⚠️ 피해야 할 것
- 클라이언트 검증만 의존: 서버 검증 필수
- 평문 비밀번호 저장: 반드시 해싱
- 민감 정보 로그 출력: 디버깅 시 주의
- 오래된 의존성: 정기적인 업데이트
- 하드코딩된 비밀키: 환경 변수 사용
- 과도한 권한: 필요 이상의 권한 부여
- 에러 스택 노출: 프로덕션에서 상세 에러 숨김
보안 체크리스트
// 보안 자가 진단 체크리스트
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에서 실행
로컬 실행
# 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관련 패턴
- Authentication - 사용자 인증
- Form Validation - 입력 검증
- API Integration - 안전한 API 호출
- Testing Patterns - 보안 테스트
Last updated on