로컬에서만 인증을 건너뛰려다, 하마터면 인터넷 전체를 열 뻔한 이야기
홈랩에 올려둔 서비스들 앞에는 죄다 oauth2-proxy + Keycloak을 세워뒀습니다.
밖에서 접속하든 집 소파에서 접속하든, 일단 Keycloak 로그인 화면을 한 번 거칩니다.
밖에서 들어올 땐 당연히 그래야 하는데,
집 안에서, 그것도 내 노트북에서 접속할 때까지 매번 로그인하는 건 좀 귀찮았습니다.
그래서 생각한 게 이겁니다.
내 공인 IP나 집 LAN 대역에서 온 요청이면 그냥 인증을 건너뛰자.
말로는 한 줄입니다.
그런데 이 한 줄을 실제로 넣으려다, 하마터면 인터넷 전체에 인증을 열어버릴 뻔했습니다.
그 이야기입니다.
이 글의 IP·호스트명은 전부 예시(플레이스홀더)로 바꿔 적었습니다.
<PUBLIC_IP>는 내 공인 IP,<LAN_CIDR>는 집 LAN 대역이라고 보면 됩니다.
지금 인증이 걸리는 구조부터
내 서비스들은 Istio 인그레스 게이트웨이 뒤에 oauth2-proxy를 리버스 프록시로 세워두는 구조입니다.
요청이 들어오면 이렇게 흐릅니다.
client → istio-ingressgateway(Envoy) → oauth2-proxy → 실제 앱oauth2-proxy가 세션 쿠키를 확인하고, 없으면 Keycloak(OIDC)으로 리다이렉트합니다.
그러니 “로컬은 인증 건너뛰기”를 구현할 자리는 명확합니다.
oauth2-proxy에는 --trusted-ip 라는 옵션이 있습니다.
여기에 CIDR을 넣으면, 그 대역에서 온 요청은 OIDC를 통째로 건너뜁니다.
extraArgs:
trusted-ip: "<PUBLIC_IP>/32,<LAN_CIDR>"끝. ⇒ 이렇게 끝났으면 이 글도 없었겠죠.
문제는 “그 IP를 대체 어디서 보고 판단하느냐” 였습니다.
”그래서 그 IP를 어디서 보는데?”
--trusted-ip가 신뢰하는 “클라이언트 IP”는, oauth2-proxy가 요청에서 읽어낸 값입니다.
그럼 지금 oauth2-proxy는 클라이언트 IP를 뭐로 보고 있을까요?
배포하기 전에 그냥 로그부터 까봤습니다.
kubectl logs -n <ns> deploy/<svc>-oauth2-proxy --tail=50<게이트웨이-파드-IP> - user@example.com [ ... ] app.example.com GET "/" ...모든 요청의 클라이언트 IP가 게이트웨이 파드 IP 하나로만 찍힙니다.
즉 지금 oauth2-proxy 입장에서는 모든 사람이 “게이트웨이 파드”에서 온 걸로 보입니다.
⇒ 클라이언트가 누구든 구분이 안 됩니다.
이유는 단순합니다.
oauth2-proxy는 --real-client-ip-header가 없으면 기본값으로 X-Real-IP를 봅니다.
그런데 Envoy는 X-Real-IP를 안 채워주니, oauth2-proxy는 그냥 TCP 소켓의 상대 주소(RemoteAddr)를 씁니다.
그 상대는 바로 앞 홉인 게이트웨이 파드고요. ←그래서 다 똑같이 찍힌 겁니다.
여기서 순진하게 --trusted-ip만 넣었으면 어떻게 됐을까요?
아무도 내 LAN 대역도 아니고 내 공인 IP도 아니니, 아무도 우회 안 됨 — 무해하지만 무의미.
그래서 “실제 클라이언트 IP를 oauth2-proxy까지 전달”부터 풀어야 했습니다.
그리고 그 과정에서 함정이 세 개 튀어나왔습니다.
함정 1 — externalTrafficPolicy 하나가 인증을 통째로 열 뻔했다
내 게이트웨이 서비스는 NodePort 타입입니다.
그리고 externalTrafficPolicy가 따로 지정 안 돼 있으니 기본값 Cluster고요.
여기서 진짜 위험한 게 숨어 있었습니다.
왜 Cluster 정책이 위험한가
externalTrafficPolicy: Cluster면, kube-proxy가 NodePort로 들어온 외부 트래픽을 노드 IP로 SNAT 합니다.
그런데 그 노드 IP들이 하필, 내가 신뢰하려던 집 LAN 대역 안에 있습니다.
그림으로 보면 이렇습니다.
즉 Cluster 정책이 켜진 상태에서 LAN 대역 우회를 넣으면,
인터넷에서 온 모든 트래픽이 노드 IP로 둔갑해서 신뢰 대역에 걸립니다.
⇒ 로컬만 열려던 게, 전 세계에 인증을 열어주는 사고가 되는 거죠.
이걸 배포 전에 로그로 확인 안 했으면 정말 큰일 날 뻔했습니다.
실제로 게이트웨이 Envoy로 들어온 소켓들을 까보니, 외부 트래픽이 죄다 노드 IP 하나로 들어오고 있었습니다.
# 게이트웨이 istio-proxy 안에서
ss -tnH state established '( sport = :8443 )' | awk '{print $5}' | sort | uniq -c결론 : SNAT가 살아있는 한, LAN 대역 신뢰는 로컬 신뢰가 아니라 전 인터넷 신뢰다.
그래서 소스 IP를 보존해야 한다
해법은 externalTrafficPolicy: Local 입니다.
Local이면 kube-proxy가 SNAT을 안 하고, Envoy가 진짜 클라이언트 소스 주소를 봅니다.
service:
type: NodePort
externalTrafficPolicy: Local단, Local엔 함정이 하나 더 붙습니다. ←공짜가 아니었습니다.
Local은 게이트웨이 파드가 떠 있는 노드로 들어온 트래픽만 처리합니다.
파드가 없는 노드로 들어온 트래픽은 그냥 드랍합니다.
그래서 라우터가 포트포워딩하는 노드에 게이트웨이 파드가 반드시 있어야 합니다.
내 경우엔 워커 노드가 하나뿐(마스터는 taint)이라 게이트웨이가 구조적으로 그 노드에만 뜹니다.
⇒ HA는 원래부터 없었으니 잃을 것도 없었고, 대신 라우터가 그 노드를 가리키는지만 확인하면 됐습니다.
배포 후 노드별로 찔러보면 Local 동작이 바로 보입니다.
# 게이트웨이 파드가 있는 노드 → 정상
curl -so /dev/null -w "%{http_code}\n" -H "Host: app.example.com" http://<gateway-node>:30080/ # 302
# 파드 없는 노드 → 드랍
curl -so /dev/null -w "%{http_code}\n" -H "Host: app.example.com" http://<other-node>:30080/ # 000 (timeout)302는 Keycloak으로 리다이렉트된다는 뜻 — 인증이 정상적으로 걸려있다는 신호입니다.
함정 2 — X-Forwarded-For를 믿으면 안 되는 이유
이제 실제 IP가 Envoy까지는 왔습니다.
그럼 oauth2-proxy한테 “이 헤더에서 클라이언트 IP를 읽어라”를 알려줘야 합니다.
가장 흔한 선택은 X-Forwarded-For(XFF)입니다.
그런데 여기서 멈칫했습니다.
oauth2-proxy의 XFF 파서는 맨 앞(leftmost) 값을 클라이언트 IP로 씁니다.
XFF의 맨 앞은 “클라이언트가 스스로 주장하는 값”이고,
Envoy는 진짜 IP를 XFF 오른쪽에 덧붙이기만 합니다.
⇒ 공격자가 XFF 맨 앞에 신뢰 대역 IP를 손수 넣어 보내면,
Envoy가 진짜 IP를 오른쪽에 붙여도 oauth2-proxy는 맨 앞의 위조값을 읽습니다.
즉 XFF + oauth2-proxy 조합은 클라이언트가 신뢰 IP를 사칭할 수 있습니다. 인증 우회 통째로 뚫리는 거죠.
그래서 XFF는 버리고 X-Envoy-External-Address를 씁니다.
이 헤더는 Envoy가 실제 TCP 소켓 상대로부터 직접 계산합니다(use_remote_address=true, xff_num_trusted_hops=0).
그리고 외부에서 들어온 x-envoy-* 헤더는 엣지에서 덮어써 버리기 때문에, 클라이언트가 위조할 수 없습니다.
extraArgs:
real-client-ip-header: X-Envoy-External-Address
trusted-ip: "<PUBLIC_IP>/32,<LAN_CIDR>"사설 IP는 이 헤더에 안 담긴다는 이야기 (버전 주의)
여기서 한 가지 더 파고들 게 있었습니다.
예전 Envoy는 RFC1918 사설 대역(집 LAN 대역 같은)을 “internal”로 보고,
X-Envoy-External-Address를 사설 소스에 대해선 비워버렸습니다.
그럼 LAN 대역 우회가 아예 안 걸리겠죠. ←이러면 LAN 우회가 무용지물.
확인해보니 내 Envoy(Istio 1.29 계열, Envoy 1.3x)는 1.33 이후 동작이라, internal_address_config가 없으면 RFC1918도 external로 취급합니다.
⇒ 사설 LAN IP도 이 헤더에 정상적으로 담깁니다. 그래서 LAN 대역이 그대로 걸립니다.
internal_address_config를 굳이 건드리면 오히려 LAN이 다시 비워질 수 있으니, 손대지 않는 게 정답이었습니다.
numTrustedProxies도 0(기본값) 그대로 둡니다. 0보다 키우면 다시 XFF 기반이 되어 스푸핑이 살아납니다.
함정 3 — 차트 버전은 그대로인데 왜 CrashLoop?
값을 다 넣고 배포했더니, 일부 파드가 CrashLoopBackOff로 뻗었습니다.
--real-client-ip-header=X-Envoy-External-Address를 거부하면서요.
원인은 oauth2-proxy 바이너리 버전이었습니다.
X-Envoy-External-Address 값은 oauth2-proxy appVersion 7.8.0부터 지원합니다.
그 이전 바이너리는 이 헤더 값을 아예 거부합니다.
--real-client-ip-header (one of: X-Forwarded-For, X-Real-IP, or X-ProxyUser-IP)문제는 헬름 차트 버전과 appVersion이 다르게 논다는 거였습니다.
차트 oauth2-proxy 리포의 버전 매핑을 직접 뽑아보면 이렇습니다.
chart 7.8.0 → appVersion 7.7.1 ← 헤더 거부 (CrashLoop)
chart 7.9.x → appVersion 7.7.1
chart 7.10.0 → appVersion 7.8.0 ← 헤더 지원 시작
chart 7.12.3 → appVersion 7.8.1chart 7.8.0을 쓰고 있었는데, 이게 사실 appVersion 7.7.1이었던 겁니다.
버전 숫자가 비슷해서 착각하기 딱 좋은 함정이었습니다.
결론 : 차트 버전이 아니라 appVersion을 봐야 한다. 헬름 인덱스에서 직접 매핑을 확인하자.
curl -s https://oauth2-proxy.github.io/manifests/index.yaml \
| grep -E 'version|appVersion'차트를 7.12.3(appVersion 7.8.1)으로 올리니 CrashLoop 없이 정상 기동했습니다.
이미지 태그를 강제로 박아두지 않았는지도 같이 확인해야 합니다 — 그게 있으면 차트만 올려도 안 바뀝니다.
직접 확인 — 진짜 내 IP가 도착하는가, 위조는 막히는가
여기서부터가 진짜 중요합니다.
“이론상 안전”과 “실제로 안전”은 다르니까요.
우회를 켜기 전에, 헤더만 먼저 배포해서 로그로 실제 도착 IP부터 관찰했습니다.
--real-client-ip-header만 넣고 --trusted-ip는 아직 주석 처리 — 이러면 로그엔 실 IP가 찍히지만 우회는 안 걸립니다.
그 상태로 휴대폰 셀룰러(집 밖 네트워크)에서 한 번 접속해봤습니다.
<외부-공인IP> - - [ ... ] app.example.com GET "/" "... iPhone ..." 302셀룰러 공인 IP가 그대로 oauth2-proxy까지 도달했습니다. 노드 IP도, 라우터 IP도 아닌 진짜 IP로요!
⇒ 라우터가 외부 트래픽은 소스를 보존한 채 넘겨준다는 게 실측으로 확인됐습니다.
이게 핵심 안전 확인입니다.
외부 클라이언트가 사설 대역으로 안 보인다는 걸 눈으로 봤으니, LAN 대역 우회가 인터넷을 열지 않는다는 게 증명된 거죠.
그다음 우회를 켜고, 스푸핑 테스트를 했습니다.
비신뢰 소스에서 신뢰 IP를 헤더로 위조해 보내봅니다.
# 위조 헤더로 신뢰 IP 사칭 시도
curl -so /dev/null -w "%{http_code}\n" \
-H "Host: app.example.com" \
-H "X-Forwarded-For: <신뢰대역-IP>" \
-H "X-Envoy-External-Address: <PUBLIC_IP>" \
http://<gateway>/결과는 302.
로그를 보니 oauth2-proxy에는 위조값이 아니라 진짜 소스 IP가 찍혀 있었습니다.
⇒ Envoy가 클라이언트가 보낸 x-envoy-*를 덮어써서, 위조가 통하지 않습니다!
결론 : 소스 IP 보존(Local) + 비스푸핑 헤더(X-Envoy-External-Address) 두 개가 같이 맞아야 안전하다. 하나라도 어긋나면 우회가 열리거나(SNAT) 뚫린다(XFF).
멀티테넌트는 또 다른 이야기
앞의 서비스들은 나 혼자 쓰는 도구라 “홈 네트워크 전체 신뢰”로 끝나도 됩니다.
그런데 사용자별 개발 워크스페이스(code-server 같은)는 이야기가 다릅니다.
여긴 사람마다 프록시가 따로 있고, 각자 자기 것만 접근하도록 격리돼 있습니다.
여기에 LAN 대역 우회를 걸면, 홈 LAN의 아무 기기나 모든 사람의 워크스페이스에 무인증으로 들어갑니다.
소스코드, 터미널, 키까지 다 열리는 거죠. ←이건 내 홈랩 신뢰와는 결이 다른 문제.
그래서 이건 전체가 아니라 내 워크스페이스에만 옵트인으로 걸었습니다.
프록시를 생성하는 컨트롤러가 워크스페이스별 어노테이션을 읽어서, 그 값이 있을 때만 우회 플래그를 붙이도록요.
⇒ 같은 메커니즘이라도 “누가 쓰는 리소스인가”에 따라 적용 범위는 완전히 달라져야 합니다.
정리하며
“로컬은 인증 건너뛰기”는 말 한 줄이었지만, 실제로는 세 개의 함정을 지나야 했습니다.
- 함정 1 (SNAT) :
externalTrafficPolicy=Cluster면 외부가 노드 IP로 둔갑 ⇒ LAN 대역 신뢰가 전 인터넷 신뢰가 됨.Local로 소스 보존. - 함정 2 (스푸핑) :
X-Forwarded-For는 leftmost 위조 가능 ⇒ Envoy가 계산하는X-Envoy-External-Address를 씀.numTrustedProxies는 0 유지. - 함정 3 (버전) : 차트 버전 ≠ appVersion. 헤더는 appVersion 7.8.0+에서만 ⇒ 매핑 직접 확인.
무엇보다, 배포 전에 로그로 실제 도착 IP를 확인하고, 우회를 켠 뒤 스푸핑 테스트를 한 게 제일 잘한 선택이었습니다.
편의를 위한 우회는 그 자체로 보안 완화입니다.
LAN 대역 하나를 통째로 믿는다는 건, 그 네트워크의 게스트·IoT·감염된 기기까지 다 믿는다는 뜻이고,
공인 IP는 동적이라 언젠가 남에게 재할당될 수도 있습니다.
이런 트레이드오프를 다 알고도, 홈랩이라는 한정된 환경에서 “이만하면 됐다” 하고 넘어가는 겁니다.
그래도 하마터면 전부 열 뻔한 걸 로그 한 줄로 잡았으니, 이번엔 꽤 남는 장사였네요.