문은 전부 닫아라
게이트, 캐시, 옆문
작은 조직 AI·서버 보안 기본기의 셋째 갈래입니다. 문 하나를 잠그고 나면 다 잠갔다고 느끼기 쉽습니다. 현장에서 열린 채로 발견된 옆문들을 모았습니다.
1. 기본 주소까지 덮는다
정적 호스팅에 조직의 도메인을 붙여도, 호스팅 업체가 처음 준 기본 주소와 미리보기 주소는 계속 살아 있고 끌 수 없습니다. 클라우드플레어 페이지라면 pages.dev로 끝나는 주소가 그렇습니다. 접근 게이트를 조직 도메인에만 걸면 같은 내용이 기본 주소로 로그인 없이 열려 있게 됩니다.
게이트는 경로와 호스트를 곱한 만큼 걸어야 합니다. 조직 도메인, 기본 주소, 미리보기 주소 세 호스트에 모두 걸고, 폴더 첫 화면과 그 아래 하위 경로를 둘 다 덮습니다.
새 게이트를 여는 순서
- 공개해도 되는 파일만 먼저 올려서 게이트를 시험할 판을 만듭니다.
- 게이트를 걸 경로를 찔러 봅니다. 로그인 화면으로 넘기는 응답이 오면 게이트가 있는 것이고, 없는 페이지라는 응답이 오면 게이트가 없는 것입니다.
- 게이트가 있다는 것을 확인한 뒤에야 내부 문서를 올립니다.
로그인 화면으로 넘기는 응답 하나만 보고 통과라고 하지 않습니다. 최종 본문까지 받아서 로그인 화면의 표지가 보이는지, 원고에만 있는 표식이 보이지 않는지를 셉니다.
게이트가 접근 앱 하나뿐인 구성이라면 되돌릴 때 앱을 지우지 않습니다. 앱을 지우는 그 순간 전부 공개되므로 DNS 레코드를 지우는 쪽으로 되돌립니다.
게이트를 건 위키를 열 때는 그 위키에 글을 쓸 사람의 이메일을 허용 명단 맨 앞에 넣습니다. 현장 기록에 따르면 실습 중이던 작성자가 자기 글 앞에서 막혔습니다. 명단을 늘린 뒤에는 로그인하지 않은 요청이 여전히 로그인 화면으로 넘어가는지도 같이 잽니다.
2. CDN은 확장자로 캐시한다
관리자만 받을 수 있는 근태 CSV 파일을 관리자가 한 번 내려받은 일이 있었습니다. 사고 기록에 따르면 그 파일이 CDN 가장자리 서버에 네 시간 동안 담겼고, 그 뒤로는 로그인 없이 누구나 같은 주소로 직원 이름과 휴대폰 번호가 든 파일을 받아 갈 수 있었습니다. 서버 코드에는 관리자 확인이 멀쩡히 걸려 있었습니다.
캐시에서 나간 응답은 서버까지 가지 않습니다. 그래서 코드의 권한 검사로는 이런 누출을 끝내 잡지 못합니다. API 경로 전체에 캐시를 금지하는 헤더를 미들웨어 한 곳에서 붙여 빼먹을 자리를 만들지 않습니다. 고친 뒤 판정은 한 번도 캐시된 적 없는 주소로 합니다. 이미 캐시된 옛 사본을 두드리면 고친 뒤에도 실패가 나옵니다.
공개 문서를 게이트 안으로 옮길 때도 같은 함정이 있습니다. 옮기기 전에 옛 주소를 평범하게 두드려 보면 그 순간 옛 본문이 가장자리 서버에 심깁니다. 처음부터 캐시를 건너뛰는 쿼리를 붙여 확인합니다. 남은 캐시는 내 요청이 지나간 지역의 가장자리 서버에만 있으니, 누출 범위를 전 세계로 부풀려 판단하지 않습니다.
3. 포트를 안 열었다는 것은 격리가 아니다
호스트 포트를 열지 않았어도 컨테이너 내부 주소로는 같은 기계의 다른 사용자가 붙을 수 있었습니다. 그 기계에는 바깥 조직 사람이 셸 계정으로 들어와 일하고 있었습니다. 격리는 열린 포트 목록을 읽어서가 아니라 실제로 접속을 시도해서 증명합니다. 막혀야 할 것과 열려야 할 것을 대조군으로 함께 시험합니다.
사설망에만 붙였다는 것은 인증이 아닙니다. 한 협동조합에서는 DB 관리 화면을 사설망 주소에만 붙여 두었는데, 그 사설망에 조합의 매장 계산대 PC가 들어 있었습니다. 그 PC 앞에 앉은 누구나 DB 편집기를 로그인 없이 열 수 있었습니다. 사설망은 「우리만」이 아니라 「가입한 기계 전부」이고, 조직과 일할수록 그 범위는 넓어집니다.
셀프호스트 DB 묶음에서 인증 프록시가 빠져 있으면 관리 화면은 인증 없이 열립니다. 관리 화면 앞에 인증 겹을 세우고, 프록시를 건너뛰는 직결 포트도 함께 닫습니다.
4. 남의 조직 계정에서 우리 역할로 되는 것과 안 되는 것
한 협동조합의 CDN 계정에 관리자 역할로 초대받았는데, 접근 게이트와 도메인 추가는 최상위 관리자만 할 수 있었습니다. 게이트를 설계하기 전에 그 계정에서 우리 역할로 무엇이 되는지부터 잽니다. 조합 쪽 손이 한 번 필요하다면 그것을 미리 알아야 일정을 짤 수 있습니다.
역할은 역할 칸과 정책 칸을 함께 봅니다. 쓰기 권한이 있는지는 조회 화면으로 판단하지 않고, 일부러 불완전한 요청을 보내 봅니다. 권한이 없으면 거부 응답이, 권한이 있으면 요청 형식 오류 응답이 돌아오므로 실제로 아무것도 바꾸지 않고 판정할 수 있습니다.
5. 없앨 수 있는 위험은 감시하지 말고 없앤다
접속 키가 만료될 위험을 발견하고 만료 경보부터 만든 일이 있었습니다. 기록을 보면 만료 자체를 끄는 데는 몇 분이면 충분했습니다. 경보는 위험을 그대로 둔 채 앞으로 사람 손을 한 번 더 부르는 장치입니다.
「없앨 수 없다」는 판정은 그 사건을 일으킨 주체를 로그로 짚어 본 뒤에만 내립니다. 감시는 디스크 사용량 증가나 외부 서비스 장애처럼 없앨 수 없는 위험에만 겁니다. 위험을 없애서 쓸모없어진 감시는 그 자리에서 걷어 냅니다.
6. 웹 화면의 기본 위생
사용자가 입력한 값을 화면의 HTML로 끼워 넣는 자리는 예외 없이 소독 함수를 거칩니다. 소독 함수를 만들어 두어도 새 화면을 짤 때 습관처럼 빠뜨리기 쉽습니다. 글자만 넣으면 되는 자리라면 HTML이 아닌 텍스트로 넣습니다.
도구 메모
- 게이트 확인: 로그인 없이 요청해 응답 코드와 최종 본문의 로그인 표지를 함께 본다
- 캐시 확인: 응답 헤더의 캐시 상태 값을 보고, 판정은 새 주소로
- 쓰기 권한 확인: 일부러 빠진 요청을 보내 거부 응답과 형식 오류 응답을 가른다