Django 보안 설정 가이드: check --deploy가 못 잡는 빈틈

Django 보안 설정을 배포 전에 점검하는 가이드다. check --deploy가 잡는 항목과 놓치는 DRF 기본 권한, 객체 권한, 프록시 HTTPS, Admin 노출을 고친 코드로 확인한다.

보안 점검 의뢰 팀 18분 분량

Django 서비스를 배포하기 전에는 manage.py check --deploy를 운영 설정으로 실행해 쿠키, HTTPS, DEBUG, SECRET_KEY 경고부터 없앤다. 그다음 도구가 보지 않는 네 곳을 직접 고친다. DRF 기본 권한을 IsAuthenticated로 바꾸고, 쿼리셋을 사용자 기준으로 좁히고, 프록시의 X-Forwarded-Proto를 덮어쓰고, Django Admin을 공개망에서 분리한다.

이 순서가 필요한 이유는 check --deploy가 설정값 일부만 검사하기 때문이다. Django 공식 문서도 이 점검이 사이트를 안전하게 만들어 주는 것은 아니라고 설명한다. 경고가 0건이어도 로그인한 사용자가 다른 회사의 청구서를 조회할 수 있다면 그 서비스는 안전하지 않다.

이 글은 2026-11-02 기준 Django 6.1 문서와 DRF 공식 문서를 바탕으로 한다. 예시 코드는 모두 가상의 코드다.

check --deploy가 보는 것과 보지 않는 것

먼저 경계를 나눠 보자.

구분항목
잡는다DEBUG=True, 약한 SECRET_KEY, 빈 ALLOWED_HOSTS, SECURE_SSL_REDIRECT, HSTS, 세션·CSRF 쿠키의 Secure, 보안 미들웨어 누락
못 잡는다ALLOWED_HOSTS = ["*"], SECURE_PROXY_SSL_HEADER 오설정, DRF 기본 권한, 객체 단위 권한, Admin 노출, 로그인 시도 제한

Django 소스를 보면 ALLOWED_HOSTS 점검은 목록이 비었는지만 확인한다. 와일드카드를 넣어도 통과한다. SECURE_PROXY_SSL_HEADER는 보안 점검 코드에서 아예 읽지 않는다. DRF는 Django 본체의 점검 대상이 아니다.

실행 방법에도 함정이 두 가지 있다.

# 경고만 출력되고 종료 코드는 0일 수 있다
python manage.py check --deploy

# CI에서는 운영 설정을 지정하고 경고도 실패로 처리한다
python manage.py check --deploy --settings=config.settings.production --fail-level WARNING

첫째, --fail-level 기본값은 ERROR다. 보안 항목은 대부분 security.W0xx 경고이므로 기본값으로는 CI가 멈추지 않는다. 둘째, 개발 설정으로 돌리면 운영 설정을 검사한 것이 아니다. --settings로 운영 모듈을 지정하거나 스테이징 환경에서 직접 실행한다.

환경 변수 파싱도 도구 밖에서 자주 틀린다.

# 취약: DEBUG=False를 넣어도 문자열 "False"는 참으로 평가된다
DEBUG = os.environ.get("DEBUG", True)
ALLOWED_HOSTS = ["*"]
SECRET_KEY = "django-insecure-dev-key"
# 수정: 기본값을 안전한 쪽으로 두고 명시적으로만 켠다
DEBUG = os.environ.get("DJANGO_DEBUG") == "1"
ALLOWED_HOSTS = ["api.example.com", "admin.example.com"]
SECRET_KEY = os.environ["SECRET_KEY"]  # 없으면 기동 실패

SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 3600  # 문제없음을 확인한 뒤 늘린다

로드밸런서 헬스체크가 내부 IP를 Host로 보내 400이 나면 ALLOWED_HOSTS를 *로 푸는 경우가 많다. 와일드카드 대신 헬스체크의 Host 값을 고정하거나, 프록시에서 헬스체크 경로를 따로 처리한다. 공식 체크리스트처럼 웹 서버에서 모르는 호스트를 먼저 거부하는 구성도 함께 둔다.

DRF 기본값은 공개 API다

DRF는 DEFAULT_PERMISSION_CLASSES를 지정하지 않으면 AllowAny로 동작한다. 인증 클래스를 붙여도 권한 클래스가 없으면 비로그인 요청은 그대로 통과한다. 스로틀도 기본값은 빈 목록이다.

# 취약: REST_FRAMEWORK 설정이 없으면 모든 뷰가 AllowAny
INSTALLED_APPS += ["rest_framework"]
# 수정: 기본을 닫고 공개할 뷰만 연다
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_RENDERER_CLASSES": [
        "rest_framework.renderers.JSONRenderer",
    ],
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {"anon": "30/min", "user": "300/min"},
    "NUM_PROXIES": 1,  # 실제 프록시 단수에 맞춘다
}

이 설정에서 볼 점은 다음과 같다.

  • 뷰에 permission_classes를 쓰면 전역 값에 더해지지 않고 대체된다. permission_classes = [] 한 줄이면 전역 설정이 사라지므로 코드 리뷰에서 검색해 본다.
  • 기본 인증에는 BasicAuthentication이 포함된다. 쓰지 않으면 빼서 공격 표면을 줄인다.
  • 브라우저블 API 렌더러는 운영에서 필요 없다면 뺀다. 필요하면 개발 설정에만 둔다.
  • NUM_PROXIES가 None이면 스로틀의 IP 판별이 느슨해진다. 0이면 REMOTE_ADDR만 쓰므로 프록시 뒤에서는 모든 사용자가 하나로 묶일 수 있다.
  • DRF 문서는 스로틀을 보안 수단으로 보지 말라고 명시한다. 로그인 무차별 대입 방어는 프록시나 게이트웨이 제한, 계정 잠금과 함께 설계한다. Django 자체도 로그인 시도를 제한하지 않는다.

시리얼라이저의 fields = "__all__"도 피한다. 모델에 컬럼을 추가하는 순간 응답과 입력에 함께 노출될 수 있다. DRF 문서도 필드를 명시적으로 나열할 것을 강하게 권장한다.

객체 단위 권한 누락은 어떻게 고칠까

IsAuthenticated는 "로그인했는가"만 본다. "이 청구서가 이 사용자의 것인가"는 보지 않는다. 이것이 IDOR 취약점이고, OWASP API Security Top 10의 첫 항목인 객체 수준 권한 부재(BOLA)다. 값이 정상이라 스캐너나 설정 점검으로는 드러나기 어렵다.

기본 패턴: 쿼리셋부터 좁힌다

# 취약: 로그인만 하면 ID를 바꿔 다른 팀 청구서를 조회·수정할 수 있다
class InvoiceViewSet(ModelViewSet):
    queryset = Invoice.objects.all()
    serializer_class = InvoiceSerializer
    permission_classes = [IsAuthenticated]
# 수정: 사용자가 속한 팀의 데이터만 대상으로 삼는다
class InvoiceViewSet(ModelViewSet):
    serializer_class = InvoiceSerializer
    permission_classes = [IsAuthenticated, CanManageInvoice]

    def get_queryset(self):
        return Invoice.objects.filter(project__team__members=self.request.user)


class CanManageInvoice(BasePermission):
    def has_object_permission(self, request, view, obj):
        if request.method in SAFE_METHODS:
            return True
        return obj.project.team.managers.filter(pk=request.user.pk).exists()

DRF의 get_object()는 filter_queryset(get_queryset())로 조회한 뒤 check_object_permissions()를 호출한다. 그래서 다른 팀 객체는 404가 되고, 같은 팀 안의 쓰기 권한은 403으로 갈린다.

has_object_permission()만 믿으면 안 되는 이유도 있다. 이 메서드는 목록 조회의 각 객체에는 실행되지 않는다. 쿼리셋 필터링이 먼저고, 객체 권한은 그 위의 세부 규칙이다.

놓치기 쉬운 경로 세 가지

커스텀 액션에서 모델을 직접 조회할 때

# 취약: get_object()를 거치지 않아 쿼리셋 필터와 객체 권한이 모두 빠진다
@action(detail=True, methods=["post"])
def approve(self, request, pk=None):
    invoice = Invoice.objects.get(pk=pk)
    invoice.approve(by=request.user)
    return Response(status=204)
# 수정
@action(detail=True, methods=["post"])
def approve(self, request, pk=None):
    invoice = self.get_object()
    invoice.approve(by=request.user)
    return Response(status=204)

get_object()를 재정의했거나 APIView, 함수형 뷰를 쓴다면 self.check_object_permissions(request, obj)를 직접 호출해야 한다.

생성할 때 외래 키를 받을 때

객체 생성에는 get_object()가 호출되지 않으므로 객체 권한이 실행되지 않는다. 다른 팀의 project ID를 넣어 청구서를 만들 수 있다는 뜻이다.

class InvoiceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Invoice
        fields = ["id", "project", "amount", "status"]
        read_only_fields = ["id", "status"]

    def validate_project(self, project):
        user = self.context["request"].user
        if not project.team.members.filter(pk=user.pk).exists():
            raise serializers.ValidationError("프로젝트를 찾을 수 없습니다.")
        return project

status처럼 사용자가 바꾸면 안 되는 필드를 읽기 전용으로 둔 점도 함께 본다. 단, 시리얼라이저에 필드를 직접 선언하면 read_only_fields가 적용되지 않으므로 그 필드에 read_only=True를 쓴다.

테스트가 없을 때

권한 로직은 사용자 두 명을 두는 테스트로 고정한다. OWASP도 권한 검증 테스트를 작성하고, 실패하면 배포하지 말라고 권고한다.

def test_other_team_cannot_read_invoice(api_client, user_a, invoice_of_team_b):
    api_client.force_authenticate(user_a)
    res = api_client.get(f"/api/invoices/{invoice_of_team_b.pk}/")
    assert res.status_code == 404

프록시 뒤 HTTPS 판별을 안전하게 설정하기

TLS를 Nginx나 로드밸런서가 처리하면 Django는 모든 요청을 HTTP로 인식한다. SECURE_SSL_REDIRECT = True에서 무한 리다이렉트가 나는 대표적 원인이다. 그래서 아래 설정을 넣는다.

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

문제는 이 한 줄이 "이 헤더를 신뢰한다"는 선언이라는 점이다. 프록시가 사용자가 보낸 헤더를 지우지 않으면 평문 HTTP 요청도 HTTPS로 판별된다. 공식 문서는 설정 전에 세 가지가 모두 참이어야 한다고 경고한다.

  1. 앱이 프록시 뒤에 있다.
  2. 프록시가 외부에서 들어온 X-Forwarded-Proto를 제거한다.
  3. 프록시가 원래 HTTPS로 들어온 요청에만 이 헤더를 설정한다.
# 취약: 클라이언트가 보낸 X-Forwarded-Proto가 그대로 전달될 수 있다
location / {
    proxy_pass http://django;
}
# 수정: Nginx가 TLS를 종료하는 구성에서 값을 항상 덮어쓴다
location / {
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_pass http://django;
}

구성별로 판단 기준이 다르다.

  • Nginx가 가장 바깥에 있는 경우: 위 설정처럼 $scheme로 덮어쓴다.
  • 외부 로드밸런서 뒤에 Nginx가 있는 경우: Nginx의 $scheme은 http가 된다. 가장 바깥 프록시가 값을 정하고, 내부 Nginx는 그 값을 전달하게 하되 Nginx에는 로드밸런서만 접근할 수 있어야 한다.
  • 앱 포트가 외부에서 직접 열린 경우: 헤더를 위조할 수 있으므로 보안 그룹이나 네트워크 정책으로 먼저 막는다.

스테이징에서는 이렇게 확인한다. 응답이 https://로 가는 301이면 정상이고, 200이면 헤더를 신뢰하고 있다는 뜻이다.

curl -sI -H "X-Forwarded-Proto: https" http://staging.example.com/

USE_X_FORWARDED_HOST도 같은 원리다. 해당 헤더를 설정하는 프록시를 쓸 때만 켠다. 코드에서 request.META["HTTP_HOST"]를 직접 읽으면 ALLOWED_HOSTS 검증을 우회하므로 request.get_host()를 쓴다.

Django Admin 노출을 줄이는 법

check --deploy는 Admin이 어디에 공개돼 있는지 알지 못한다. Django Admin은 is_active와 is_staff를 확인하지만, 공개망에 열린 로그인 화면에 대한 시도 제한은 제공하지 않는다. 경로 이름만 바꾸는 것은 노출을 줄일 뿐 접근 통제가 아니다.

가장 깔끔한 방법은 공개 배포에서 Admin 라우트를 빼는 것이다.

# 취약: 공개 API와 같은 도메인·배포에 Admin이 열려 있다
urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("api.urls")),
]
# 수정: 내부 전용 배포에서만 Admin을 켠다
urlpatterns = [path("api/", include("api.urls"))]

if settings.ADMIN_ENABLED:  # 공개 배포는 False, VPN 내부 배포만 True
    urlpatterns += [path("internal-admin/", admin.site.urls)]

배포를 나누기 어렵다면 프록시에서 접근 대역을 제한한다.

location /internal-admin/ {
    allow 203.0.113.10;  # 사내 VPN 출구 IP 예시
    deny all;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_pass http://django;
}

외부 로드밸런서 뒤라면 Nginx가 보는 IP는 로드밸런서 주소일 수 있다. 실제 클라이언트 IP 복원을 먼저 구성했는지 확인한다.

네트워크 통제에 더해 다음을 적용한다. 더 자세한 항목은 관리자 페이지 보안 체크리스트에서 다룬다.

  • 관리자 계정에 SSO 또는 2단계 인증을 적용하고, 로그인 실패 제한을 둔다.
  • is_superuser를 최소화하고 업무별 그룹 권한을 쓴다.
  • 퇴사·이동 시 is_staff 회수를 절차로 만들고 정기적으로 목록을 검토한다.
  • 개인정보 필드는 list_display와 내보내기 기능에서 필요한 범위로 줄인다.

빈틈을 CI로 막는 체크리스트

사람이 매번 기억하기 어렵다면 프로젝트 정책을 커스텀 시스템 체크로 만든다. deploy=True로 등록하면 check --deploy에 함께 실행된다.

# core/checks.py
from django.conf import settings
from django.core.checks import Error, Tags, register


@register(Tags.security, deploy=True)
def check_project_policy(app_configs, **kwargs):
    errors = []
    if "*" in settings.ALLOWED_HOSTS:
        errors.append(Error("ALLOWED_HOSTS에 와일드카드가 있습니다.", id="core.E001"))

    drf = getattr(settings, "REST_FRAMEWORK", {})
    perms = drf.get("DEFAULT_PERMISSION_CLASSES", [])
    if not perms or "rest_framework.permissions.AllowAny" in perms:
        errors.append(Error("DRF 기본 권한이 열려 있습니다.", id="core.E002"))
    return errors
# core/apps.py
class CoreConfig(AppConfig):
    name = "core"

    def ready(self):
        from . import checks  # noqa: F401

배포 전에는 아래 항목을 확인한다.

도구로 확인

  • 운영 설정으로 check --deploy --fail-level WARNING이 통과한다
  • SILENCED_SYSTEM_CHECKS에 숨긴 보안 경고가 없다
  • Django가 지원 중인 버전이고 최신 패치다

직접 확인

  • DEFAULT_PERMISSION_CLASSES가 AllowAny가 아니다
  • AllowAny와 permission_classes = [] 사용처를 모두 설명할 수 있다
  • 모든 뷰셋의 get_queryset()이 사용자·조직 기준으로 필터링된다
  • 커스텀 액션이 get_object()를 거친다
  • 생성 API의 외래 키 입력을 소유권 기준으로 검증한다
  • 다른 사용자 데이터 접근 테스트가 CI에 있다
  • 프록시가 X-Forwarded-Proto를 덮어쓰고, 앱 포트가 외부에 닫혀 있다
  • NUM_PROXIES가 실제 구성과 같다
  • Admin이 공개망에서 차단되거나 추가 인증 뒤에 있다
  • 요청 본문 크기를 웹 서버에서 제한한다

마지막 항목은 ASGI에서 특히 중요하다. 공식 문서는 ASGI에서 제한 적용 전에 요청 전체가 디스크에 임시 저장될 수 있다며, Django 앞단의 크기 제한을 권장한다.

자주 묻는 질문

check --deploy 경고를 모두 없애야 하나요?

대부분 없애는 것이 맞다. 다만 SECURE_HSTS_PRELOAD처럼 되돌리기 어려운 항목은 전체 서브도메인의 HTTPS 준비를 확인한 뒤 적용한다. 예외를 둘 때는 SILENCED_SYSTEM_CHECKS에 넣는 이유를 주석과 문서로 남긴다.

get_queryset 필터링과 has_object_permission 중 무엇을 써야 하나요?

둘 다 쓰되 역할을 나눈다. 필터링은 "볼 수 있는 범위"를 정하며 목록과 상세 조회에 모두 적용된다. has_object_permission은 그 안에서 수정·삭제 같은 행동별 규칙을 정한다. 하나만 고른다면 필터링이 먼저다.

Django 버전은 어디까지 올려야 하나요?

지원 중인 버전의 최신 패치를 쓴다. 2026년 10월 6일 보안 릴리스 기준으로는 6.1.2, 6.0.9, 5.2.18이다. 4.2는 2026년 4월 릴리스로 지원이 끝났으므로 올리는 계획을 세운다. check --deploy는 버전 지원 종료를 알려주지 않는다.

정리

check --deploy는 설정값을 확인하는 출발점이며, CI에서는 운영 설정과 --fail-level WARNING으로 실행한다.

DRF는 기본 권한을 닫고, 쿼리셋 필터링과 객체 권한을 커스텀 액션과 생성 경로까지 적용해 테스트로 고정한다.

SECURE_PROXY_SSL_HEADER는 프록시가 헤더를 덮어쓴다는 보장이 있을 때만 켜고, Admin은 공개망에서 분리한다.

직접 점검이 어렵다면 무료 분석 신청으로 현재 구성을 확인받아 볼 수 있다.

참고 자료

함께 읽으면 좋은 글