企业内网场景下 Python 自定义 CA 证书信任链的正确配置方法
Python 在内网访问 HTTPS 接口时,常遇到 SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate。多数人用 verify=False 绕过验证,但这等于关闭了 TLS 的身份校验。正确做法是将企业 CA 根证书加入 Python 的信任链,同时保留对公网证书的正常校验。
内网自定义 CA 的来源
企业内网的自定义 CA 证书来自两类场景。
内部服务 (API 网关、GitLab、Harbor、监控平台等) 使用内部域名或 IP 地址,公共 CA 无法签发此类证书,企业自建 CA 签发后要求客户端信任其根证书。
网络安全设备 (防火墙、IDS、DLP) 对 HTTPS 流量做 MITM 检查,设备持有自签根证书,经其转发的连接会被重新签名。浏览器通过组策略信任该根证书,Python 不会。
两类场景的本质相同:程序需要信任一个不在公共 CA 信任链里的根证书。
Python 的证书验证机制
Python 的 ssl 模块加载 CA bundle (PEM 格式文件) 作为信任锚。默认路径可通过 ssl.get_default_verify_paths() 查看,各操作系统不同。
requests 库在此基础上使用 certifi 包提供的证书包 (certifi.where()),其中只包含 Mozilla 维护的公共根证书。遇到企业 CA 签名的证书时,在 certifi 证书包中找不到对应的根证书,验证失败。
配置方法
方法一: 环境变量指定合并证书包
requests 读取 REQUESTS_CA_BUNDLE 环境变量, ssl 模块读取 SSL_CERT_FILE。直接指定企业 CA 文件会丢失所有公共根证书,必须先合并:
python
import certifi, shutil
combined_path = "/path/to/combined-ca-bundle.pem"
shutil.copy(certifi.where(), combined_path)
with open(combined_path, "a") as f, \
open("/path/to/company-root-ca.pem") as ca:
f.write("\n")
f.write(ca.read())
bash
export REQUESTS_CA_BUNDLE=/path/to/combined-ca-bundle.pem
优点:不改现有代码。缺点:影响进程内所有 requests 调用;合并文件需随 certifi 更新手动重新生成。
方法二: verify 参数按请求指定
verify 参数只影响当前请求,不影响全局:
python
import requests
r = requests.get(
"https://internal-api.company.com/v1/users",
verify="/path/to/combined-ca-bundle.pem"
)
缺点:每个内网请求都要传参;合并文件维护问题不变。
方法三: 自定义 SSLContext + HTTPAdapter
通过 ssl.SSLContext 精细控制 TLS 配置,挂载到 HTTPAdapter 上按域名匹配:
python
import ssl, certifi, requests
from requests.adapters import HTTPAdapter
ctx = ssl.create_default_context()
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.set_ciphers("ECDHE+AESGCM:DHE+AESGCM:!aNULL:!MD5:!DSS")
ctx.load_verify_locations(certifi.where())
ctx.load_verify_locations("/path/to/company-root-ca.pem")
adapter = HTTPAdapter()
adapter.poolmanager.connection_pool_kw["ssl_context"] = ctx
session = requests.Session()
session.mount("https://internal-api.company.com", adapter)
r = session.get("https://internal-api.company.com/v1/users")
session.mount 按 URL 前缀匹配,内网请求走自定义 Adapter,公网请求走默认 SSLContext。此方法支持 TLS 版本和密码套件控制,适合等保合规场景。
亿牛云代理场景下的证书处理
亿牛云 (16yun.cn) 是企业级代理 IP 服务商,代理地址如 t.16yun.cn:31111,需用户名密码认证。HTTPS 请求经过正向代理时使用 CONNECT 方法建立加密隧道,代理仅转发流量不做 MITM。目标服务器使用公共 CA 证书时,走代理与直连行为一致,校验正常通过。
若目标在内网且使用企业 CA 证书,证书校验仍会失败,需将企业 CA 加入信任链。三种方法均可适用,以 Session + SSLContext 为例:
python
import ssl, certifi, requests
from requests.adapters import HTTPAdapter
ctx = ssl.create_default_context()
ctx.load_verify_locations(certifi.where())
ctx.load_verify_locations("/path/to/company-root-ca.pem")
adapter = HTTPAdapter()
adapter.poolmanager.connection_pool_kw["ssl_context"] = ctx
session = requests.Session()
session.mount("https://", adapter)
session.proxies = {
"http": "http://username:password@t.16yun.cn:31111",
"https": "http://username:password@t.16yun.cn:31111",
}
r = session.get("https://internal-api.company.com/v1/data")
若企业网络设备对代理流量做 MITM 审查,客户端收到的证书由防火墙签发而非目标服务器,需将防火墙 CA 加入信任链。
排查原则:亿牛云正向代理不做 MITM,SSL 错误的根源在客户端信任链配置或服务器端证书链配置,不在代理参数。
多级 CA 证书链
企业 CA 的典型结构为:根 CA 签发中间 CA,中间 CA 签发服务器证书。客户端加载根 CA 即可完成整条链的验证,无需单独加载中间 CA。但若服务器端未发送完整证书链 (缺少中间证书),客户端需手动加载:
bash
# 检查服务器证书链完整性
openssl s_client -connect internal-api.company.com:443 -showcerts
若输出仅含服务器证书而无中间证书,说明服务器配置不完整。客户端可临时补丁:
python
ctx.load_verify_locations("/path/to/company-root-ca.pem")
ctx.load_verify_locations("/path/to/company-intermediate-ca.pem")
根本解决方案是让运维修复服务器端配置,使其发送完整证书链。
容器与 CI 环境
Python 官方 Docker 镜像基于 Debian,不含企业 CA。两种注入方式:
构建时注入:
dockerfile
FROM python:3.11
COPY company-root-ca.pem /usr/local/share/ca-certificates/company-root-ca.crt
RUN update-ca-certificates
RUN python -c "\
import certifi, shutil;\
shutil.copy(certifi.where(), '/tmp/combined.pem');\
with open('/tmp/combined.pem', 'a') as f:\
with open('/usr/local/share/ca-certificates/company-root-ca.crt') as ca:\
f.write(ca.read());\
shutil.copy('/tmp/combined.pem', certifi.where())"
ENV REQUESTS_CA_BUNDLE=/tmp/combined.pem
运行时挂载:
bash
docker run \
-v /path/to/combined-ca-bundle.pem:/etc/ssl/custom-ca.pem \
-e REQUESTS_CA_BUNDLE=/etc/ssl/custom-ca.pem \
my-python-app
CI/CD 环境中,将企业 CA 证书存入 secrets 管理系统,流水线启动时动态合并:
yaml
# GitLab CI 示例
variables:
COMPANY_CA: $CI_COMPANY_CA_PEM
script:
- python merge_ca.py
- export REQUESTS_CA_BUNDLE=/tmp/combined.pem
- python run_tests.py
常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
| DER 文件静默加载失败 | load_verify_locations 只接受 PEM 格式,DER 文件不报错但不生效 |
openssl x509 -inform DER -in ca.cer -out ca.pem |
| 证书链断裂 | 中间 CA 过期,根 CA 有效时整条链仍断 | openssl x509 -in ca.pem -noout -dates 检查各层有效期 |
| 合合文件解析失败 | 两个 PEM 证书之间缺少换行分隔 | 合并时 f.write("\n") 确保分隔 |
| Windows 环境变量路径异常 | 路径含空格或反斜杠 | 用正斜杠或 os.path.abspath() 规范化 |
生产环境配置模板
python
import ssl, certifi, requests
from requests.adapters import HTTPAdapter
from pathlib import Path
def create_ssl_context(ca_paths, min_tls=ssl.TLSVersion.TLSv1_2):
ctx = ssl.create_default_context()
ctx.minimum_version = min_tls
ctx.set_ciphers("ECDHE+AESGCM:DHE+AESGCM:!aNULL:!MD5:!DSS")
ctx.load_verify_locations(certifi.where())
for ca_path in ca_paths:
if not Path(ca_path).exists():
raise FileNotFoundError(f"CA certificate not found: {ca_path}")
ctx.load_verify_locations(ca_path)
return ctx
def create_internal_session(ca_paths, domain_prefix, proxy_config=None):
ctx = create_ssl_context(ca_paths)
adapter = HTTPAdapter()
adapter.poolmanager.connection_pool_kw["ssl_context"] = ctx
session = requests.Session()
session.mount(domain_prefix, adapter)
if proxy_config:
session.proxies = proxy_config
return session
session = create_internal_session(
ca_paths=["/etc/ssl/company-root-ca.pem"],
domain_prefix="https://internal-api.company.com",
proxy_config={
"http": "http://user:pass@t.16yun.cn:31111",
"https": "http://user:pass@t.16yun.cn:31111",
},
)
r = session.get("https://internal-api.company.com/v1/users")
模板包含:TLS 1.2 强制最低版本、弱密码套件禁用、公共证书与企业 CA 合并加载、域名级 Adapter 匹配、可选代理配置、CA 文件路径校验。
总结
核心原则:将企业 CA 加入信任链,而非绕过验证。环境变量方式适合快速验证;verify 参数适合少量内网请求;自定义 SSLContext + HTTPAdapter 适合生产环境精细控制。无论哪种方法,合并证书包而非替换、确认 PEM 格式编码、定期检查 CA 有效期。代理场景下,正向代理不做 MITM,SSL 错误排查方向在客户端信任链或服务器证书链,不在代理参数。