서비스에 붙는 경로가 하나였다. 밖에서 오는 사용자도 안에서 도는 배치도 같은 주소로 들어왔다.
Table of contents
Open Table of contents
제약 — 하나의 문이 만든 타협
들어오는 자리가 이랬다.
사용자 → api.example.com → 서버
배치 → api.example.com → 서버
관리 도구 → api.example.com → 서버
api.example.com 앞단에 방어 장치를 두면 안쪽 것까지 걸린다.
배치가 초당 수십 번 부르면 과도한 요청으로 차단된다. 예외로 빼면 그 예외 조건을 밖에서도 흉내 낼 수 있다.
User-Agent 로 배치를 가려내는 방법도 생각해 봤지만 그 값은 밖에서 얼마든지 지정할 수 있다. 요청 안에 든 것으로 안팎을 가르는 방식은 전부 같은 한계를 갖는다.
그래서 방어 설정을 느슨하게 두고 있었고 밖을 막으려면 안이 막히고 안을 열면 밖도 열리는 상태였다.
문이 하나면 그 문의 규칙도 하나라 서로 다른 요구를 담을 수 없다. 구성을 나누지 않고는 이 타협에서 못 벗어난다.
나누기로 했다
이름을 둘로 갈랐다.
api.example.com 밖에서 오는 것. 방어 장치를 거친다
internal.example.com 안에서만. 사설 대역에서만 닿는다
api 와 internal 이 같은 애플리케이션을 보는데 들어오는 문만 다르다.
# 밖으로 나가는 쪽
server_name api.example.com;
# 여기에 요청 수 제한, 봇 차단, 국가 제한 등을 건다
# 안쪽
server_name internal.example.com;
allow 10.0.0.0/24;
deny all;
server_name 으로 갈라 밖에는 방어를 세게 걸고 안쪽은 allow 대역으로만 막았다.
변경 내용 — 무엇이 어느 문으로
나누고 나서 부르는 쪽을 하나씩 옮겼다.
사용자 화면 밖
모바일 앱 밖
배치 안
관리 도구 안
모니터링 안
협력사 연동 밖 (별도 경로)
협력사가 애매했는데 밖에서 오지만 아는 상대이기 때문이다.
partner.example.com 협력사 전용. 키 인증 + 요청 수 제한
partner.example.com 으로 밖에 두되 경로를 따로 뒀다.
애매한 것을 억지로 둘 중 하나에 넣으면 거기서 다시 타협이 생기므로 세 번째를 만드는 편이 쌌다.
협력사를 안쪽으로 넣으면 남의 대역을 allow 에 적어야 한다. 밖에 그냥 두면 사용자와 같은 제한에 걸려 연동이 자주 막힌다.
검증 — 이름으로만 가르면 남는 구멍
설정만 하고 끝내지 않고 밖에서 찔러 봤다.
$ curl -sI https://internal.example.com/health
403 Forbidden
internal.example.com 으로 붙으면 403 으로 막힌다.
$ curl -sI -H "Host: internal.example.com" https://<서버IP>/health
200 OK
서버 주소를 직접 치고 Host 헤더를 넣으니 200 이 왔다.
앞단이 server_name 으로만 가르고 있었기 때문인데 안 맞는 요청이 어디로 갈지를 정해 두지 않으면 첫 블록이 받는다.
# 기본 서버 블록에서 막는다
server {
listen 443 default_server;
return 444;
}
default_server 에서 444 로 끊고 서버 주소 자체도 방화벽에서 막았다.
주의 — 밖에 열려 있던 안쪽 기능
경로를 나누면서 무엇이 열려 있었는지도 봤다.
/admin/cache/clear
/admin/config/reload
/debug/info
/admin/ 과 /debug/ 가 밖에서 닿는 상태였고 인증이 있긴 했지만 있어야 할 자리가 아니었다.
안쪽 문으로만 열리게 옮기고 밖에서는 그 경로 자체를 막았다.
location ~ ^/(admin|debug)/ { return 404; }
404 를 준 것은 그 경로가 있는지 없는지도 안 알려 주기 위해서였다.
한 문으로 다 들어올 때는 이것을 확인할 기준 자체가 없었고 나누면서 무엇이 어느 쪽인지 정하다 보니 저절로 드러났다.
/debug/info 는 서버 구성을 그대로 뱉는 경로였다. 인증이 걸려 있었다고 해도 밖에서 그 주소가 응답한다는 것 자체가 정보였다.
로그도 나뉘어 보기 편해졌다
api 와 internal 이 갈리니 각각에 다른 제한을 걸 수 있었다.
요청 수 제한 IP당 초당 10회
본문 크기 1MB
연결 시간 10초
밖은 이렇게 좁게 잡고 internal 쪽은 넉넉하게 뒀다.
배치가 큰 본문을 보내는 일이 있어 크기를 늘렸고 오래 걸리는 조회가 있어 시간도 늘렸다. 하나였을 때는 둘 중 하나에 맞춰야 했던 것이 각각에 맞춰졌다.
access_log 도 나뉘었다.
/var/log/nginx/api.access.log
/var/log/nginx/internal.access.log
밖의 로그에서 이상한 요청을 찾기가 쉬워졌다. 전에는 배치 요청이 대부분이라 그 안에서 찾아야 했고 나누는 것의 이득이 방어 설정만이 아니라 관측에도 있었다.
api.access.log 는 하루 분량이 훨씬 줄어서 눈으로 훑는 것도 가능해졌다. 같은 자료인데 섞여 있느냐 나뉘어 있느냐가 볼 수 있는 것을 바꿨다.
정리
- 밖과 안이 같은 문으로 들어오면 방어 설정을 한쪽에 맞춰야 한다
- 문이 하나면 규칙도 하나라 서로 다른 요구를 못 담는다
- 문을 나누고 밖에는 세게 안에는 대역으로 막는다
- 무엇이 어느 문으로 와야 하는지 목록으로 정한다
- 애매한 것은 억지로 넣지 말고 따로 둔다
- 이름으로만 가르면 주소로 직접 붙는 경로가 남는다
- 이름이 안 맞는 요청이 어디로 가는지 정해 두지 않으면 첫 블록이 받는다
- 기본 블록에서 끊고 주소 자체도 방화벽에서 막는다
- 나누면서 밖에 열려 있으면 안 되는 경로가 드러난다
- 없는 것처럼 보이게 하려고 404를 준다
- 나누면 각각에 다른 제한을 걸 수 있다
- 로그가 나뉘어 밖의 이상한 요청을 찾기 쉬워진다