如何通过HttpClient发送Webhooks时防范SSRF漏洞?
问题背景
我需要让服务器应用向用户可控的HTTP(S) URL发送Webhooks(出站POST请求),但直接实现会存在SSRF风险——目标主机可能处于服务器所在的私有网络中。原本在经典.NET中,可通过HttpWebRequest的ServicePoint.BindIPEndPointDelegate回调获取要连接的IP地址,以此黑名单私有IP来防范;但迁移到.NET Core后,HttpWebRequest成为HttpClient的包装器,该回调完全失效,直接使用HttpClient也找不到能在连接前获取IP的回调。
尝试过提前解析域名到IP再发起请求,但存在诸多问题:需处理多个IP结果、存在ToC/ToU(检查与请求阶段解析结果不一致)问题,还要手动处理重定向以校验目标IP;另一种思路是解析域名后用IP作为连接目标、保留原Host头,但同样需手动处理重定向,实现复杂且脆弱;使用代理、防火墙等外部方案又不够简洁,还会增加基础设施成本与复杂度。
可行解决方案
1. 基于SocketsHttpHandler的ConnectCallback(推荐,.NET Core 3.0+)
在.NET Core 3.0及以上版本,SocketsHttpHandler提供了自定义连接逻辑的能力,可在连接建立前解析并校验IP,从根源避免SSRF:
var handler = new SocketsHttpHandler { ConnectCallback = async (context, cancellationToken) => { // 解析目标域名的IP列表 var ipAddresses = await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, cancellationToken); // 过滤私有/保留IP地址 var validIps = ipAddresses.Where(ip => !IsPrivateOrReservedIp(ip)).ToList(); if (!validIps.Any()) { throw new InvalidOperationException("目标地址属于私有/保留网络,禁止访问"); } // 建立到合法IP的连接 using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(new IPEndPoint(validIps[0], context.DnsEndPoint.Port), cancellationToken); return new NetworkStream(socket, ownsSocket: true); } }; var httpClient = new HttpClient(handler);
配套的IP校验方法示例:
private static bool IsPrivateOrReservedIp(IPAddress ip) { // 转换IPv6映射的IPv4地址 if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4(); var ipBytes = ip.GetAddressBytes(); // 覆盖常见私有/保留IP段:10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.0/8等 return ipBytes[0] == 10 || (ipBytes[0] == 172 && ipBytes[1] is >= 16 and <= 31) || (ipBytes[0] == 192 && ipBytes[1] == 168) || ipBytes[0] == 127; }
该方案的核心优势:
- 连接阶段直接校验IP,彻底避免ToC/ToU问题
HttpClient自动处理重定向,所有重定向目标都会经过ConnectCallback校验- 基于.NET原生API实现,无外部依赖,逻辑简洁
2. HTTPS场景下的证书校验补充(辅助方案)
如果仅处理HTTPS请求,可以在证书校验阶段额外校验远程IP,作为安全兜底:
var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) => { if (msg.RequestMessage.Properties.TryGetValue("RemoteIpAddress", out var ipObj) && ipObj is IPAddress remoteIp) { if (IsPrivateOrReservedIp(remoteIp)) { return false; } } // 保留原有证书校验逻辑 return errors == SslPolicyErrors.None; } };
注意:该方案是连接建立后才校验IP,存在一定风险,仅适合作为额外安全层,不单独使用。
3. 手动处理重定向(旧版本.NET Core兜底)
如果使用的是.NET Core 3.0以下版本,只能限制自动重定向,手动处理每个重定向请求并提前校验IP:
var handler = new HttpClientHandler { AllowAutoRedirect = false }; var httpClient = new HttpClient(handler); var request = new HttpRequestMessage(HttpMethod.Post, userProvidedUrl); // 设置请求内容等 var response = await httpClient.SendAsync(request); // 手动处理重定向 while (response.StatusCode is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect) { var redirectUrl = response.Headers.Location; if (redirectUrl == null) break; // 解析重定向目标的IP并校验 var ipAddresses = await Dns.GetHostAddressesAsync(redirectUrl.Host); if (ipAddresses.Any(IsPrivateOrReservedIp)) { throw new InvalidOperationException("重定向目标属于私有网络,禁止访问"); } request = new HttpRequestMessage(response.StatusCode is HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect ? request.Method : HttpMethod.Get, redirectUrl); response = await httpClient.SendAsync(request); }
该方案需处理多种重定向状态码,逻辑复杂且仍存在ToC/ToU问题,仅作为旧版本的临时解决方案。
内容的提问来源于stack exchange,提问作者CodesInChaos

