通过HTTPS直接使用IP访问共享主机文件时的SSL证书验证问题
问题背景与现象
我在项目中遇到了这样的场景:数据存放在共享虚拟主机上,网站DNS托管在CloudFlare并启用了代理功能,之前通过域名HTTPS请求下载文件一切正常:
urllib.request.Request( 'https://www.example.org/myfile.zip', headers={...} )
后来为了规避代理DNS的性能问题,我尝试直接用主机IP访问——因为是共享主机,必须在请求头中指定Host字段绑定对应域名。用HTTP协议时完全正常:
urllib.request.Request( "http://123.456.100.1/myfile.zip", headers={'Host': 'www.example.org'} )
但换成HTTPS协议时,直接触发了SSL证书验证失败的错误:
urllib.error.URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: IP address mismatch, certificate is not valid for '123.456.100.1'
本质原因很明确:SSL证书是绑定域名的,直接用IP访问时,默认验证逻辑会检查证书是否对应该IP,自然会出现不匹配的情况。我们需要让SSL验证阶段使用Host头指定的域名,而非URL中的IP。
解决方案(基于urllib)
我们可以自定义一个HTTPSHandler,手动替换SSL验证时使用的主机名,覆盖默认的IP验证逻辑:
import urllib.request import ssl # 自定义HTTPS处理器,用于指定SSL验证的目标域名 class CustomHTTPSHandler(urllib.request.HTTPSHandler): def __init__(self, target_host): self.target_host = target_host # 初始化默认的安全SSL上下文 ssl_context = ssl.create_default_context() super().__init__(context=ssl_context) def https_open(self, req): # 重写HTTPS请求逻辑,使用自定义的连接创建方法 return self.do_open(self._create_ssl_connection, req) def _create_ssl_connection(self, host, timeout=300): # 创建SSL连接,但强制指定验证的主机名为目标域名(而非IP) conn = super().get_connection(host, timeout) # 覆盖连接的host属性,让SSL验证时用我们指定的域名 conn.host = self.target_host return conn # 1. 配置目标域名和IP target_domain = "www.example.org" host_ip = "123.456.100.1" # 2. 创建带自定义处理器的Opener并安装 opener = urllib.request.build_opener(CustomHTTPSHandler(target_domain)) urllib.request.install_opener(opener) # 3. 发起HTTPS请求,指定IP和Host头 req = urllib.request.Request( f"https://{host_ip}/myfile.zip", headers={'Host': target_domain} ) # 4. 执行请求并保存文件 with urllib.request.urlopen(req) as response, open("myfile.zip", "wb") as f: f.write(response.read())
逻辑说明
这个自定义处理器的核心是:在创建SSL连接时,把原本要验证的IP地址替换成Host头对应的域名。这样SSL证书验证就会检查该域名是否在证书的有效范围内,和正常通过域名访问的验证逻辑完全一致,既解决了验证失败的问题,又保留了HTTPS的安全性。
更简洁的替代方案:使用requests库
如果项目允许切换到requests库,实现起来会更清爽,通过自定义SSL适配器即可完成需求:
import requests import ssl from requests.adapters import HTTPAdapter from urllib3.poolmanager import PoolManager class DomainVerifiedSSLAdapter(HTTPAdapter): def __init__(self, verified_domain, **kwargs): self.verified_domain = verified_domain super().__init__(**kwargs) def init_poolmanager(self, *args, **kwargs): # 初始化SSL连接池时,强制指定验证的主机名为目标域名 ssl_context = ssl.create_default_context() kwargs["ssl_context"] = ssl_context kwargs["assert_hostname"] = self.verified_domain return super().init_poolmanager(*args, **kwargs) # 1. 配置目标域名和IP target_domain = "www.example.org" host_ip = "123.456.100.1" # 2. 创建会话并挂载自定义适配器 session = requests.Session() session.mount("https://", DomainVerifiedSSLAdapter(target_domain)) # 3. 发起请求并保存文件 response = session.get( f"https://{host_ip}/myfile.zip", headers={'Host': target_domain} ) with open("myfile.zip", "wb") as f: f.write(response.content)
重要提醒
绝对不要通过禁用SSL验证(比如设置ssl_context.check_hostname = False或verify=False)来绕开这个问题——这会完全丧失HTTPS的安全防护,让你的请求暴露在中间人攻击的风险下。我们的方案是保持完整的SSL验证逻辑,仅修正验证的目标主体,安全性和正常访问完全一致。
备注:内容来源于stack exchange,提问作者Johannes Hinrichs

