기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
94 lines
8.5 KiB
Markdown
94 lines
8.5 KiB
Markdown
---
|
|
kind: REFERENCE
|
|
slug: overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it
|
|
title: 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
|
|
topic: lab-entry-path-and-measurement-integrity
|
|
topicName: 실험대의 진입 경로
|
|
project: virtualization
|
|
status: 게시 전
|
|
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
|
source:
|
|
- final/document.md#209-nginx-keycloak-lab-conf-스티키-스위치와-신뢰-경계
|
|
- final/document.md#259-x-forwarded--와-신뢰-경계
|
|
- final/document.md#207-lab-edge-dnat-nft-dnat-파일이-자기-안에-적어-둔-네-가지
|
|
- final/document.md#206-이-부의-출처와-범위
|
|
---
|
|
|
|
# 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
|
|
|
|
맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다. nginx 에서는 `$proxy_add_x_forwarded_for` 가 아니라 `$remote_addr` 를 쓴다는 뜻이다. 커널 쪽에도 같은 계약의 절반이 있어서, 경계 앞에서는 출발지 주소를 바꾸지 않는다.
|
|
|
|
## 관계
|
|
|
|
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
|
|
이 기준이 말하는 경계가 두 홉 가운데 어느 쪽인지를 그 글이 먼저 정한다.
|
|
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
|
|
이 기준이 적용되는 설정을 실제로 쓰는 절차다. 헤더 네 줄과 DNAT 규칙이 거기 있다.
|
|
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
|
그 이동으로 경계가 호스트에서 엣지 게스트로 옮겨 갔다.
|
|
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
|
|
경계 앞에 한 겹을 더 세우지 않기로 한 결정이다. 그 결정의 이유가 이 실험대가 재려는 계약이다.
|
|
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
|
|
경계 프록시가 아닌 경로를 막는 일이 방화벽의 몫이라는 것을 그 기록이 보여 준다.
|
|
|
|
## 목적
|
|
|
|
`X-Forwarded-For` 는 누구나 보낼 수 있는 평범한 HTTP 헤더다. 값을 믿을 만하게 만드는 것은 헤더 이름이 아니라 그 값을 쓴 주체이고, 쓰는 주체를 하나로 좁히는 것이 이 기준이다. 맨 바깥 프록시가 클라이언트의 값을 이어 붙이면 위조된 값이 사슬 앞에 남고, 그러면 뒤쪽의 어느 것도 그 헤더를 근거로 쓸 수 없다.
|
|
|
|
설정 정본이 그 줄 위에 이유를 적어 두었다. 원문은 영어이고 그대로 옮긴다.
|
|
|
|
$remote_addr, not $proxy_add_x_forwarded_for. This is the trust boundary: a client-supplied X-Forwarded-For must be discarded, not extended, or nothing downstream can rely on the value.
|
|
|
|
§206 은 설정 원본의 주석 59줄을 대조하면서 이 저장소 어디에도 없는 것 둘을 셌고, 이 설명이 그중 하나다. §189 과 §190 은 그 줄을 옮겨 적기만 하고 왜 그 형태여야 하는지는 적지 않았다.
|
|
|
|
## 규칙
|
|
|
|
### 1. 맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다
|
|
|
|
nginx 에서는 `proxy_set_header X-Forwarded-For $remote_addr;` 이고 `$proxy_add_x_forwarded_for` 가 아니다. 둘의 차이는 클라이언트가 보낸 값을 사슬 앞에 남기느냐 버리느냐에 있다. `proxy_set_header` 가 값을 이어 붙이지 않고 덮어쓰는 지시어인 이유도 여기에 있다.
|
|
|
|
### 2. `X-Forwarded-Proto` 와 `X-Forwarded-Host` 도 같은 규칙으로 다룬다
|
|
|
|
셋 다 프록시가 뒤쪽 서버에게 원래 클라이언트가 어땠는지 알려 주는 관례적 헤더이고, 클라이언트가 임의로 보낼 수 있다. 하나만 덮어쓰고 나머지를 이어 붙이면, 뒤쪽 서버는 클라이언트가 보낸 값을 그대로 근거로 삼는다. 공격자가 `X-Forwarded-Host` 를 조작해 인증 흐름을 자기 도메인으로 돌릴 수 있는 것도 이 경로다.
|
|
|
|
### 3. 경계 앞에서는 출발지 주소를 바꾸지 않는다
|
|
|
|
호스트의 DNAT 파일은 DNAT 만 걸고 SNAT 는 걸지 않는다. 파일 자신이 「DNAT only, never SNAT」이라고 적고 그 이유를 바로 뒤에 붙였다. masquerade 를 걸면 출발지가 다시 쓰여 엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 되고, 그러면 이 실험대가 재는 `X-Forwarded-For` 계약이 조용히 무효가 된다. SNAT 없이도 응답이 돌아오는 것은 게스트의 기본 경로가 호스트이기 때문이다. 응답이 호스트를 다시 지나고, conntrack 이 변환을 알아서 되돌린다. 경계 앞에서는 출발지를 바꾸지 않고 경계에서는 클라이언트가 준 값을 버린다는 두 문장이 한 계약이다.
|
|
|
|
### 4. 경계가 어디인지는 「그 앞에 우리가 통제하지 않는 것이 있는가」로 가른다
|
|
|
|
이름에 엣지가 붙었다고 해서 그 프록시가 경계가 되는 것은 아니다. 우리가 통제하지 않는 쪽에서 요청을 직접 받는 첫 프록시가 경계이고, 그 한 대만 이 기준을 따른다. 이 실험대에서는 엣지 게스트의 nginx 가 그렇고, 클라우드라면 ALB 가 같은 곳에 선다.
|
|
|
|
### 5. 프록시를 한 겹 더 넣거나 옮길 때 이 기준을 다시 적용한다
|
|
|
|
설정을 처음 쓸 때보다 구성을 바꿀 때 이 기준이 실제로 쓰인다. 이 실험대가 엣지를 호스트에서 게스트로 옮겼을 때 경계도 함께 옮겨 갔다. 공개 터널을 앞에 붙였다면 경계가 Cloudflare 엣지로 한 번 더 옮겨 갔을 것이다. 옮겨 간 뒤에도 옛 경계가 클라이언트의 값을 이어 붙이고 있으면 사슬은 다시 믿을 수 없게 된다.
|
|
|
|
## 적용 조건
|
|
|
|
- 신뢰 경계에 선 맨 바깥 프록시 한 대. 이 실험대에서는 엣지 게스트의 nginx 이고, 클라우드라면 ALB 가 같은 곳에 선다
|
|
- 대상 헤더 : `X-Forwarded-For` · `X-Forwarded-Proto` · `X-Forwarded-Host` 셋. 셋 다 관례적 헤더이고 클라이언트가 임의로 보낼 수 있다
|
|
- 적용 시점 : 설정을 처음 쓸 때가 아니라 프록시를 한 겹 더 넣거나 옮길 때
|
|
- 경계 판정 : 그 프록시 앞에 우리가 통제하지 않는 것이 있을 때
|
|
|
|
## 예외
|
|
|
|
경계 안쪽의 두 번째 홉은 반대다. 이 실험대의 Traefik 처럼 신뢰하는 프록시 뒤에 서는 것은 앞이 쓴 값을 이어받아야 하고, 거기서 덮어쓰면 원래 클라이언트 주소가 없어진다. `$proxy_add_x_forwarded_for` 자체가 틀린 값은 아니고, 쓰는 곳이 따로 정해져 있다. 경계에서 쓰면 클라이언트가 위조한 값을 그대로 통과시킨다.
|
|
|
|
이 실험대는 그 두 번째 홉을 아직 재지 않았다. 저장소에 적힌 forwarded 헤더 계약이 1홉을 가정한 것이라 `nginx → Traefik` 2홉과 어긋나고, Traefik 이 앞이 쓴 값을 덮어쓰는지 신뢰하는지 이어 붙이는지에 따라 결과가 갈린다. 이 실험대가 가장 먼저 실측할 항목이 그것이다.
|
|
|
|
L4 통과 구성에는 적용되지 않는다. NLB 처럼 TCP 를 그대로 흘리면 원본 IP 가 보존되어 헤더가 아예 필요 없다. 그때 원본 주소를 알리는 데 쓰는 것은 PROXY protocol 이라 이 기준의 대상이 아니다.
|
|
|
|
경계 앞에 CDN 이나 터널이 있으면 그 공급자의 헤더가 정본이 된다. Cloudflare 라면 `CF-Connecting-IP` 이고, 그때는 그 헤더를 놓고 이 기준을 다시 세운다.
|
|
|
|
이 기준만으로 신뢰가 완성되지 않는다. 경계 프록시를 거치지 않는 경로로 뒤쪽에 직접 닿을 수 있으면 헤더를 어떻게 쓰든 소용이 없다. 그 경로를 막는 것은 방화벽과 네트워크 배치의 일이고, 이 실험대에서는 게스트가 libvirt NAT 뒤에 있는 것이 그 몫을 한다.
|
|
|
|
## 예시
|
|
|
|
- 경계의 nginx : `proxy_set_header X-Forwarded-For $remote_addr;`
|
|
- 경계 안쪽 두 번째 홉 : `$proxy_add_x_forwarded_for` 로 앞이 쓴 값을 이어받는다
|
|
- 호스트 커널 : DNAT 만 걸고 masquerade 는 걸지 않는다
|
|
- masquerade 를 걸었을 때 엣지가 보는 클라이언트 주소 : `192.168.122.1` 하나
|
|
- 경계 앞에 Cloudflare 가 있을 때의 정본 헤더 : `CF-Connecting-IP`
|
|
- 클라이언트가 보낸 값을 이어 붙인 사슬 : 위조된 값이 앞에 남아 뒤쪽에서 근거로 못 쓴다
|
|
- L4 통과 구성 : 헤더가 필요 없고 PROXY protocol 을 쓴다
|