- 발행일
관리자 화면 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로 바꾸는 건 목록 템플릿의 링크를 전부 폼으로 바꿔야 해서 같이 안 했어요.
except에 return만 있으면 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로 바뀐 것 확인.
StaffOnlyMixin은LoginRequiredMixin을 타므로 403이 아니라 관리자 로그인으로 보내는 리다이렉트입니다. - 공개 화면
/notice/·/faq/는 익명 200 유지 확인. 이 둘은common/urls.py의 다른 뷰라 이번 변경과 무관해야 하고, 실제로 무관했습니다. - staff 계정으로 12개 화면 진입·등록·수정·삭제 정상 동작 확인.
- 없는
MediaImageid로 순서 변경 주소를 직접 호출 → 500이 아니라 목록으로 302 확인. 고치기 전에 같은 요청으로 500을 먼저 재현했습니다. - 변경 범위는 뷰 파일 4개, 31줄 추가·14줄 삭제. 템플릿·URL은 안 건드렸습니다.
남은 것 · 한계
- 같이 붙인
required_permissions12줄은 읽는 코드가 없었습니다.["web_admin.view_system"]같은 값을 뷰마다 선언했는데,StaffOnlyMixin.dispatch()는 staff/superuser 여부만 보고 그 값을 안 봅니다. 세분된 권한이 있는 것처럼 보이게 만들었을 뿐이에요. 이 선언들은 나중에 224개가 통째로 걷혔습니다. 선언만 두는 건 게이트가 아니라 게이트가 있다는 착각이고, 그걸 이번에 12개 더 만들었습니다. - 관리자 화면 223개의 진입 판정이 이 믹스인 한 곳입니다. 관리자 화면에 들어온 사람은 모든 관리자 화면에 들어옵니다. 화면별 세분은 지금 없어요. 필요해지면 선언을 두는 게 아니라
dispatch()에 검사를 추가해야 합니다. - 순서 변경은 여전히 GET입니다. 익명 실행만 막았고 메서드는 그대로예요. 로그인한 관리자를 대상으로 한 CSRF 성격의 문제는 그대로 남아 있습니다. 목록 템플릿의 링크를 폼으로 바꿔야 해서 미뤘습니다.
- 12개는 제가 우연히 마주친 수입니다. 세션이 끊긴 김에 눈에 띈 화면을 따라간 것이지, 관리자 화면 전체를 훑은 게 아니에요. 익명으로 전 라우트를 한 번 긁어 상태 코드를 세는 스크립트가 있어야 "몇 개가 남았는가"에 답할 수 있는데, 안 만들었습니다.
- 기본값이 열림인 건 그대로입니다. 다음에 추가되는 관리자 뷰도 아무것도 안 붙이면 공개됩니다. ④안을 보류로 남겼으니 같은 실수를 할 자리를 알면서 안 막아둔 셈이에요.