발행일

관리자 화면 12개가 익명에 열려 있었다 — 그중 둘은 GET으로 저장하고 있었다

관리자 화면 12개가 익명에 열려 있었다

관리자 화면을 손보다가 로그인 세션이 끊겼는데, 화면이 그대로 떠 있었습니다. 새로고침해도 그대로였어요. 주소를 복사해 시크릿 창에 붙여 넣었더니 공지 목록이 그냥 나왔습니다.

훑어보니 열려 있는 화면이 12개였습니다.

  • 공지 5개 — 목록·상세·등록·수정·삭제
  • FAQ 5개 — 목록·상세·등록·수정·삭제
  • 뉴스·팝업 순서 변경 2개

삭제까지 포함입니다. 로그인하지 않은 사람이 주소만 알면 공지를 지울 수 있는 상태였어요.

같은 파일 안에서 다섯은 막혀 있고 다섯은 열려 있었다

제일 먼저 본 게 web_admin/views/system/post.py였는데, 이 파일 하나에 분류(Category) 뷰 5개와 공지(Notice) 뷰 5개가 나란히 들어 있습니다. 그리고 이렇게 생겼습니다.

# 18행 — 이미 import 돼 있었다
from user_org.permissions import StaffOnlyMixin

# 57행부터, 분류 뷰 5개 — 붙어 있다
class CategoryListView(StaffOnlyMixin, ListView):
class CategoryDetailView(StaffOnlyMixin, DetailView):
class AddCategoryView(StaffOnlyMixin, CreateView):
class EditCategoryView(StaffOnlyMixin, UpdateView):
class DeleteCategoryView(StaffOnlyMixin, DeleteView):

# 205행부터, 공지 뷰 5개 — 없다
class NoticeListView(ListView):
class NoticeDetailView(DetailView):
class AddNoticeView(CreateView):
class EditNoticeView(UpdateView):
class DeleteNoticeView(DeleteView):

import는 이미 파일 맨 위에 있었습니다. 같은 관리 화면의 다른 탭이고, 같은 파일이고, 150줄 아래인데 한쪽만 빠져 있었어요.

이런 종류의 누락은 "몰라서"가 아니라 기본값이 열림이라서 생깁니다. 새 뷰를 하나 추가할 때 아무것도 안 붙이면 그 화면은 공개됩니다. 붙여야 막히는 구조에서는 붙이는 걸 잊은 화면만 조용히 열려 있고, 열려 있다는 사실을 알려 주는 신호가 없어요. 화면은 잘 뜨니까요.

게이트를 어디에 둘 것인가

방법장점포기하는 것판단
/admin/ 경로 전체를 미들웨어에서 차단뷰를 빠뜨릴 수 없음로그인·정적 파일 등 예외 목록이 새로 생기고, 그 목록이 다시 빠뜨릴 자리가 됨기각
② 뷰마다 데코레이터변경이 작음붙이는 걸 잊는 실패 방식이 지금과 똑같음기각
③ 12개 뷰에 StaffOnlyMixin 부착형제 뷰 5개가 이미 쓰는 방식과 같아짐. 진입 판정이 한 곳기본값이 여전히 열림 — 다음 새 뷰도 같은 자리에서 빠짐채택
④ 공용 base 뷰에 기본 부착하고 공개 화면이 명시적으로 opt-out기본값이 닫힘으로 뒤집힘공개 화면들이 같은 base 뷰를 쓰고 있어 전수 확인이 필요. 이번 수정 범위를 한참 넘김보류

④가 정답에 가깝다고 봅니다. 이 프로젝트에는 이미 그 방향의 조각이 있어요 — 공개 화면용 DatasetViewMixin이 따로 있고, docstring이 이렇게 말합니다.

class DatasetViewMixin(View):
    """공개 화면 — 익명 접근이 **설계다.** 막지 않는다.

    권한을 보지 않으므로 화면별 선언을 두지 않는다. 예전에는 클래스마다
    `required_permissions` 가 붙어 있었는데 읽는 코드가 없어, 읽는 사람에게 게이트가 있다고
    착각만 시켰다(48곳). 이 믹스인을 붙이는 것 자체가 "공개로 정했다"는 표시다.
    """

"공개로 정했다"를 명시적으로 표시한다는 아이디어는 이미 있는데, 그게 강제되지는 않습니다. 아무것도 안 붙인 뷰가 여전히 공개로 동작하니까요. 이번엔 ③으로 구멍만 메웠고, 기본값은 못 뒤집었습니다.

그런데 둘은 GET으로 저장하고 있었다

뉴스·팝업의 순서 변경 뷰를 열어 보고 손이 멈췄습니다.

class ChangeNewsView(View):
    http_method_names = ["get"]

    def get(self, request, *args, **kwargs):
        ...
        src.save()
        dest.save()
        ...
        return redirect("web-admin:system:news_list")

http_method_names["get"]이고, 그 get() 안에서 save()를 두 번 부릅니다. 목록에서 위/아래 화살표를 누르면 링크를 타고 와서 MediaImage.order를 바꾸는 구조였어요.

익명 접근이 열려 있는 것과 겹치면 성질이 달라집니다. 인증이 필요한 상태 변경이 주소 하나로 실행되니까, 그 주소를 어딘가에 심어 두면 관리자가 그걸 여는 것만으로 순서가 바뀝니다. 크롤러나 브라우저 프리페치가 링크를 미리 당겨도 마찬가지고요. GET은 안전한 메서드라는 전제로 온갖 것들이 미리 당겨 봅니다.

이번 커밋에서는 두 뷰에도 믹스인을 붙여 익명 실행만 막았습니다. GET을 POST로 바꾸는 건 목록 템플릿의 링크를 전부 폼으로 바꿔야 해서 같이 안 했어요.

exceptreturn만 있으면 500이 난다

같은 두 뷰에 하나 더 있었습니다.

        except MediaImage.DoesNotExist:
            return

Django 뷰가 None을 반환하면 응답 객체가 없다는 이유로 500이 납니다. 순서 변경은 목록에서 눌러 오는 동작이라 낡은 id로 들어오는 게 드문 일이 아닌데, 그때마다 오류 화면이 떴어요.

        except MediaImage.DoesNotExist:
            # 응답 없이 반환하면 Django 가 500 을 낸다. 순서 바꾸기는 목록에서 눌러 오는
            # 동작이라, 낡은 id 로 들어온 경우 목록으로 돌려보내는 편이 맞다.
            return redirect("web-admin:system:news_list")

except를 쓴 사람이 "여기서는 아무것도 안 한다"를 표현하려 했다는 건 읽힙니다. 그런데 뷰에서 "아무것도 안 한다"는 return이 아니라 어디로 돌려보낼지를 정하는 일입니다. 예외를 삼키는 코드는 삼킨 뒤에 무엇을 내줄지까지 정해야 끝나요.

검증

  • 시크릿 창(익명)으로 12개 주소를 하나씩 요청 → 전부 200에서 302로 바뀐 것 확인. StaffOnlyMixinLoginRequiredMixin을 타므로 403이 아니라 관리자 로그인으로 보내는 리다이렉트입니다.
  • 공개 화면 /notice/·/faq/는 익명 200 유지 확인. 이 둘은 common/urls.py의 다른 뷰라 이번 변경과 무관해야 하고, 실제로 무관했습니다.
  • staff 계정으로 12개 화면 진입·등록·수정·삭제 정상 동작 확인.
  • 없는 MediaImage id로 순서 변경 주소를 직접 호출 → 500이 아니라 목록으로 302 확인. 고치기 전에 같은 요청으로 500을 먼저 재현했습니다.
  • 변경 범위는 뷰 파일 4개, 31줄 추가·14줄 삭제. 템플릿·URL은 안 건드렸습니다.

남은 것 · 한계

  • 같이 붙인 required_permissions 12줄은 읽는 코드가 없었습니다. ["web_admin.view_system"] 같은 값을 뷰마다 선언했는데, StaffOnlyMixin.dispatch()는 staff/superuser 여부만 보고 그 값을 안 봅니다. 세분된 권한이 있는 것처럼 보이게 만들었을 뿐이에요. 이 선언들은 나중에 224개가 통째로 걷혔습니다. 선언만 두는 건 게이트가 아니라 게이트가 있다는 착각이고, 그걸 이번에 12개 더 만들었습니다.
  • 관리자 화면 223개의 진입 판정이 이 믹스인 한 곳입니다. 관리자 화면에 들어온 사람은 모든 관리자 화면에 들어옵니다. 화면별 세분은 지금 없어요. 필요해지면 선언을 두는 게 아니라 dispatch()에 검사를 추가해야 합니다.
  • 순서 변경은 여전히 GET입니다. 익명 실행만 막았고 메서드는 그대로예요. 로그인한 관리자를 대상으로 한 CSRF 성격의 문제는 그대로 남아 있습니다. 목록 템플릿의 링크를 폼으로 바꿔야 해서 미뤘습니다.
  • 12개는 제가 우연히 마주친 수입니다. 세션이 끊긴 김에 눈에 띈 화면을 따라간 것이지, 관리자 화면 전체를 훑은 게 아니에요. 익명으로 전 라우트를 한 번 긁어 상태 코드를 세는 스크립트가 있어야 "몇 개가 남았는가"에 답할 수 있는데, 안 만들었습니다.
  • 기본값이 열림인 건 그대로입니다. 다음에 추가되는 관리자 뷰도 아무것도 안 붙이면 공개됩니다. ④안을 보류로 남겼으니 같은 실수를 할 자리를 알면서 안 막아둔 셈이에요.

관련 글: Jinja가 캐시 버스터를 컴파일 시점에 굳혔다 · 게시판 종류를 코드가 아니라 데이터로 뒀다