diff --git a/docs/evidence/two-hop-proxy-headers/05-networkpolicy.txt b/docs/evidence/two-hop-proxy-headers/05-networkpolicy.txt new file mode 100644 index 0000000..9a96efa --- /dev/null +++ b/docs/evidence/two-hop-proxy-headers/05-networkpolicy.txt @@ -0,0 +1,48 @@ +수집 시각: 2026-09-03 16:16:48 KST +주제: 프록시 우회 경로 차단 (NetworkPolicy) + +=== [1] 차단 전 — 클러스터 안에서 앱에 직접 요청 === +명령: kubectl run ... -- curl http://echo:8081/api/echo \ + -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4' + + scheme https + secure True + serverName evil.example.com ← 위조 성공 + remoteAddr 1.2.3.4 ← 위조 성공 + requestUrl https://evil.example.com/api/echo + + ★ Traefik 을 거치지 않으면 헤더 위조가 그대로 통한다. + trustedIPs 와 internalProxies 가 둘 다 '대역'을 믿기 때문. + +=== [2] 적용한 것 === + deploy/lab/k8s/traefik-forwarded-headers.yaml — 192.168.122.0/24 제거 + "--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16" + "--entryPoints.websecure.forwardedHeaders.trustedIPs=10.42.0.0/16" + + deploy/lab/k8s/echo-network-policy.yaml — Traefik 파드에서만 8081 허용 +[{"from":[{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"kube-system"}},"podSelector":{"matchLabels":{"app.kubernetes.io/name":"traefik"}}}],"ports":[{"port":8081,"protocol":"TCP"}]},{"from":[{"ipBlock":{"cidr":"10.42.0.1/32"}},{"ipBlock":{"cidr":"10.42.1.1/32"}}],"ports":[{"port":8081,"protocol":"TCP"}]}] + +=== [3] 차단 후 — 정상 경로 (계속 동작해야 함) === + x-forwarded-proto https + x-forwarded-port 443 + x-forwarded-host app1.hyeonworks.com + x-real-ip 100.123.124.30 + x-forwarded-server traefik-5d6fcf895-wpfhr + --- 앱이 해석한 값 + scheme https + secure True + serverName app1.hyeonworks.com + serverPort 443 + remoteAddr 100.123.124.30 + localAddr 10.42.0.14 + requestUrl https://app1.hyeonworks.com/api/echo + +=== [4] 차단 후 — 우회 시도 === + HTTP 000 / curl exit 7 + HTTP 000 / curl exit 7 + + ★ curl exit 7 = Failed to connect. 연결 자체가 성립하지 않는다. + +=== [5] 파드 건강 상태 (probe 가 차단되지 않았는지) === + echo-54dbd94986-8jmdb 1/1 Running restarts=0 + echo-54dbd94986-lfltk 1/1 Running restarts=0 diff --git a/docs/evidence/two-hop-proxy-headers/README.md b/docs/evidence/two-hop-proxy-headers/README.md index d4d98d8..f70abd1 100644 --- a/docs/evidence/two-hop-proxy-headers/README.md +++ b/docs/evidence/two-hop-proxy-headers/README.md @@ -164,3 +164,36 @@ curl -s http://192.168.122.11/api/echo \ 업그레이드하는 동안 구 Traefik 파드가 함께 살아 있다.** 이 시점에 측정하면 옛 파드가 응답해 "고쳤는데 안 바뀌었다"고 오해하게 된다. `x-forwarded-server` 값의 파드 이름으로 어느 파드가 응답했는지 확인해야 한다. + +--- + +## 프록시 우회 차단 (2026-09-03 16:16 KST) + +`05-networkpolicy.txt` + +헤더 신뢰를 켠 뒤 남아 있던 구멍을 실증하고 막았다. + +**차단 전** — 클러스터 안에서 Traefik을 우회해 앱에 직접 요청하면 +`serverName: evil.example.com`, `remoteAddr: 1.2.3.4`로 **위조가 성립했다.** + +**적용한 것** + +| 파일 | 변경 | +|---|---| +| `traefik-forwarded-headers.yaml` | `192.168.122.0/24` 제거 (SNAT 때문에 도달 불가한 대역) | +| `echo-network-policy.yaml` | Traefik 파드에서만 8081 허용 (라벨 기준) | + +**차단 후** + +``` +정상 경로 scheme=https, remoteAddr=100.123.124.30 동작 +우회 시도 HTTP 000 / curl exit 7 연결 거부 +파드 상태 1/1 Running, restarts=0 probe 정상 +``` + +`exit 7`은 curl의 "Failed to connect"다. HTTP 403이 아니라 +**TCP 연결 자체가 성립하지 않았다**는 뜻이다. + +`restarts=0`이 중요하다. NetworkPolicy에서 kubelet probe 경로를 빠뜨리면 +probe가 실패해 파드가 재시작 루프에 빠진다. 노드의 cni0 주소 +(`10.42.0.1`, `10.42.1.1`)를 `/32`로 허용해 이를 피했다. diff --git a/docs/two-hop-proxy-header-contract.md b/docs/two-hop-proxy-header-contract.md index 662ed20..4d3292f 100644 --- a/docs/two-hop-proxy-header-contract.md +++ b/docs/two-hop-proxy-header-contract.md @@ -714,24 +714,82 @@ curl -s https://app1.hyeonworks.com/api/echo \ `$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 클라이언트가 넣은 값이 사슬 앞부분에 남아 신뢰 경계가 무너진다. -### 잔여 위험 — 프록시 우회 경로 +### 프록시 우회 경로 차단 -앱이 헤더를 신뢰하게 됐으므로, **Traefik을 거치지 않고 파드에 직접 도달할 수 -있으면 위조가 가능하다.** 클러스터 안에서는 Service ClusterIP로 접근할 수 -있다. +앱이 헤더를 신뢰하게 되면 **Traefik을 거치지 않고 파드에 직접 도달할 수 +있는 경로가 곧 구멍**이 된다. 클러스터 안에서는 Service ClusterIP로 접근할 +수 있으므로 실제로 위조가 성립했다. ```bash -# 클러스터 내부에서 (위조 가능함을 확인) -kubectl -n header-lab run t --rm -it --image=curlimages/curl --restart=Never -- \ - curl -s http://echo:8081/api/echo -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-For: 1.2.3.4' +kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \ + curl -s http://echo:8081/api/echo \ + -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4' ``` -**NetworkPolicy로 Traefik에서 오는 트래픽만 허용하는 것이 정석이다.** -이 클러스터는 kube-router 내장 컨트롤러가 있어 적용 가능하다. -아직 적용하지 않았으며, `feature/keycloak-header-spoofing-defense`에서 -다룰 항목이다. +``` +serverName evil.example.com ← 위조 성공 +remoteAddr 1.2.3.4 ← 위조 성공 +requestUrl https://evil.example.com/api/echo +``` ---- +**두 신뢰 설정이 모두 "대역"을 믿기 때문**이다. + +| 계층 | 신뢰 범위 | 지정 방식 | +|---|---|---| +| Traefik `trustedIPs` | 파드 대역 전체 | IP 대역 | +| 앱 Tomcat `internalProxies` | 사설 대역 전체 (기본 정규식) | IP 정규식 | + +IP로는 Traefik을 특정할 수 없다. **파드 IP가 재시작마다 바뀌기 때문**이다 +(측정 중 실제로 `10.42.0.8` → `10.42.1.12`로, 노드까지 옮겨갔다). + +**해결 — NetworkPolicy는 IP가 아니라 라벨로 지정한다.** + +| | | +|---|---| +| 저장소 파일 | `deploy/lab/k8s/echo-network-policy.yaml` | + +```yaml + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik # ← IP 가 아니라 라벨 + ports: + - protocol: TCP + port: 8081 +``` + +`namespaceSelector`와 `podSelector`를 **같은 리스트 항목**에 두면 AND로 +결합된다. 별개 항목으로 나누면 OR이 되어 kube-system 전체가 허용되므로 +주의한다. + +**kubelet probe를 위한 규칙이 별도로 필요하다.** readiness/liveness는 파드가 +아니라 노드에서 오므로 위 규칙에 걸리지 않는다. 빠뜨리면 probe가 실패하고 +**파드가 재시작 루프에 빠진다.** + +```yaml + - from: + - ipBlock: { cidr: 10.42.0.1/32 } # kc-lab-1 의 cni0 + - ipBlock: { cidr: 10.42.1.1/32 } # kc-lab-2 의 cni0 +``` + +probe의 출발지는 **노드의 flannel 브리지(cni0)** 이고, 각 노드 `/24`의 첫 +주소다. `/32`로 정확히 지정해야 한다 — `10.42.0.0/16`으로 넓히면 임의의 +파드가 다시 들어와 정책이 무의미해진다. + +**적용 후 확인** + +``` +정상 경로 scheme=https, remoteAddr=100.123.124.30 계속 동작 +우회 시도 HTTP 000 / curl exit 7 연결 자체가 거부됨 +파드 상태 1/1 Running, restarts=0 probe 정상 +``` + +**"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.** +앞의 것만 하면 이 구멍이 남는다. ## 12. 증거