服务端请求伪造(SSRF)经常出现在“看起来很普通”的功能里,例如 URL 预览、远程图片抓取、Webhook 回调、文档导入和在线截图。它的危险不在于某一行代码本身,而在于应用把用户可控的字符串交给了具有网络访问能力的服务端组件。这类问题在审计中并不难被发现,难的是判断实际影响范围,并设计出不会被重定向、DNS 解析变化、IPv6 表示形式和代理配置绕过的修复方案。本文以一个 URL 预览服务为例,整理从入口定位、数据流分析、风险验证到修复和回归测试的完整过程。示例均面向本地授权测试环境,不涉及访问第三方内网或云平台元数据。一、背景:为什么 URL 预览容易引入 SSRF典型的 URL 预览流程如下:客户端提交一个 URL。服务端解析 URL,并发起 HTTP 或 HTTPS 请求。服务端读取响应内容,提取标题、摘要或图片。结果返回给前端,或者缓存到数据库。如果服务端对目标地址没有做严格限制,攻击者就可能让应用代替自己访问本机服务、内网管理面板、容器内部服务或其他受信任网络资源。即使响应内容不会直接返回,状态码、响应时间、错误信息和页面标题也可能成为信息泄露通道。在实际审计中,以下代码形态都值得重点关注:url = request.json.get('url')
response = requests.get(url, timeout=5)
return {'title': parse_title(response.text)}问题不只是缺少一个正则校验。URL 的语义比较复杂,协议、主机名、端口、用户名信息、重定向和 DNS 解析都会影响最终连接目标。二、审计入口:从数据流而不是关键字开始审计此类功能时,建议先画出数据流,而不是直接搜索 requests.get 或 http.Get。重点确认四个问题:用户输入是否能到达网络请求函数。请求是否经过统一的代理、网关或出站防火墙。客户端是否自动跟随重定向。响应内容、状态码或错误是否会回传给用户。以 Python 项目为例,可以优先检索以下调用点,再沿调用链向上追踪参数来源:requests.get
requests.post
httpx.get
urllib.request.urlopen
socket.create_connection
subprocess.run
curl
wget不要忽略间接请求。很多项目会把抓取逻辑封装在图片下载器、Markdown 渲染器、Webhook 发送器或异步任务中。审计时还要检查消息队列消费者和定时任务,因为前端接口可能只负责写入 URL,真正的网络请求在后台完成。三、核心风险:字符串校验无法替代目标校验1. 协议限制最基本的限制是只允许业务真正需要的协议,通常为 HTTP 和 HTTPS。应拒绝文件、FTP、Gopher、LDAP 等非预期协议,也要注意某些 HTTP 客户端支持通过特殊适配器扩展协议。from urllib.parse import urlparse
def parse_target(raw_url):
parsed = urlparse(raw_url)
if parsed.scheme.lower() not in {'http', 'https'}:
raise ValueError('unsupported scheme')
if not parsed.hostname:
raise ValueError('missing hostname')
if parsed.username or parsed.password:
raise ValueError('userinfo is not allowed')
if parsed.port is not None and parsed.port not in {80, 443}:
raise ValueError('unsupported port')
return parsed协议限制只是第一层。只允许 HTTP 和 HTTPS 并不能阻止访问内网地址,因为内网服务同样可能使用 HTTP。2. 主机名不能只做字符串黑名单简单判断主机名是否以 localhost 结尾,或者只屏蔽几个常见私网网段,通常是不可靠的。原因包括:IPv4 地址可以使用十进制整数、八进制或十六进制等非标准表示。IPv6 存在压缩表示、IPv4 映射地址和本地回环地址。域名解析结果可能同时包含公网地址和私网地址。域名首次解析时是公网地址,连接时解析结果可能已经发生变化。HTTP 客户端可能跟随重定向到另一个主机。更稳妥的做法是:解析 URL 后获取主机名对应的所有地址,将每个地址转换为标准 IP 对象,再检查地址是否属于禁止范围。生产环境还应该由网络出口策略做第二层兜底。import ipaddress
import socket
BLOCKED_NETWORKS = [
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'),
ipaddress.ip_network('::1/128'),
ipaddress.ip_network('fc00::/7'),
ipaddress.ip_network('fe80::/10'),
]
def is_blocked_ip(ip_text):
ip = ipaddress.ip_address(ip_text)
return any(ip in network for network in BLOCKED_NETWORKS)
def resolve_and_check(hostname, port):
infos = socket.getaddrinfo(
hostname,
port,
type=socket.SOCK_STREAM
)
addresses = set()
for family, socktype, proto, canonname, sockaddr in infos:
addresses.add(sockaddr[0])
if not addresses:
raise ValueError('host has no address')
for address in addresses:
if is_blocked_ip(address):
raise ValueError('resolved address is not allowed')
return addresses上面的代码适合作为说明和基础实现,正式环境还需要结合连接层设计,避免“校验时解析一次、发起请求时又由客户端重新解析一次”的时间差问题。四、容易被忽略的绕过点1. DNS 解析与连接之间的时间差如果应用先用 DNS 解析结果做检查,随后又把原始域名交给 HTTP 客户端,那么 HTTP 客户端可能重新解析域名。两次解析结果不一致时,前面的检查就失去了意义。防护方案通常有两种:使用经过审核的 IP 地址建立连接,同时保留原始 Host 头用于协议层请求,但要严格控制 Host 头构造并验证证书。通过固定的安全 DNS 解析器、出口代理或网络策略统一执行解析和连接,应用层只允许访问代理。第一种方案实现复杂,尤其要正确处理 HTTPS 的证书校验和 SNI;多数业务系统更适合采用受控出站代理,并在代理侧再次校验目标地址。2. 重定向即使初始 URL 是公网地址,也可能通过 30x 响应跳转到禁止访问的地址。因此不能只校验第一次请求的 URL。import requests
def fetch_once(url):
response = requests.get(
url,
timeout=(3, 5),
allow_redirects=False,
stream=True
)
return response如果业务必须支持重定向,应当逐跳解析 Location,并对每一个新地址重复协议、端口、域名解析和 IP 范围检查,同时限制最大跳转次数。不要直接依赖客户端默认的自动跳转行为。3. 端口和服务类型只检查 IP 是否为公网地址仍然不够。业务如果只是抓取公开网页,可以进一步限制端口为 80 和 443;如果确实需要访问其他端口,应建立明确的端口白名单,并限制响应大小和连接时间。4. IPv6 和代理环境很多测试只覆盖 IPv4,导致 IPv6 地址被遗漏。还要确认应用运行环境是否设置了 HTTP_PROXY、HTTPS_PROXY 或 NO_PROXY。不同 HTTP 库对这些环境变量的支持方式不同,代理绕过规则也可能让安全校验与实际出站路径不一致。五、一个相对完整的安全抓取流程建议把 URL 抓取拆成几个明确阶段,每一阶段都返回结构化结果,而不是把所有逻辑写在一个请求函数里。规范化并解析 URL。验证协议、主机名、端口和用户信息。解析全部地址,拒绝内网、回环、链路本地和保留地址。通过受控 HTTP 客户端发起请求,关闭自动重定向或逐跳检查。限制连接超时、读取超时、响应体大小和最大跳转次数。只读取业务需要的内容类型,并对 HTML 解析设置资源上限。记录目标域名、解析地址、最终状态和拒绝原因,避免记录敏感响应正文。示例配置可以集中管理,避免不同接口使用不同的限制:FETCH_CONFIG = {
'allowed_schemes': {'http', 'https'},
'allowed_ports': {80, 443},
'connect_timeout': 3,
'read_timeout': 5,
'max_redirects': 3,
'max_response_bytes': 2 * 1024 * 1024,
'allowed_content_types': {
'text/html',
'application/xhtml+xml'
}
}响应读取也要限制大小。不要因为只需要提取标题,就把整个响应无限制地读入内存。对压缩响应、分块传输和异常 Content-Length 都应有上限控制。六、验证与回归测试安全修复不能只靠代码审查,应该在隔离测试环境建立最小化的回归用例。测试目标是验证“请求被正确拒绝或被安全限制”,而不是访问真实的内部系统。建议覆盖以下类别:不支持的协议和缺少主机名的 URL。带用户名密码的 URL。标准 IPv4 私网、回环地址和链路本地地址。IPv6 回环、唯一本地地址和 IPv4 映射地址。解析到多个地址且其中一个属于禁止范围的域名。公网地址跳转到禁止地址的重定向链。超过最大响应体大小的响应。连接超时、读取超时和持续返回数据的慢速响应。非 HTML 内容类型以及异常字符编码。可以在本地测试网络中运行一个简单的 HTTP 服务,用于模拟正常响应、重定向、超大响应和延迟响应。测试用例只应访问自己控制的地址,并在 CI 中固定结果,避免依赖外部网络。七、日志、监控与事件复盘SSRF 防护不仅是输入校验问题,还涉及出站流量治理。建议在应用和网络出口两侧保留以下信息:请求来源接口和业务请求编号。规范化后的目标主机、端口和协议。DNS 解析得到的地址数量及是否命中禁止规则。重定向次数、最终状态码和响应内容类型。超时、连接失败、策略拒绝等错误分类。日志中不要直接记录完整的 Authorization 头、响应正文或可能含有敏感参数的 URL。对于多次尝试访问不同内网地址、频繁触发解析拒绝、异常高的超时比例等情况,可以设置告警,但要结合业务特征降低误报。如果发生疑似利用事件,复盘时重点检查三条链路:应用是否正确执行了目标校验,出口防火墙是否允许了不必要的访问,受访问服务是否暴露了过多管理接口或敏感响应。八、常见错误修复方式只加正则:正则难以覆盖 URL 解析差异、IPv6、重定向和 DNS 变化,不能作为主要防线。只屏蔽 localhost:内网服务并不都使用 localhost,云环境和容器环境还有其他特殊地址范围。只检查第一次 URL:自动重定向会让最终目标脱离初始校验范围。只在前端校验:前端校验可以改善用户体验,但不能约束服务端行为。只依赖内网不可达:网络拓扑会变化,应用本身仍应执行目标限制。修复后取消所有出站访问:这可能破坏正常业务。更好的方式是建立清晰的域名、协议、端口和代理策略。总结URL 抓取功能的安全边界,不是“把一个字符串传给 HTTP 客户端”这么简单。可靠的防护需要同时考虑协议、主机名、DNS 解析结果、IPv4 和 IPv6、端口、重定向、超时、响应大小以及实际网络出口。从代码审计角度看,最有价值的工作通常不是找到一个调用点,而是完整还原用户输入到网络连接的路径,并确认每一层的限制是否覆盖了真实行为。修复时建议采用应用层校验、受控出站代理和网络访问控制的纵深防御,再用本地隔离环境完成回归测试。这样既能降低 SSRF 风险,也能让后续新增的图片抓取、Webhook 和导入功能复用同一套安全能力。
推荐意见