【Python】【requests】プロキシ環境下でSSLErrorになる原因と対処法

Pythonの`requests`ライブラリをプロキシ環境下で使用しているとしばしば次のエラーに出会います。

```bash
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:997)

During handling of the above exception, another exception occurred:
（略）
urllib3.exceptions.MaxRetryError: HTTPSConnectionPool(host='hogehoge.com', port=443): Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:997)')))

During handling of the above exception, another exception occurred:
（略）
requests.exceptions.SSLError: HTTPSConnectionPool(host='hogehoge.com', port=443): Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1, 
'[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:997)')))
```

これはSSL Cert Verificationに失敗しているために生じます。
SSL Cert Verificationとは、HTTPS リクエストの SSL 証明書の検証のことです。証明書を検証できない場合、SSLError が発生します。
SSL 証明書の検証とは、クライアント側が接続先のサーバーを信頼してよいかを判断することです。SSL 証明書の検証では主に下記のような点をチェックします。

- サーバー証明書がどの認証局で発行されたかを確認する。
- 発行した認証局が信頼できるか確認する。これは、発行した認証局の上位の認証局の上位の認証局の・・・と辿っていき、クライアント側で元々信頼すると設定していた認証局の証明書（主にルート認証局の証明書）に辿り着ければ信頼できるとします。
- 証明書が改ざんされていないか確認します。
- 証明書の有効期限や、無効化されていないか確認します。

詳しく正確な話は別の記事に譲ります。

で、本題なのですが、アクセスしようとしているサイトが信頼できるようなサイトで、HTTPS通信を可能にしていれば、普通SSLErrorは起きません。SSLErrorが起きる場合はサーバー証明書の有効期限が切れているといった場合でしょうか。または、クライアント側のライブラリのバージョンが古すぎて、ルート証明書が古いといった場合でしょうか。ともあれ、あまり見かけることはありません。

ですが、プロキシを介する通信ではSSLErrorが起きます。プロキシを介する通信では、プロキシサーバの証明書を検証する場合があります。プロキシサーバは組織内のみで使用されるため、自己署名証明書（上位の認証局が自分自身）が入っている場合があります。自己署名証明書は信頼できない証明書（＝クライアント側で元々信頼すると設定していない証明書）であるため、SSLErrorが生じます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211712/dc38b961-04b1-eb8a-d351-ab0d33858b17.png)

引用元：[Proxyサーバ - ネットワークスペシャリスト - SE娘の剣 -](https://nw.seeeko.com/archives/50540600.html)

したがって、解決法は以下の2通りになります。

### 1. サーバー証明書の検証をしない

`requests`ライブラリであれば、`requests.get('https://hogehoge.com', verify=False)`のように、検証をしない設定にします。ただし、これはセキュリティ的によろしくないので、あくまで手元の検証作業などのときに使用します。

また、コードをいじらず、環境変数`REQUESTS_CA_BUNDLE`または`CURL_CA_BUNDLE`を空文字にすると`verify=False`に出来るとの情報がありますが、コード内で`verify=True`とされている場合はこの方法が使えないようです。

```python:requests/requests/sessions.py 
if verify is True or verify is None:
    verify = (
        os.environ.get('REQUESTS_CA_BUNDLE') or
        os.environ.get('CURL_CA_BUNDLE') or
        verify
    )
```

### 2. プロキシサーバの証明書をクライアント側で設定する

`requests`ライブラリが参照する証明書は`certifi`ライブラリで管理している証明書であり、OSに設定した証明書ではありません。ライブラリ内では変数`DEFAULT_CA_BUNDLE_PATH`で設定されています。

```python:requests/requests/utils.py
from . import certs
DEFAULT_CA_BUNDLE_PATH = certs.where()
```

```python:requests/requests/adapters.py
    def cert_verify(self, conn, url, verify, cert):
        """Verify a SSL certificate. This method should not be called from user
        code, and is only exposed for use when subclassing the
        :class:`HTTPAdapter <requests.adapters.HTTPAdapter>`.
        :param conn: The urllib3 connection object associated with the cert.
        :param url: The requested URL.
        :param verify: Either a boolean, in which case it controls whether we verify
            the server's TLS certificate, or a string, in which case it must be a path
            to a CA bundle to use
        :param cert: The SSL certificate to verify.
        """
        if url.lower().startswith("https") and verify:

            cert_loc = None

            # Allow self-specified cert location.
            if verify is not True:
                cert_loc = verify

            if not cert_loc:
                cert_loc = extract_zipped_paths(DEFAULT_CA_BUNDLE_PATH)

            if not cert_loc or not os.path.exists(cert_loc):
                raise OSError(
                    f"Could not find a suitable TLS CA certificate bundle, "
                    f"invalid path: {cert_loc}"
                )

            conn.cert_reqs = "CERT_REQUIRED"

            if not os.path.isdir(cert_loc):
                conn.ca_certs = cert_loc
            else:
                conn.ca_cert_dir = cert_loc
        else:
            conn.cert_reqs = "CERT_NONE"
            conn.ca_certs = None
            conn.ca_cert_dir = None

        if cert:
            if not isinstance(cert, basestring):
                conn.cert_file = cert[0]
                conn.key_file = cert[1]
            else:
                conn.cert_file = cert
                conn.key_file = None
            if conn.cert_file and not os.path.exists(conn.cert_file):
                raise OSError(
                    f"Could not find the TLS certificate file, "
                    f"invalid path: {conn.cert_file}"
                )
            if conn.key_file and not os.path.exists(conn.key_file):
                raise OSError(
                    f"Could not find the TLS key file, invalid path: {conn.key_file}"
                )
```

したがって、プロキシサーバの証明書はOSのインストール機能ではなく、`requests`ライブラリが指定する方法で設定する必要があります。

コード中に仕込む場合は、`requests.get('https://hogehoge.com', verify='/path/to/certfile')`のように~~証明書を含むフォルダまたは~~証明書そのもののパスを指定します（フォルダだとうまくいかない）。

環境変数でも指定する事ができ、`requests`ライブラリの場合は`REQUESTS_CA_BUNDLE`または`CURL_CA_BUNDLE`のどちらかに~~証明書を含むフォルダまたは~~証明書そのもののパスを指定します。
証明書の入手は組織のシステム管理者から入手するか、OSにインストールされていればエクスポート機能で出力し、どこか（例えばC:直下）に配置しておきます。
参考：[Python requestsライブラリは認証局の証明書をどう管理する?](https://dev.classmethod.jp/articles/how-to-manage-ca-root-certs-for-requets-library/)

上記の方法で`verify`にパスを設定した場合、デフォルトの`certifi`による証明書設定は外れますので注意してください。
`certifi`の証明書も加えたい場合は、`certifi.where()`で証明書の場所を確認し、`cacert.pem`ファイルをコピーします。拡張子を`.cer`に変更します。`cacert.cer`ファイルをテキストエディタで開き、プロキシサーバの証明書の中身を最後尾に追加します。

あと当たり前ですが、プロキシサーバの情報をセットできているかはチェックしましょう。

```python
import os
import requests

os.environ['http_proxy'] = 'http://user:pass@proxyip.0.0.1:80'
os.environ['https_proxy'] = 'http://user:pass@proxyip.0.0.1:80'

res = requests.get('https://hogehoge.com/')
```

```python
import requests

proxies = {
    'http' : 'http://user:pass@proxyip.0.0.1:80',
    'https' : 'http://user:pass@proxyip.0.0.1:80',
}

res = requests.get('https://hogehoge.com/', proxies=proxies)
```

### ほか参考

- [requests でプロキシサーバーを通してリクエストを投げる](https://odinseye.info/tech/python/requests-with-proxy/)
- [Python requests で SSLError が起きて毎回ググってるのでまとめた](https://qiita.com/sta/items/6d08151fd9b20fa8b319)
- [Pythonのrequestsでverify=Falseを使わずにSSLの検証を無効化する](https://qiita.com/hrappuccino/items/84e3a5113b871037024a)

以上　誤り等あればコメント下さい。
フォルダ指定だとうまくいかないので、原因が分かる方は教えてください。

`requests`ライブラリのコードはApache2.0ライセンスです。
https://github.com/psf/requests/blob/main/LICENSE