NO_PROXY は標準ではない — ツールごとに異なる3つの解釈
一言でいうと
プロキシの環境変数は約束事にすぎず、標準ではないので、同じNO_PROXYの1行を、curl・Python urllib・requestsが、互いに違うように読みます。顧客の現場では、設定を「正しく」書くことより、ツールごとに実際の経路を測って、証拠として残すことが先です。
なぜ必要なのか
納品の初日に、顧客のセキュリティチームが、設定を3行送ってきました。「社内標準です。このまま入れてください」。
HTTP_PROXY=http://127.0.0.1:3128
HTTPS_PROXY=http://127.0.0.1:3128
NO_PROXY=corp.example,127.0.0.0/8
入れたあと、連携ツールのヘルスチェック(curl)は緑なのに、Pythonで作ったメトリクスの収集ツールは、社内APIから403を受け取り始めました。外部のSaaSに出ていくべきrequestsの呼び出しは、かえってプロキシを飛ばして、名前解決で落ちました。同じ3行を読んだのに、3つのプログラムが、3通りに動いたのです。誰かが間違っているのではありません。http_proxyは、多くのツールが共有する慣習にすぎず、以下で引用するcurl・Python・requestsのドキュメントも、それぞれ自分の規則だけを説明しています。共通の文法を決めている場所がないので、実装ごとに規則が少しずつ違い、その違いが、顧客のネットワークの中で、一度に表に出ます。
FDEが、この状況で「設定をこう変えればよいです」と先に言うのは危険です。顧客側の担当者は、curlで確認して「こちらは正常です」と答えるでしょうし、その言葉も事実だからです。必要なのは、どのリクエストがプロキシを通ったのかを、記録で見せる証拠です。
どう動くのか
以下は、このラボのイメージで、記録するダミーのプロキシを立ち上げて、直接測った結果です(実測: curl 8.5.0、Python 3.12.3、requests 2.32.3)。
1. 変数名の大文字・小文字。curlのマニュアルのENVIRONMENTの節は、小文字と大文字のどちらも読むが、http_proxyだけは小文字でしか受け付けないと書いています。この例外がなぜ必要なのかは、urllib.requestのドキュメントが説明しています。CGI環境では、クライアントが送ったProxy:ヘッダーが、HTTP_PROXYという環境変数として注入されることがあるからです。そのため、Pythonは、REQUEST_METHODが設定されたCGI環境でだけ、大文字のHTTP_PROXYを無視し、普段は大文字も受け付けます。実測でもそのように動作しました。結果として、大文字のHTTP_PROXYだけを入れると、curlは直接、Pythonの2つはプロキシに行きます。小文字と大文字の両方があれば、小文字が勝ちます。これは、curlのマニュアルとurllibのドキュメントが、同じことを言っています。
ALL_PROXYも分かれます。curlのマニュアルは、スキームごとの変数がないときに、これを使うと書き、requestsのドキュメントも、all_proxyを読む変数として挙げています。実測では、2つはプロキシに行きましたが、urllibは、ALL_PROXYだけがあるとき、直接出ていきました。
2. NO_PROXYの名前の照合。curlのマニュアルは、各項目を「そのホスト自身か、そのドメインに属する名前」として照合すると説明し、local.comがwww.local.comには一致するが、www.notlocal.comには一致しないと例を挙げています。urllibも、ドットの境界を守ります。requestsは違います。実測では、NO_PROXY=corp.exampleが、saascorp.exampleまで直接送りました。文字列の末尾が同じかどうかだけを見るからです。先頭のドット(.corp.example)は、3つのツールすべてが、api.corp.exampleとcorp.example自身に一致し、saascorp.exampleには一致しませんでした。一方、*.corp.exampleのようにアスタリスクを付けた項目は、3つのツールすべてが、何にも一致できませんでした。アスタリスクは、リスト全体が*の1文字のときだけ、「すべて直接」の意味になり、*,localhostのように混ぜると、アスタリスクの力が失われます(実測)。
3. IP・CIDR・ポート。curlは、7.86.0から、127.0.0.0/8のようなCIDR表記を受け付けると、マニュアルに書かれています。requestsも、IPアドレスのホストには、CIDRを適用しました。urllibは、CIDRを知りません。文字列としてだけ比べるので、127.0.0.2をプロキシに送りました。ポートは逆です。urllibのドキュメントは、some.host:8080のように、ポートを付けた項目を許可すると書いていて、実際に一致しましたが、curlは、ポートが付いた項目に一致できず、requestsは、IPにポートを付けると一致できず、名前にポートを付けると一致しました。また、NO_PROXY=127.0.0.1は、localhostに一致しませんでした。3つのツールすべてが、名前をアドレスに解決して比べなかったという意味です(実測)。
4. コードが渡したプロキシ。requestsのドキュメントのProxiesの節は、session.proxiesに入れた値が、環境変数のプロキシに上書きされることがあるので、確実に使うには、リクエストごとにproxies引数で渡すよう勧めています。ところが、リクエストごとに渡したproxiesには、NO_PROXYが適用されませんでした(実測)。設定ファイルのプロキシを引数で渡す連携ツールは、NO_PROXYをどれだけ直しても、社内APIに直接行けません。
| 同じ条件 | curl | urllib | requests |
|---|---|---|---|
大文字のHTTP_PROXYだけ |
直接 | プロキシ | プロキシ |
NO_PROXY=corp.example → saascorp.example |
プロキシ | プロキシ | 直接 |
NO_PROXY=127.0.0.0/8 → 127.0.0.2 |
直接 | プロキシ | 直接 |
NO_PROXY=127.0.0.2:8080 → 127.0.0.2:8080 |
プロキシ | 直接 | プロキシ |
現場での姿
顧客のネットワークでは、原因がいつも別の症状に見えます。プロキシが社内の宛先を403で拒否すれば、「API認証が壊れた」と見え、外部の呼び出しがプロキシを飛ばせば、「DNSがおかしい」と見えます。ヘルスチェックがcurlなので緑だと、みんながアプリケーションを疑います。そんなとき、プロキシのアクセス記録の1行が、議論を終わらせます。そのリクエストがプロキシに届いたのか、届いていないのか、です。
2つ目によくある姿は、「セキュリティチームの標準」と「ツールの現実」がぶつかることです。標準の文書にCIDRで書かれた社内の帯域を、urllibベースのツールが理解できないからといって、標準を直せとは言えません。FDEは、標準を守りながら、3つのツールがすべて理解する共通項(先頭のドットのドメイン、明示したIPの一覧、小文字と大文字の両方)を、もう一式書き加え、その理由を記録に残します。
実務で本当に大切なこと
- 設定を推測せず、測ります。プロキシ側に記録が残るダミーのプロキシをローカルに立ち上げれば、顧客のプロキシへのアクセス権限がなくても、ツールごとの判断を確認できます。
- 共通項として使います。小文字・大文字の両方、ドメインは先頭のドット、IPはCIDRと一緒に個別のアドレスも、ポートは付けません。
- コードがプロキシを直接渡すなら、
NO_PROXYは、コードが面倒を見る必要があります。環境の別のプロキシが混ざらないようにすることも、あわせて行います。 - 確認の結果は、ツール名とバージョンを付けて残します。バージョンが変われば、規則も変わったことがあります(curlのCIDRのサポートがその例です)。
次のラボですること
記録するダミーのプロキシと社内APIを立ち上げて、顧客の設定の障害を再現し、用意されたケース18個を、3つのツールで直接測って、表に残します。そのあと、3つのツールが同じ経路に行く設定を作り、proxies引数を使う連携ツールを直し、最後に、どんな設定ファイルでも、ツールごとの経路を実際に測って判定する探針を作ります。