← 블로그 홈

Django & Django Ninja 보안 완전 가이드: 취약점 분석과 대응 방안

웹 애플리케이션 보안은 개발자가 반드시 고려해야 할 핵심 요소입니다. Django는 기본적으로 많은 보안 기능을 제공하지만, Django Ninja와 함께 사용할 때 추가적인 보안 고려사항들이 있습니다. 이 글에서는 Django와 Django Ninja의 주요...

웹 애플리케이션 보안은 개발자가 반드시 고려해야 할 핵심 요소입니다. Django는 기본적으로 많은 보안 기능을 제공하지만, Django Ninja와 함께 사용할 때 추가적인 보안 고려사항들이 있습니다. 이 글에서는 Django와 Django Ninja의 주요 보안 취약점과 대응 방안을 상세히 다루겠습니다.

🔒 Django 기본 보안 기능

Django는 OWASP Top 10 취약점 대부분을 기본적으로 방어합니다:

1. CSRF (Cross-Site Request Forgery) 보호

Django의 기본 CSRF 보호:

# settings.py
MIDDLEWARE = [
    'django.middleware.csrf.CsrfViewMiddleware',  # CSRF 보호 활성화
    # ... 다른 미들웨어들
]

# 템플릿에서 CSRF 토큰 사용
{% raw %}{% csrf_token %}{% endraw %}

Django Ninja에서의 CSRF 처리:

# api.py
from ninja import NinjaAPI
from django.middleware.csrf import get_token
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator

api = NinjaAPI()

# CSRF 토큰 제공 엔드포인트
@api.get("/csrf-token")
def get_csrf_token(request):
    return {"csrfToken": get_token(request)}

# CSRF 보호가 필요한 API
@api.post("/protected-endpoint")
def protected_endpoint(request, data: dict):
    # CSRF 토큰은 자동으로 검증됨
    return {"message": "성공"}

# CSRF 보호 비활성화 (주의해서 사용)
@method_decorator(csrf_exempt, name='dispatch')
class UnsafeAPI:
    @api.post("/unsafe-endpoint")
    def unsafe_endpoint(self, request):
        return {"message": "CSRF 보호 없음"}

2. XSS (Cross-Site Scripting) 보호

Django 템플릿 자동 이스케이프:

# views.py
def user_profile(request):
    user_input = request.GET.get('comment', '')
    return render(request, 'profile.html', {
        'comment': user_input  # 자동으로 HTML 이스케이프됨
    })

Django Ninja에서 XSS 방지:

# schemas.py
from ninja import Schema
from pydantic import validator
import html
import re

class CommentSchema(Schema):
    content: str
    author: str
    
    @validator('content')
    def sanitize_content(cls, v):
        # HTML 태그 제거
        clean_content = re.sub(r'<[^>]+>', '', v)
        # HTML 엔티티 이스케이프
        return html.escape(clean_content)
    
    @validator('author')
    def sanitize_author(cls, v):
        return html.escape(v.strip())

# api.py
@api.post("/comments")
def create_comment(request, comment: CommentSchema):
    # 이미 sanitize된 데이터
    return {"message": "댓글이 안전하게 생성되었습니다"}

3. SQL Injection 방지

Django ORM 사용 시 자동 방지:

# 안전한 방식 (Django ORM)
def get_user_posts(request, user_id: int):
    posts = Post.objects.filter(author_id=user_id)  # 파라미터 바인딩으로 안전
    return posts

# 위험한 방식 (Raw SQL - 피해야 함)
def unsafe_get_posts(request, user_id: str):
    # SQL Injection 취약점 존재
    query = f"SELECT * FROM posts WHERE author_id = {user_id}"
    return Post.objects.raw(query)

# 안전한 Raw SQL 사용법
def safe_raw_query(request, user_id: int):
    query = "SELECT * FROM posts WHERE author_id = %s"
    return Post.objects.raw(query, [user_id])

🛡️ Django Ninja 특화 보안 구현

1. JWT 인증 시스템 보안

# authentication.py
from ninja.security import HttpBearer
from django.contrib.auth.models import User
from django.http import HttpRequest
import jwt
from datetime import datetime, timedelta
import secrets
import logging

logger = logging.getLogger('security')

class SecureJWTAuth(HttpBearer):
    def __init__(self):
        self.algorithm = 'HS256'
        self.access_token_expire = timedelta(minutes=15)
        self.refresh_token_expire = timedelta(days=7)
    
    def authenticate(self, request: HttpRequest, token: str):
        try:
            # 토큰 디코드
            payload = jwt.decode(
                token, 
                settings.SECRET_KEY, 
                algorithms=[self.algorithm]
            )
            
            # 토큰 만료 확인
            if datetime.utcnow().timestamp() > payload.get('exp', 0):
                logger.warning(f"Expired token from IP: {self.get_client_ip(request)}")
                return None
            
            # 사용자 존재 확인
            user = User.objects.get(id=payload['user_id'])
            
            # 토큰 블랙리스트 확인 (Redis 캐시 사용)
            if self.is_token_blacklisted(token):
                logger.warning(f"Blacklisted token usage from user: {user.username}")
                return None
                
            return user
            
        except jwt.ExpiredSignatureError:
            logger.warning("Expired JWT token")
            return None
        except jwt.InvalidTokenError:
            logger.warning("Invalid JWT token")
            return None
        except User.DoesNotExist:
            logger.warning("User not found for JWT token")
            return None
        except Exception as e:
            logger.error(f"JWT authentication error: {str(e)}")
            return None
    
    def generate_tokens(self, user):
        """액세스 토큰과 리프레시 토큰 생성"""
        now = datetime.utcnow()
        
        # 액세스 토큰
        access_payload = {
            'user_id': user.id,
            'username': user.username,
            'exp': now + self.access_token_expire,
            'iat': now,
            'type': 'access',
            'jti': secrets.token_urlsafe(16)  # JWT ID for tracking
        }
        
        # 리프레시 토큰
        refresh_payload = {
            'user_id': user.id,
            'exp': now + self.refresh_token_expire,
            'iat': now,
            'type': 'refresh',
            'jti': secrets.token_urlsafe(16)
        }
        
        access_token = jwt.encode(access_payload, settings.SECRET_KEY, algorithm=self.algorithm)
        refresh_token = jwt.encode(refresh_payload, settings.SECRET_KEY, algorithm=self.algorithm)
        
        return {
            'access_token': access_token,
            'refresh_token': refresh_token,
            'token_type': 'bearer',
            'expires_in': self.access_token_expire.total_seconds()
        }
    
    def get_client_ip(self, request):
        """클라이언트 IP 주소 추출"""
        x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
        if x_forwarded_for:
            ip = x_forwarded_for.split(',')[0]
        else:
            ip = request.META.get('REMOTE_ADDR')
        return ip
    
    def is_token_blacklisted(self, token):
        """토큰 블랙리스트 확인 (Redis 사용)"""
        from django.core.cache import cache
        return cache.get(f"blacklist_{token}") is not None
    
    def blacklist_token(self, token):
        """토큰을 블랙리스트에 추가"""
        from django.core.cache import cache
        # 토큰 만료 시간까지 블랙리스트에 유지
        cache.set(f"blacklist_{token}", True, timeout=86400)  # 24시간

auth = SecureJWTAuth()

2. API 레이트 리미팅

# rate_limiting.py
from ninja import NinjaAPI
from django.core.cache import cache
from django.http import JsonResponse
import time
from functools import wraps

class RateLimiter:
    def __init__(self, max_requests=100, window_seconds=3600):
        self.max_requests = max_requests
        self.window_seconds = window_seconds
    
    def __call__(self, func):
        @wraps(func)
        def wrapper(request, *args, **kwargs):
            # 클라이언트 식별
            client_id = self.get_client_identifier(request)
            
            # 현재 시간 윈도우
            current_window = int(time.time() // self.window_seconds)
            cache_key = f"rate_limit_{client_id}_{current_window}"
            
            # 현재 요청 수 확인
            current_requests = cache.get(cache_key, 0)
            
            if current_requests >= self.max_requests:
                return JsonResponse({
                    'error': 'Rate limit exceeded',
                    'retry_after': self.window_seconds
                }, status=429)
            
            # 요청 수 증가
            cache.set(cache_key, current_requests + 1, self.window_seconds)
            
            return func(request, *args, **kwargs)
        return wrapper
    
    def get_client_identifier(self, request):
        """클라이언트 식별자 생성"""
        # 인증된 사용자의 경우 사용자 ID 사용
        if hasattr(request, 'auth') and request.auth:
            return f"user_{request.auth.id}"
        
        # 비인증 사용자의 경우 IP 주소 사용
        x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
        if x_forwarded_for:
            ip = x_forwarded_for.split(',')[0]
        else:
            ip = request.META.get('REMOTE_ADDR')
        return f"ip_{ip}"

# 사용 예시
rate_limit = RateLimiter(max_requests=50, window_seconds=3600)  # 시간당 50개 요청

@api.get("/public-data")
@rate_limit
def get_public_data(request):
    return {"data": "공개 데이터"}

# 더 엄격한 제한
strict_rate_limit = RateLimiter(max_requests=5, window_seconds=60)  # 분당 5개 요청

@api.post("/sensitive-action")
@strict_rate_limit
def sensitive_action(request):
    return {"message": "민감한 작업 완료"}

3. 입력 데이터 검증 및 Sanitization

# validation.py
from ninja import Schema
from pydantic import validator, Field
import re
import bleach
from typing import Optional

class SecureUserSchema(Schema):
    username: str = Field(..., min_length=3, max_length=30)
    email: str = Field(..., regex=r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
    password: str = Field(..., min_length=8)
    bio: Optional[str] = Field(None, max_length=500)
    
    @validator('username')
    def validate_username(cls, v):
        # 영숫자와 언더스코어만 허용
        if not re.match(r'^[a-zA-Z0-9_]+$', v):
            raise ValueError('사용자명은 영숫자와 언더스코어만 사용 가능합니다')
        return v.lower()
    
    @validator('password')
    def validate_password(cls, v):
        # 비밀번호 복잡성 검증
        if not re.search(r'[A-Z]', v):
            raise ValueError('대문자가 포함되어야 합니다')
        if not re.search(r'[a-z]', v):
            raise ValueError('소문자가 포함되어야 합니다')
        if not re.search(r'[0-9]', v):
            raise ValueError('숫자가 포함되어야 합니다')
        if not re.search(r'[!@#$%^&*(),.?":{}|<>]', v):
            raise ValueError('특수문자가 포함되어야 합니다')
        return v
    
    @validator('bio')
    def sanitize_bio(cls, v):
        if v is None:
            return v
        
        # HTML 태그 제거 (일부 안전한 태그는 허용)
        allowed_tags = ['b', 'i', 'u', 'em', 'strong']
        cleaned = bleach.clean(v, tags=allowed_tags, strip=True)
        return cleaned

class SecurePostSchema(Schema):
    title: str = Field(..., min_length=1, max_length=200)
    content: str = Field(..., min_length=1, max_length=10000)
    tags: list[str] = Field(default=[], max_items=10)
    
    @validator('title', 'content')
    def prevent_xss(cls, v):
        # XSS 공격 패턴 탐지
        xss_patterns = [
            r'<script[^>]*>.*?</script>',
            r'javascript:',
            r'on\w+\s*=',
            r'<iframe[^>]*>.*?</iframe>',
        ]
        
        for pattern in xss_patterns:
            if re.search(pattern, v, re.IGNORECASE):
                raise ValueError('잠재적으로 위험한 콘텐츠가 감지되었습니다')
        
        return bleach.clean(v, strip=True)
    
    @validator('tags')
    def validate_tags(cls, v):
        # 태그 길이 및 형식 검증
        cleaned_tags = []
        for tag in v:
            if len(tag) > 20:
                raise ValueError('태그는 20자를 초과할 수 없습니다')
            if not re.match(r'^[a-zA-Z0-9가-힣_-]+$', tag):
                raise ValueError('태그에 특수문자는 사용할 수 없습니다')
            cleaned_tags.append(tag.strip().lower())
        
        return list(set(cleaned_tags))  # 중복 제거

4. API 권한 관리

# permissions.py
from ninja import NinjaAPI
from django.http import JsonResponse
from functools import wraps
import logging

logger = logging.getLogger('security')

def require_permission(permission_name):
    """권한 기반 접근 제어 데코레이터"""
    def decorator(func):
        @wraps(func)
        def wrapper(request, *args, **kwargs):
            if not request.user.is_authenticated:
                logger.warning(f"Unauthenticated access attempt to {func.__name__}")
                return JsonResponse({'error': '인증이 필요합니다'}, status=401)
            
            if not request.user.has_perm(permission_name):
                logger.warning(f"Permission denied for user {request.user.username} on {func.__name__}")
                return JsonResponse({'error': '권한이 없습니다'}, status=403)
            
            return func(request, *args, **kwargs)
        return wrapper
    return decorator

def require_ownership_or_admin(model_class, param_name='id'):
    """객체 소유권 또는 관리자 권한 확인"""
    def decorator(func):
        @wraps(func)
        def wrapper(request, *args, **kwargs):
            if not request.user.is_authenticated:
                return JsonResponse({'error': '인증이 필요합니다'}, status=401)
            
            # 객체 ID 추출
            obj_id = kwargs.get(param_name)
            if not obj_id:
                return JsonResponse({'error': '잘못된 요청입니다'}, status=400)
            
            try:
                obj = model_class.objects.get(id=obj_id)
            except model_class.DoesNotExist:
                return JsonResponse({'error': '객체를 찾을 수 없습니다'}, status=404)
            
            # 소유권 또는 관리자 권한 확인
            if not (request.user.is_staff or 
                   (hasattr(obj, 'owner') and obj.owner == request.user) or
                   (hasattr(obj, 'author') and obj.author == request.user)):
                logger.warning(f"Ownership violation: user {request.user.username} tried to access {model_class.__name__} {obj_id}")
                return JsonResponse({'error': '접근 권한이 없습니다'}, status=403)
            
            return func(request, *args, **kwargs)
        return wrapper
    return decorator

# 사용 예시
from .models import Post

@api.get("/admin/users")
@require_permission('auth.view_user')
def list_users(request):
    return {"users": "사용자 목록"}

@api.delete("/posts/{int:post_id}")
@require_ownership_or_admin(Post, 'post_id')
def delete_post(request, post_id: int):
    Post.objects.filter(id=post_id).delete()
    return {"message": "게시글이 삭제되었습니다"}

🚨 주요 보안 취약점과 대응 방안

1. 인증 우회 공격

취약점:

# 위험한 코드
@api.get("/user/{int:user_id}/profile")
def get_user_profile(request, user_id: int):
    # 인증 확인 없이 모든 사용자 정보 반환
    user = User.objects.get(id=user_id)
    return {"username": user.username, "email": user.email}

보안 강화:

# 안전한 코드
@api.get("/user/{int:user_id}/profile", auth=auth)
def get_user_profile(request, user_id: int):
    # 자신의 정보만 조회 가능하도록 제한
    if request.user.id != user_id and not request.user.is_staff:
        return JsonResponse({'error': '권한이 없습니다'}, status=403)
    
    user = User.objects.get(id=user_id)
    return {
        "username": user.username,
        "email": user.email if request.user.id == user_id else None
    }

2. 민감한 정보 노출

취약점:

# 위험한 코드 - 모든 정보 반환
@api.get("/users", response=List[UserSchema])
def list_users(request):
    return User.objects.all()

class UserSchema(ModelSchema):
    class Config:
        model = User
        model_fields = "__all__"  # 비밀번호 등 민감한 정보 포함

보안 강화:

# 안전한 코드 - 필요한 정보만 반환
class PublicUserSchema(ModelSchema):
    class Config:
        model = User
        model_fields = ["id", "username", "date_joined"]  # 공개 정보만

class PrivateUserSchema(ModelSchema):
    class Config:
        model = User
        model_fields = ["id", "username", "email", "first_name", "last_name"]
        # 비밀번호, 토큰 등 민감한 정보 제외

@api.get("/users", response=List[PublicUserSchema])
def list_users(request):
    return User.objects.filter(is_active=True)

@api.get("/me", auth=auth, response=PrivateUserSchema)
def get_my_profile(request):
    return request.user

3. 파일 업로드 취약점

# file_upload.py
import os
import magic
from django.conf import settings
from django.core.files.storage import default_storage
from ninja import File, UploadedFile

class SecureFileUploader:
    ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.pdf', '.txt', '.docx'}
    ALLOWED_MIME_TYPES = {
        'image/jpeg', 'image/png', 'image/gif',
        'application/pdf', 'text/plain',
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
    }
    MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB
    
    @staticmethod
    def validate_file(file: UploadedFile):
        """파일 보안 검증"""
        # 파일 크기 확인
        if file.size > SecureFileUploader.MAX_FILE_SIZE:
            raise ValueError('파일 크기가 너무 큽니다 (최대 10MB)')
        
        # 파일 확장자 확인
        file_ext = os.path.splitext(file.name)[1].lower()
        if file_ext not in SecureFileUploader.ALLOWED_EXTENSIONS:
            raise ValueError(f'허용되지 않는 파일 형식입니다: {file_ext}')
        
        # MIME 타입 확인 (실제 파일 내용 기반)
        file_content = file.read()
        file.seek(0)  # 파일 포인터 리셋
        
        mime_type = magic.from_buffer(file_content, mime=True)
        if mime_type not in SecureFileUploader.ALLOWED_MIME_TYPES:
            raise ValueError(f'허용되지 않는 MIME 타입입니다: {mime_type}')
        
        # 파일명 sanitization
        safe_filename = SecureFileUploader.sanitize_filename(file.name)
        return safe_filename
    
    @staticmethod
    def sanitize_filename(filename):
        """파일명 안전하게 변경"""
        import re
        import uuid
        
        # 위험한 문자 제거
        safe_name = re.sub(r'[^\w\s.-]', '', filename)
        safe_name = re.sub(r'[-\s]+', '-', safe_name)
        
        # 고유한 파일명 생성
        name, ext = os.path.splitext(safe_name)
        unique_name = f"{name}_{uuid.uuid4().hex[:8]}{ext}"
        
        return unique_name

@api.post("/upload")
def upload_file(request, file: UploadedFile = File(...)):
    try:
        # 파일 보안 검증
        safe_filename = SecureFileUploader.validate_file(file)
        
        # 안전한 경로에 저장
        file_path = default_storage.save(
            f"uploads/{safe_filename}",
            file
        )
        
        return {
            "message": "파일이 안전하게 업로드되었습니다",
            "filename": safe_filename,
            "path": file_path
        }
    
    except ValueError as e:
        return JsonResponse({"error": str(e)}, status=400)
    except Exception as e:
        logger.error(f"File upload error: {str(e)}")
        return JsonResponse({"error": "파일 업로드 중 오류가 발생했습니다"}, status=500)

4. API 버전 관리와 보안

# versioning.py
from ninja import NinjaAPI

# API 버전별 보안 정책
api_v1 = NinjaAPI(version="1.0", url_namespace="v1")
api_v2 = NinjaAPI(version="2.0", url_namespace="v2")

# V1 - 레거시 (더 엄격한 보안)
@api_v1.get("/users", auth=auth)
@rate_limit
def list_users_v1(request):
    # 인증된 관리자만 접근 가능
    if not request.user.is_staff:
        return JsonResponse({"error": "관리자 권한 필요"}, status=403)
    return {"users": "v1 사용자 목록"}

# V2 - 최신 (개선된 보안과 기능)
@api_v2.get("/users", auth=auth)
@require_permission('auth.view_user')
def list_users_v2(request):
    # 권한 기반 접근 제어
    return {"users": "v2 사용자 목록", "features": ["pagination", "filtering"]}

# API 사용 중단 경고
@api_v1.get("/deprecated-endpoint")
def deprecated_endpoint(request):
    return {
        "warning": "이 엔드포인트는 곧 제거됩니다. v2 API를 사용하세요",
        "migration_guide": "/docs/migration-v1-to-v2"
    }

🔍 보안 모니터링 및 로깅

# security_monitoring.py
import logging
from django.utils import timezone
from django.contrib.auth.signals import user_logged_in, user_login_failed
from django.dispatch import receiver
from django.contrib.auth.models import User

# 보안 이벤트 로거 설정
security_logger = logging.getLogger('security')

class SecurityEvent:
    """보안 이벤트 추적"""
    
    @staticmethod
    def log_login_success(user, request):
        security_logger.info(
            f"LOGIN_SUCCESS: User {user.username} from {SecurityEvent.get_client_ip(request)}"
        )
    
    @staticmethod
    def log_login_failure(username, request):
        security_logger.warning(
            f"LOGIN_FAILED: Username {username} from {SecurityEvent.get_client_ip(request)}"
        )
    
    @staticmethod
    def log_permission_denied(user, resource, request):
        security_logger.warning(
            f"PERMISSION_DENIED: User {user.username} tried to access {resource} from {SecurityEvent.get_client_ip(request)}"
        )
    
    @staticmethod
    def log_rate_limit_exceeded(identifier, request):
        security_logger.warning(
            f"RATE_LIMIT_EXCEEDED: {identifier} from {SecurityEvent.get_client_ip(request)}"
        )
    
    @staticmethod
    def get_client_ip(request):
        x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
        if x_forwarded_for:
            ip = x_forwarded_for.split(',')[0]
        else:
            ip = request.META.get('REMOTE_ADDR')
        return ip

# Django 신호를 통한 자동 로깅
@receiver(user_logged_in)
def log_user_login(sender, request, user, **kwargs):
    SecurityEvent.log_login_success(user, request)

@receiver(user_login_failed)
def log_user_login_failed(sender, credentials, request, **kwargs):
    username = credentials.get('username', 'unknown')
    SecurityEvent.log_login_failure(username, request)

# 의심스러운 활동 탐지
class SecurityAnalyzer:
    @staticmethod
    def detect_brute_force(username, ip_address):
        """무차별 대입 공격 탐지"""
        from django.core.cache import cache
        
        key = f"failed_login_{username}_{ip_address}"
        failed_attempts = cache.get(key, 0)
        
        if failed_attempts >= 5:
            # 계정 임시 잠금
            SecurityAnalyzer.temporary_lock_account(username)
            security_logger.critical(
                f"BRUTE_FORCE_DETECTED: {failed_attempts} failed attempts for {username} from {ip_address}"
            )
            return True
        
        return False
    
    @staticmethod
    def temporary_lock_account(username, duration=3600):
        """계정 임시 잠금 (1시간)"""
        from django.core.cache import cache
        cache.set(f"locked_account_{username}", True, duration)
    
    @staticmethod
    def is_account_locked(username):
        """계정 잠금 상태 확인"""
        from django.core.cache import cache
        return cache.get(f"locked_account_{username}", False)

⚙️ 프로덕션 보안 설정

# settings/production.py
import os

# 기본 보안 설정
DEBUG = False
SECRET_KEY = os.environ.get('SECRET_KEY')  # 환경변수에서 로드

# HTTPS 강제
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

# 보안 헤더
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_SECONDS = 31536000  # 1년
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

# 쿠키 보안
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Strict'
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True

# 허용된 호스트
ALLOWED_HOSTS = [
    'yourdomain.com',
    'www.yourdomain.com',
]

# 보안 미들웨어
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'corsheaders.middleware.CorsMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'your_app.middleware.SecurityMiddleware',  # 커스텀 보안 미들웨어
]

# 데이터베이스 보안
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DB_NAME'),
        'USER': os.environ.get('DB_USER'),
        'PASSWORD': os.environ.get('DB_PASSWORD'),
        'HOST': os.environ.get('DB_HOST'),
        'PORT': os.environ.get('DB_PORT', '5432'),
        'OPTIONS': {
            'sslmode': 'require',  # SSL 연결 강제
        },
    }
}

# 로깅 설정
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'security': {
            'format': '[{asctime}] {levelname} {name}: {message}',
            'style': '{',
        },
    },
    'handlers': {
        'security_file': {
            'level': 'INFO',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': '/var/log/django/security.log',
            'maxBytes': 10485760,  # 10MB
            'backupCount': 5,
            'formatter': 'security',
        },
    },
    'loggers': {
        'security': {
            'handlers': ['security_file'],
            'level': 'INFO',
            'propagate': False,
        },
    },
}

🛠️ 보안 테스트

# tests/test_security.py
from django.test import TestCase, Client
from django.contrib.auth.models import User
from ninja.testing import TestClient
from your_app.api import api
import json

class SecurityTestCase(TestCase):
    def setUp(self):
        self.client = TestClient(api)
        self.user = User.objects.create_user(
            username='testuser',
            email='test@example.com',
            password='TestPass123!'
        )
    
    def test_csrf_protection(self):
        """CSRF 보호 테스트"""
        response = self.client.post('/api/protected-endpoint', {
            'data': 'test'
        })
        self.assertEqual(response.status_code, 403)  # CSRF 토큰 없음
    
    def test_xss_prevention(self):
        """XSS 방지 테스트"""
        malicious_input = '<script>alert("xss")</script>'
        response = self.client.post('/api/comments', {
            'content': malicious_input,
            'author': 'test'
        })
        
        # 스크립트 태그가 제거되어야 함
        self.assertNotIn('<script>', response.json().get('content', ''))
    
    def test_sql_injection_prevention(self):
        """SQL Injection 방지 테스트"""
        malicious_input = "'; DROP TABLE users; --"
        response = self.client.get(f'/api/users/{malicious_input}')
        
        # 400 에러 또는 안전한 처리
        self.assertIn(response.status_code, [400, 404])
    
    def test_rate_limiting(self):
        """레이트 리미팅 테스트"""
        # 연속으로 많은 요청 보내기
        for i in range(10):
            response = self.client.get('/api/public-data')
        
        # 일정 수 이상 요청 시 429 에러
        response = self.client.get('/api/public-data')
        if response.status_code == 429:
            self.assertEqual(response.status_code, 429)
    
    def test_authentication_required(self):
        """인증 필요 엔드포인트 테스트"""
        response = self.client.get('/api/protected-resource')
        self.assertEqual(response.status_code, 401)
    
    def test_authorization_enforcement(self):
        """권한 확인 테스트"""
        # 다른 사용자의 리소스 접근 시도
        other_user = User.objects.create_user(
            username='otheruser',
            password='TestPass123!'
        )
        
        self.client.force_authenticate(self.user)
        response = self.client.get(f'/api/users/{other_user.id}/private-data')
        self.assertEqual(response.status_code, 403)

📋 보안 체크리스트

Django 기본 보안

  • DEBUG = False (프로덕션)
  • 강력한 SECRET_KEY 설정
  • HTTPS 강제 (SECURE_SSL_REDIRECT = True)
  • 보안 헤더 설정
  • 허용된 호스트 제한 (ALLOWED_HOSTS)
  • CSRF 보호 활성화
  • 세션 쿠키 보안 설정

Django Ninja API 보안

  • JWT 토큰 만료 시간 설정
  • API 레이트 리미팅 구현
  • 입력 데이터 검증 및 sanitization
  • 권한 기반 접근 제어
  • 파일 업로드 보안 검증
  • API 버전 관리

모니터링 및 로깅

  • 보안 이벤트 로깅
  • 무차별 대입 공격 탐지
  • 의심스러운 활동 모니터링
  • 정기적인 보안 감사

🎯 결론

Django와 Django Ninja는 강력한 보안 기능을 제공하지만, 개발자가 올바르게 구현해야 효과적입니다. 주요 포인트는:

  1. 기본 보안 설정: Django의 기본 보안 기능을 모두 활성화
  2. API 특화 보안: JWT 인증, 레이트 리미팅, 입력 검증
  3. 지속적인 모니터링: 보안 이벤트 추적 및 분석
  4. 정기적인 업데이트: 보안 패치 및 의존성 업데이트

보안은 한 번 설정하고 끝나는 것이 아니라 지속적으로 관리해야 하는 영역입니다. 정기적인 보안 감사와 최신 보안 동향 파악을 통해 안전한 웹 서비스를 유지하시기 바랍니다.

이 글을 공유해보세요!