1. はじめに
OCI の Flexible Load Balancer(以下 FLB)を HTTP リスナーで挟むと、通信は L7 で終端されます。バックエンドのAPサーバから TCP レベルで見える送信元は LB になるため、クライアントのIPを知りたければ LB が付けるヘッダを読むことになります。
候補は 2 つあります。公式ドキュメント1によれば、FLB は X-Real-IP と X-Forwarded-For の両方を常に付けます。X-Real-IP の説明はこうです。
クライアントのIPアドレスを識別します。ロード・バランサ・サービスの場合、クライアントとは最後のリモート・ピアです。
X-Forwarded-For については、LB が最後のリモートピアのアドレスを末尾に追加する(append)とあります。
では、どちらを読むべきでしょうか。 実装に落とすとなると、値が入るかどうかだけでは足りません。クライアントがその値を偽装できるかどうかまで分かっていないと、アクセス制御やレート制限の判定には使えないからです。そこは公式ドキュメントを読んでも分かりませんでした。
そこで 端末 → OCI-FLB → APサーバ の構成を実際に作り、両方のヘッダを実測して比べます。
1.1. 検証ゴール
| # | 確認すること | OK 条件 |
|---|---|---|
| 1 | 素のリクエストで X-Real-IP に入る値 |
端末のグローバルIPと一致する |
| 2 | クライアントが X-Real-IP を送ってきたときの LB の扱い |
上書き / 重複追加 / そのまま通す のどれかが判別できる |
| 3 |
X-Forwarded-For が append か replace か |
クライアント指定値が残るかどうかが判別できる |
| 4 | HTTPS リスナーで挙動が変わるか | HTTP リスナーとの差が、あるなら何のヘッダに出るかが判別できる |
1.2. 結論
-
X-Real-IPには端末のグローバルIPが入る。LB が必ず上書きするので、クライアントは改ざんできない -
X-Forwarded-Forは末尾に追記される。左端にクライアントの指定値が残るため、左端は信用できない -
HTTPS リスナーでも上の 2 つは変わらない。差が出るのは
X-Forwarded-Proto/X-Forwarded-Port/X-Forwarded-Hostの 3 つだけ - FLB 配下でクライアントIPを見るなら
X-Real-IPを読む
1 番目の上書きの挙動は、公式ドキュメントに記載がありませんでした。
本記事のグローバルIPはマスクしてあります。端末のグローバルIPを 203.0.113.7、詐称に使った値を 192.0.2.1 と表記していますが、どちらも RFC 5737 のドキュメント用アドレスです。実測は実在のグローバルIPで行っており、結果は変わりません。
2. 検証環境
| 項目 | 値 |
|---|---|
| リージョン | ap-tokyo-1 |
| ロードバランサ | Flexible shape(最小 10 Mbps / 最大 10 Mbps) |
| リスナー | HTTP :80 と HTTPS :443 の 2 つ(同一 LB・同一バックエンドセット) |
| HTTPS の証明書 | 自己署名(LB で SSL 終端し、バックエンドへは平文 HTTP :80 で転送) |
| VCN | 10.30.0.0/16 |
| パブリックサブネット | 10.30.1.0/24(LB を配置) |
| プライベートサブネット | 10.30.2.0/24(APサーバを配置) |
| APサーバ | VM.Standard.E5.Flex(1 OCPU / 8 GB) |
| APサーバ OS | Oracle Linux 9 |
| エコーサーバ | Python 3 標準ライブラリのみ |
| OCI CLI | 3.86.0 |
3. 構成・実装
3.1. 全体構成
APサーバはプライベートサブネットに置き、LB からのみ 80/tcp を受け付けます。LB 側の NSG は、検証に使う端末のグローバルIPからの 80/tcp と 443/tcp だけを許可しました。
リスナーを 2 つ作ったのは、検証ゴール 4 のためです。同一の LB に HTTP と HTTPS の両方を載せ、どちらも同じバックエンドセットに向けています。 こうするとリスナーのプロトコル以外の条件が揃うので、差が出たらそれはリスナー起因だと切り分けられます。
なお OCI の HTTPS リスナーは、プロトコルとしては HTTP を指定し、そこに証明書を紐づけて作ります。HTTPS というプロトコル指定はありません。SSL は LB で終端し、バックエンドへは平文の HTTP で転送されます。
3.2. エコーサーバ
APサーバには、受け取った全ヘッダと TCP レベルの送信元を JSON で返すだけのサーバを cloud-init で常駐させました。Oracle Linux 9 標準の Python 3 だけで動くので、追加パッケージは要りません。レスポンス本文に結果が出るため、APサーバに SSH でログインせずに測定できます。
class Handler(BaseHTTPRequestHandler):
def _handle(self):
seen = set()
headers = {}
for key in self.headers.keys():
lk = key.lower()
if lk in seen:
continue
seen.add(lk)
vals = self.headers.get_all(key)
headers[lk] = vals[0] if len(vals) == 1 else vals
body = {
"tcp_source_ip": self.client_address[0],
"tcp_source_port": self.client_address[1],
"request_line": self.requestline,
"headers": headers,
}
payload = json.dumps(body, ensure_ascii=False, indent=2).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
3.3. なぜ get_all() を使うか
ここが検証ゴール 2 の判定を左右します。
ヘッダを {k.lower(): v for k, v in self.headers.items()} のように dict へ素直に入れると、同名ヘッダが 2 つ届いたとき後勝ちで片方が消えます。この実装でテストB(クライアントが X-Real-IP を詐称)を行うと、LB の値が返ってきたときに次の 2 つを区別できません。
- LB がクライアントの値を置換した
- LB が 2 つ目を追加し、dict の後勝ちで LB の値だけが残った
そこで self.headers.get_all(key) を使い、複数あればリスト・単一なら文字列で返す実装にしました。これで「返り値がリストかどうか」で置換と追加を切り分けられます。
なお Oracle Linux 9 は firewalld が有効で 80/tcp が閉じているため、cloud-init でポートを開けています。firewalld を止めるのではなくポートを開ける形にし、OCI 側は NSG で守ります。
runcmd:
- firewall-cmd --permanent --add-port=80/tcp
- firewall-cmd --reload
- systemctl daemon-reload
- systemctl enable --now echo-server.service
4. 手順・実行
X-Real-IP の値を照合するために、端末のグローバルIPを先に確認しておきます。
Invoke-RestMethod 'https://api.ipify.org?format=json'
そのうえで、リクエストヘッダだけを変えた 3 パターンを端末から投げます。LB のバックエンドがヘルスチェックで OK になったことを確認してから実行しました。
# テストA: 素のリクエスト
curl -s http://<LB_PUBLIC_IP>/
# テストB: クライアントが X-Real-IP を詐称
curl -s -H "X-Real-IP: 192.0.2.1" http://<LB_PUBLIC_IP>/
# テストC: クライアントが X-Forwarded-For を詐称
curl -s -H "X-Forwarded-For: 192.0.2.1" http://<LB_PUBLIC_IP>/
同じ 3 パターンを HTTPS リスナーにも投げます。証明書が自己署名なので -k を付けています。
curl -sk https://<LB_PUBLIC_IP>/
curl -sk -H "X-Real-IP: 192.0.2.1" https://<LB_PUBLIC_IP>/
curl -sk -H "X-Forwarded-For: 192.0.2.1" https://<LB_PUBLIC_IP>/
5. 実行結果
5.1. テストA: 素のリクエスト
{
"tcp_source_ip": "10.30.1.48",
"tcp_source_port": 3944,
"request_line": "GET / HTTP/1.1",
"headers": {
"host": "<LB_PUBLIC_IP>",
"x-real-ip": "203.0.113.7",
"x-forwarded-for": "203.0.113.7",
"x-forwarded-proto": "http",
"x-forwarded-port": "80",
"x-forwarded-host": "<LB_PUBLIC_IP>:80",
"user-agent": "curl/8.19.0",
"accept": "*/*"
}
}
x-real-ip は事前に確認した端末のグローバルIPと一致しました。ここは公式ドキュメントどおりです。
x-forwarded-for が x-real-ip と同じ値になっているのも公式ドキュメントどおりで、クライアントのリクエストに X-Forwarded-For が含まれない場合は X-Real-IP と同じ値になると書かれています。
tcp_source_ip が 10.30.1.48 になっている点も見てください。これは LB の内部IPです。L7 で終端されるため、APサーバから TCP レベルではクライアントが見えず、ヘッダを読むしかないことがここから確認できます。
5.2. テストB: クライアントが X-Real-IP を詐称
| 項目 | 値 |
|---|---|
| クライアントが送った値 | 192.0.2.1 |
| APサーバが受け取った値 | 203.0.113.7 |
"x-real-ip": "203.0.113.7",
クライアントが指定した 192.0.2.1 は消え、LB が実際のクライアントIPで上書きしていました。
返り値がリストではなく単一の文字列である点が重要です。3.3 章のとおり、このエコーサーバは同名ヘッダが複数あればリストで返します。文字列で返っているということは、APサーバに届いた X-Real-IP は 1 つだけです。つまり LB は 2 つ目を追加したのではなく、置換しています。
5.3. テストC: クライアントが X-Forwarded-For を詐称
| 項目 | 値 |
|---|---|
| クライアントが送った値 | 192.0.2.1 |
| APサーバが受け取った値 | 192.0.2.1, 203.0.113.7 |
"x-forwarded-for": "192.0.2.1, 203.0.113.7",
クライアントが指定した値が左端に残り、その右に実際のクライアントIPが追記されました。値はカンマ区切りの 1 ヘッダで、2 行に分かれてはいません。
5.4. 結果まとめ(HTTP リスナー)
| テスト | 送ったヘッダ | 受け取った X-Real-IP
|
受け取った X-Forwarded-For
|
|---|---|---|---|
| A | なし | 203.0.113.7 |
203.0.113.7 |
| B | X-Real-IP: 192.0.2.1 |
203.0.113.7 |
203.0.113.7 |
| C | X-Forwarded-For: 192.0.2.1 |
203.0.113.7 |
192.0.2.1, 203.0.113.7 |
5.5. HTTPS リスナーの結果
同じ 3 パターンを HTTPS リスナー(:443)に投げた結果です。テストA の生レスポンスがこちらです。
{
"tcp_source_ip": "10.30.1.55",
"request_line": "GET / HTTP/1.1",
"headers": {
"host": "<LB_PUBLIC_IP>",
"x-real-ip": "203.0.113.7",
"x-forwarded-for": "203.0.113.7",
"x-forwarded-proto": "https",
"x-forwarded-port": "443",
"x-forwarded-host": "<LB_PUBLIC_IP>:443",
"user-agent": "curl/8.21.0",
"accept": "*/*"
}
}
テストA の全ヘッダを HTTP リスナーの結果と並べると、差が出たのは 3 つだけでした。
| ヘッダ | HTTP リスナー(:80) | HTTPS リスナー(:443) | 差 |
|---|---|---|---|
x-real-ip |
203.0.113.7 |
203.0.113.7 |
なし |
x-forwarded-for |
203.0.113.7 |
203.0.113.7 |
なし |
x-forwarded-proto |
http |
https |
あり |
x-forwarded-port |
80 |
443 |
あり |
x-forwarded-host |
<LB_PUBLIC_IP>:80 |
<LB_PUBLIC_IP>:443 |
あり |
host |
<LB_PUBLIC_IP> |
<LB_PUBLIC_IP> |
なし |
tcp_source_ip |
10.30.1.55 |
10.30.1.55 |
なし |
テストB・C も HTTP リスナーと同じ結果でした。HTTPS でもクライアントが送った X-Real-IP: 192.0.2.1 は上書きされて消え、単一の文字列で返っています。X-Forwarded-For は 192.0.2.1, 203.0.113.7 と追記されました。
| テスト | 受け取った X-Real-IP
|
受け取った X-Forwarded-For
|
|---|---|---|
HTTPS-B(X-Real-IP 詐称) |
203.0.113.7 |
203.0.113.7 |
HTTPS-C(X-Forwarded-For 詐称) |
203.0.113.7 |
192.0.2.1, 203.0.113.7 |
6. 考察
6.1. X-Real-IP は上書きされる
テストB の結果から、クライアントが送った X-Real-IP は LB が破棄して実際のクライアントIPで置換すると分かりました。単一ヘッダで届いていることから、追加ではなく置換だと確定できます。
この挙動は公式ドキュメントに記載がありません。実測で確認できた範囲として扱っています。
なお、LB が付ける別のヘッダに X-Request-Id があります。こちらは「クライアントが既に同名ヘッダを送っている場合、LB は値を変更しない」と公式に明記されています2。同じ「LB が付けるヘッダ」でも X-Real-IP とは扱いが逆です。片方の挙動からもう片方を類推しないほうがよさそうです。
6.2. X-Forwarded-For の左端は信用できない
X-Forwarded-For の一般的な解釈は「左端が元のクライアント、右に行くほど経由したプロキシ」です。公式ドキュメントも同じ説明をしているのですが、よく読むと但し書きが付いています。
リクエスト元のクライアントは、受信フィールドの内容が信頼できると仮定すると、リスト内の最初(左端)のIPアドレスです。
「信頼できると仮定すると」が条件です。テストC は、まさにその仮定が成り立たないケースにあたります。クライアントは自分で左端の値を作れて、FLB はそれを消さずに残し、右に本物を追記するだけでした。
つまり X-Forwarded-For の左端を「クライアントIP」として採用する実装は、IP偽装をそのまま受け入れます。アクセス制御やレート制限の判定に使っている場合は影響があります。
X-Forwarded-For を使うなら右端を見ることになりますが、FLB 配下では素直に X-Real-IP を読むほうが簡単です。値が 1 つに定まっており、LB が必ず上書きするので偽装できません。
6.3. HTTPS でもクライアントIPの扱いは変わらない
HTTPS リスナーでも X-Real-IP は上書きされ、X-Forwarded-For は追記されました。改ざん耐性という観点では HTTP リスナーと同じです。
差が出た 3 つは、いずれもリスナーのプロトコルとポートを反映しているだけでした。X-Forwarded-Proto は公式ドキュメントで「クライアントがロード・バランサへの接続に使用したプロトコル(http または https)」と定義されており、実測はその定義どおりです。X-Forwarded-Port と X-Forwarded-Host も同様に、接続に使ったリスナーのポートを反映しています。
今回は同一の LB に両方のリスナーを載せ、同じバックエンドセットに向けました。リスナーのプロトコル以外の条件が揃っているので、この 3 つ以外に差が無いことは、リスナーのプロトコルがクライアントIPの扱いに影響しないことを示しています。
実務上ここで効いてくるのは、SSL が LB で終端され、バックエンドへは平文の HTTP で転送される点です。APサーバ側から見ると HTTP リスナー経由か HTTPS リスナー経由かは接続からは分かりません。クライアントが HTTPS で来たかどうかを知りたい場合は X-Forwarded-Proto を読むことになります。
6.4. 今回の実測が当てはまる範囲
単一の構成で測っているため、以下は今回の結果からは言えません。
- TCP リスナー(L4)での挙動は対象外
- LB の手前に別のプロキシがいる構成は試していない。その場合クライアントが送る
X-Forwarded-Forは「プロキシが付けた正当な値」でありうるため、左端の扱いの判断が変わる - Rule Set でヘッダを操作した場合の挙動は対象外
なお Rule Set で X-Real-IP の挙動を変えられるかというと、これはできません。組込みの Host ヘッダーや X- ヘッダーに対する追加・削除アクションは、値を上書きせず追加または重複させるだけだと公式に書かれています3。さらにヘッダー値にはクライアントIPを埋め込むプレースホルダも使えず、{variable_name} のようなパターンは将来の拡張用に予約されていて現時点では無効とされています4。nginx の $remote_addr に相当する書き方がないため、Rule Set で X-Real-IP を作り替えることはできません。
6.5. 同じ役割のヘッダは製品ごとにある
クライアントIPを origin へ伝えるヘッダは FLB に限った話ではありません。CDN やリバースプロキシがそれぞれ独自のものを持っています。代表的なのが Akamai の True-Client-IP です。
ここで注意したいのは、同じ役割のヘッダでも信用してよいかどうかは製品と設定で変わることです。Akamai の場合、Send True Client IP Header を有効にすると edge server がクライアントIPを origin へ渡します。ヘッダ名は True Client IP Header Name で変更でき、さらに Allow Clients To Set True Client IP Header というトグルが用意されています5。名前のとおり、クライアントがこのヘッダを設定することを許可するかどうかを選ぶ設定です。
つまり Akamai では、そのヘッダを信用してよいかが設定次第ということになります。一方 FLB の X-Real-IP には相当する設定がなく、今回の実測のとおり常に上書きされます。
「クライアントIPを表すヘッダだから信用してよい」とは言えません。信用の根拠は、最後のホップがその値を必ず上書きするかどうかにあります。今回の結論は OCI FLB の X-Real-IP について確かめたものであって、他の製品の似たヘッダにそのまま持ち込めるものではありません。
7. まとめ
| ヘッダ | クライアントによる改ざん | FLB 配下での扱い |
|---|---|---|
X-Real-IP |
できない(LB が必ず上書き) | クライアントIPとして読んでよい |
X-Forwarded-For |
左端は改ざんできる | 左端を採用しない。右端を見る |
tcp_source_ip(TCP の送信元) |
該当なし | LB の内部IPになる。クライアントは分からない |
FLB を L7 リスナーで挟んだ構成でクライアントIPを扱うなら、X-Real-IP を読むのが確実です。LB が必ず上書きするため、クライアントから偽装できません。これは HTTP リスナーでも HTTPS リスナーでも同じでした。
一方 X-Forwarded-For の左端はクライアントが自由に設定できます。公式ドキュメントが「受信フィールドの内容が信頼できると仮定すると」と但し書きを付けているとおりで、その仮定はインターネットに面した LB では成り立ちません。左端をそのまま信用すると偽装が通ります。
HTTP リスナーと HTTPS リスナーで差が出るのは X-Forwarded-Proto / X-Forwarded-Port / X-Forwarded-Host の 3 つだけです。SSL は LB で終端されてバックエンドへは平文で届くため、クライアントが HTTPS で接続したかどうかを知りたい場合は X-Forwarded-Proto を読みます。
8. 参考
-
ロード・バランサ・ヘッダー - Oracle Cloud Infrastructure ドキュメント。FLB が追加・変更するヘッダの一覧と、
X-Real-IP/X-Forwarded-Forの定義。本文の引用はこのページから。 ↩ -
LoadBalancer - OCI Python SDK Documentation。
is_request_id_enabledの説明に、クライアントがX-Request-Idを送っている場合は LB が値を変更しない旨の記載がある。 ↩ -
ルール・セットの管理 - Oracle Cloud Infrastructure ドキュメント。「リクエストおよびレスポンス・ヘッダー・ルール」のノートに、組込みのホスト・ヘッダーや X- ヘッダーを追加・削除しても値は削除もオーバーライドもされず、追加や複製になる旨の記載がある。 ↩
-
AddHttpRequestHeaderRule - OCI Python SDK Documentation。
valueプロパティの説明にvalue cannot contain patterns like {variable_name}. They are reserved for future extensions. Currently, such values are invalid.とある。 ↩ -
Origin Server - Akamai TechDocs。
Send True Client IP Header/True Client IP Header Name/Allow Clients To Set True Client IP Headerの各設定の説明がある。なお本記事は Akamai 側の挙動を実測していない(公式ドキュメントに設定項目の記載があることのみを示している)。 ↩