成功的 HTTPS 请求涉及 HTTP 客户端根据已知且受信任的根证书列表来验证服务器提供的 TLS 证书。PHP Curl 扩展也不例外;Curl 扩展使用 libcurl 发起 HTTPS 请求,而 libcurl 进而使用 OpenSSL 等 TLS 库来验证请求。

Curl 扩展需要一个包含所有受信任根证书的有效文件来完成 HTTPS 验证,PHP 将此作为 php.ini 文件中的一个指令暴露出来。

在 Linux、BSD 和 macOS 上,libcurl 可以默认使用系统根证书,但在 Windows 上这是不可能的,因为 Windows 没有提供一个包含所有系统根证书的单一文件。

本文讨论了在使用 Curl 扩展成功发起 HTTPS 请求时的两种可能方法,以及哪些不应该做,以免导致 HTTPS 请求不安全。

为什么失败

$ch = curl_init('https://www.ggdoc.cn');  
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);  
curl_exec($ch); // false  

curl_error($ch);
// SSL certificate problem: unable to get local issuer certificate

如果 curl_exec 调用返回 false,并且 curl_error 指示出现 SSL certificate problem: unable to get local issuer certificate 错误,这意味着未向 Curl 提供包含根证书的文件,或者它无法发现该文件。

此错误在 Linux、BSD 和 macOS 系统中并不常见,但在 Windows 上非常常见,因为没有指定的文件来获取根证书,而且 PHP 本身也不附带根证书列表。

解决方案是提供一个包含最新根证书的文件,或者理想情况下,让 Curl 解析底层操作系统提供的原生根存储。

使用原生证书颁发机构

在 Curl 7.71 及更高版本中,可以设置一个 Curl 选项以请求 Curl 使用原生(系统)根证书。这甚至在 Windows 上也有效,其中 Curl 会解析系统根证书并使用它们。

当 CURLOPT_SSL_OPTIONS 选项设置为 CURLSSLOPT_NATIVE_CA 或包含这些位的掩码时,Curl 尝试使用原生根证书存储,具体取决于底层 TLS 库的功能和版本。

这是推荐的修复方案,如果 Curl 扩展是使用 Curl 7.71 或更高版本以及 PHP 8.2 或更高版本构建的。

  $ch = curl_init('https://www.ggdoc.cn');
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NATIVE_CA);
  curl_exec($ch);

请注意,上面的代码片段没有检查 Curl 版本和 PHP 版本,并假设满足 PHP 和 Curl 的版本要求。以下是一个示例,展示了如何有条件地添加 Curl 选项:

$ch = curl_init('https://www.ggdoc.cn');  
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);  
if (defined('CURLSSLOPT_NATIVE_CA')  
  && version_compare(curl_version()['version'], '7.71', '>=')) {  
  curl_setopt($ch, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NATIVE_CA);  
}  

curl_exec($ch);

下载并维护 cacert.pem 文件

对于运行在低于 8.2 版本的 PHP 上的应用程序(其中 CURLSSLOPT_NATIVE_CA 常量不可用),或者当 Curl 版本低于 7.71 时,推荐的替代解决方案是下载兼容 Curl 的根证书文件,并配置 PHP 或 Curl 请求以使用它。

Curl 项目维护着最新的证书列表。参见从 Mozilla 提取的 CA 证书。

  1. 下载 cacert.pem 文件
  2. 将文件移动到 PHP 和 Web 服务器可访问的目录。例如,移动到 C:/php/cacert.pem。
  3. 编辑 php.ini 文件并修改 curl.cainfo 条目,使其指向 cacert.pem 文件的绝对路径。
    [curl]
     ; A default value for the CURLOPT_CAINFO option. This is required to be an
     ; absolute path.
    -;curl.cainfo =
    +curl.cainfo = "C:/php/cacert.pem"
  4. 可选地,重启 Web 服务器(如 Apache)以重新加载 INI 文件。

这种方法的缺点是必须定期更新 cacert.pem 文件。例如,由 Curl 项目提供的 cacert.pem 文件是从 Mozilla 维护的根存储中提取的。平均而言,此列表和文件每年更新 4-5 次。为了确保与最新的根证书列表兼容,请务必定期更新此文件的本地副本。

如果无法修改 INI 文件,也可以在 Curl 请求中指定 cacert.pem 文件的绝对路径:

  $ch = curl_init('https://www.ggdoc.cn');
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_CAINFO, 'C:/php/cacert.pem');
  curl_exec($ch);

在带有 Curl 7.77 的 PHP 8.2+ 上,可以使用 CURLOPT_CAINFO_BLOB 选项传入包含 cacert.pem 内容的字符串。

切勿禁用证书验证

在互联网论坛和文章中发现的一个常见的错误建议是禁用证书验证。这是一个安全问题,因为如果没有证书验证,Curl 将乐意接受任何 TLS 证书,包括可能被拦截或修改的内容。

以下示例展示了一个经常被提出的解决方案,它是不安全且不推荐的。

$ch = curl_init('https://www.ggdoc.cn');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

// Disables peer certificate validation.
// DO NOT DO THIS!!!
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);

curl_exec($ch);