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는 강력한 보안 기능을 제공하지만, 개발자가 올바르게 구현해야 효과적입니다. 주요 포인트는:
- 기본 보안 설정: Django의 기본 보안 기능을 모두 활성화
- API 특화 보안: JWT 인증, 레이트 리미팅, 입력 검증
- 지속적인 모니터링: 보안 이벤트 추적 및 분석
- 정기적인 업데이트: 보안 패치 및 의존성 업데이트
보안은 한 번 설정하고 끝나는 것이 아니라 지속적으로 관리해야 하는 영역입니다. 정기적인 보안 감사와 최신 보안 동향 파악을 통해 안전한 웹 서비스를 유지하시기 바랍니다.