URL 预览、网页截图、Markdown 远程图片解析等功能,通常需要由服务端主动访问用户提交的地址。它们看起来只是普通的网络请求,但如果缺少严格的目标校验和出站访问控制,就可能演变为服务端请求伪造(SSRF)。
SSRF 的风险不只在于“能不能访问内网”,更在于应用本身往往拥有比普通用户更高的网络可见性和身份权限。一次看似简单的 URL 请求,可能触及内部管理面板、服务发现接口、容器网络或云环境的实例身份服务。因此,审计这类功能时,不能只检查黑名单是否完善,而应从解析、解析结果校验、连接建立、重定向、DNS 变化以及网络出口策略等多个层面建立完整边界。
一、问题场景与审计目标
假设业务提供了一个 URL 预览接口:
POST /api/preview
Content-Type: application/json
{
'url': '
}服务端收到请求后,会抓取页面标题、摘要和缩略图,再将结果返回给前端。最初的实现可能类似下面这样:
import requests
def fetch_preview(url):
response = requests.get(url, timeout=5)
return response.text[:4096]这段代码的主要问题不是使用了某个特定 HTTP 库,而是把用户输入直接交给了网络客户端。攻击者如果控制目标地址,服务端就可能替其访问原本不应暴露给用户的网络资源。
审计目标应明确为以下几点:
- 确认服务端是否会访问用户完全可控的地址。
- 确认是否限制了协议、端口、目标地址类型和重定向行为。
- 确认域名解析结果是否经过校验,以及是否存在 DNS 解析结果变化问题。
- 确认请求是否携带了内部凭据、Cookie、代理头或其他敏感信息。
- 确认网络层是否有默认拒绝的出站策略。
- 确认错误信息和响应内容是否会造成内部拓扑泄露。
二、先定位真实的请求链路
代码审计时,建议从用户输入开始向下追踪,而不是只搜索 requests.get 或 HttpClient。实际项目中,URL 可能经过多层封装:
controller 参数
-> 业务服务
-> URL 清洗器
-> 重定向处理器
-> HTTP 客户端
-> 代理或网关
-> 目标服务器重点关注以下代码特征:
- 从请求参数、消息队列、数据库或模板中读取 URL。
- 调用 HTTP、HTTPS、FTP、file 等协议处理器。
- 默认开启重定向,或手动跟随 Location 响应头。
- 先通过 DNS 解析进行检查,再把原始域名交给 HTTP 客户端重新解析。
- 将用户输入拼接到代理地址、抓取器命令或浏览器自动化参数中。
- 把服务端环境变量、默认请求头或内部 Cookie 自动带到目标请求。
除了审查入口,还应确认任务是否异步执行。异步任务容易被忽略,因为接口层可能只做了格式校验,真正的请求发生在后台 Worker、渲染服务或独立的截图容器中。漏洞验证和修复必须覆盖完整调用链。
三、解析与校验为什么容易失效
很多修复停留在字符串层面,例如判断 URL 是否以某个域名开头,或者简单拦截某些内网地址。这类方案通常不可靠,原因是 URL 解析存在多个边界问题。
第一,域名、端口、用户名密码、路径和片段属于不同组成部分。把字符串前缀当成主机名判断,可能误把相似域名当成可信域名。第二,IPv4、IPv6、十进制或十六进制表示、尾随点、大小写和 IDNA 域名都可能导致不同组件产生不同的解析结果。第三,重定向可能把一个看似可信的地址引向另一个不可信地址。
因此,校验顺序应尽量统一:
- 使用成熟 URL 解析器解析一次,并拒绝无法规范化的输入。
- 只允许业务明确需要的协议,通常仅允许 HTTPS,必要时再开放 HTTP。
- 拒绝用户名、密码、片段和非必要端口。
- 提取规范化后的主机名,并进行 IDNA 处理。
- 解析所有 A 和 AAAA 记录,对每个结果进行地址分类。
- 拒绝回环地址、链路本地地址、私有地址、保留地址、组播地址和未指定地址。
- 建立连接时使用已经校验过的解析结果,避免再次按原始域名解析。
- 每次重定向都重新执行完整校验,而不是只校验首次 URL。
四、一个更稳妥的防护实现
下面示例使用 Python 展示防护思路。它是一个简化版本,适合说明审计重点,不应直接替代经过充分测试的生产级网络组件。
import ipaddress
import socket
from urllib.parse import urlsplit
ALLOWED_SCHEMES = {'https'}
ALLOWED_PORTS = {443}
def is_public_address(address):
ip = ipaddress.ip_address(address)
return not (
ip.is_private or
ip.is_loopback or
ip.is_link_local or
ip.is_multicast or
ip.is_reserved or
ip.is_unspecified
)
def resolve_public_host(host, port):
results = socket.getaddrinfo(
host,
port,
type=socket.SOCK_STREAM
)
addresses = sorted({item[4][0] for item in results})
if not addresses:
raise ValueError('host has no address')
if not all(is_public_address(item) for item in addresses):
raise ValueError('destination is not allowed')
return addresses
def validate_url(raw_url):
parsed = urlsplit(raw_url)
if parsed.scheme.lower() not in ALLOWED_SCHEMES:
raise ValueError('scheme is not allowed')
if not parsed.hostname:
raise ValueError('host is required')
if parsed.username or parsed.password:
raise ValueError('userinfo is not allowed')
if parsed.port not in ALLOWED_PORTS:
raise ValueError('port is not allowed')
host = parsed.hostname.rstrip('.').lower()
addresses = resolve_public_host(host, parsed.port)
return parsed, addresses这个示例仍然有一个工程上的关键问题:仅在校验阶段解析 DNS,并不能天然保证后续连接一定使用同一批地址。如果 HTTP 客户端在真正建立连接时再次解析域名,解析结果可能发生变化。更稳妥的做法是使用支持固定目标地址连接、同时保留正确 Host 和 TLS SNI 的客户端封装,或者把出站访问放入独立的代理服务中,由代理统一完成解析和连接策略。
五、重定向、DNS 变化与连接复用
SSRF 审计中最容易遗漏的是“初始地址安全,但后续请求不安全”的情况。
重定向方面,建议默认关闭自动重定向。若业务必须跟随重定向,应限制最大跳转次数,并对每个 Location 目标重新执行协议、端口、域名和 IP 校验。相对路径、绝对路径、跨协议跳转都应纳入测试。
DNS 方面,不要只在应用层调用一次解析函数就认为完成了防护。常见的风险包括:
- 校验阶段解析到公网地址,连接阶段解析到其他地址。
- A 记录安全,但 AAAA 记录指向不允许的网络。
- 域名背后的多个地址中只有一个属于内网。
- 代理、连接池或底层解析器绕过了应用层的检查。
连接复用也需要注意。连接池如果按照域名复用连接,可能让一次已经建立的连接被后续请求继续使用。对于高风险抓取功能,应限制连接池生命周期、单请求连接数和目标主机范围,并记录最终连接的 IP、端口和证书信息。
六、请求头、响应体与资源消耗
即使目标地址校验正确,服务端请求本身仍可能泄露信息。建议明确构造请求头,而不是把用户请求头整体转发:
SAFE_HEADERS = {
'User-Agent': 'PreviewFetcher/1.0',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'zh-CN,zh;q=0.8'
}通常不应向外部目标转发以下内容:
- 用户浏览器的 Cookie、Authorization 和代理认证信息。
- 内部服务使用的追踪头、管理头和调试头。
- 可能包含租户标识、内部主机名或用户隐私的自定义头。
响应处理也不能只限制返回字符串长度。应同时限制响应体总大小、响应头大小、读取时间、连接超时、下载速度和内容类型。对压缩响应要防止解压后体积异常增长;对 HTML、SVG、PDF 和图片解析器要单独设置资源上限,避免通过解析过程触发新的网络请求或消耗大量 CPU。
MAX_BODY = 2 * 1024 * 1024
CONNECT_TIMEOUT = 3
READ_TIMEOUT = 5
MAX_REDIRECTS = 3七、在隔离环境中验证修复
验证 SSRF 修复时,不需要也不应直接探测真实生产内网。可以在本地构造多个明确用途的测试服务:
- 公网模拟服务:返回固定的 HTML 和重定向响应。
- 禁止地址模拟服务:绑定在本机回环地址,用于确认应用是否拒绝访问。
- 大响应模拟服务:返回超过限制的内容,验证大小控制。
- 慢响应模拟服务:延迟发送数据,验证超时和连接释放。
- 多次重定向模拟服务:验证每一跳是否重新校验。
测试用例应覆盖协议、地址、端口和解析行为,而不仅是正常 URL:
def test_reject_non_https(client):
result = client.validate('
assert result.is_error
def test_reject_loopback(client):
result = client.validate('
assert result.is_error
def test_reject_userinfo(client):
result = client.validate('
assert result.is_error
def test_limit_redirects(client):
result = client.fetch('
assert result.error_code == 'redirect_limit'
def test_limit_body_size(client):
result = client.fetch('
assert result.error_code == 'body_too_large'在集成测试中,还应检查网络层日志,确认最终连接地址、解析结果和拒绝原因与应用层判断一致。只有应用返回错误并不代表防护有效,还要确认目标服务确实没有收到请求。
八、网络层是最后一道边界
应用层校验存在实现错误的可能,因此不应把安全性完全建立在单一代码判断上。生产环境建议为抓取服务设置独立运行身份和独立网络区域,并采用默认拒绝的出站策略。
- 仅允许访问业务明确需要的代理或目标网段。
- 禁止直接访问云实例身份服务、容器管理接口和内部控制面。
- 限制 DNS 服务器、代理服务器和可用端口。
- 对出站连接记录目标域名、解析地址、端口、状态码和耗时。
- 对异常的目标数量、失败率和响应体大小设置告警。
- 使用最小权限的服务账号,避免抓取进程持有云平台高权限凭据。
网络隔离不能替代应用校验,但可以显著降低单个漏洞的影响范围。尤其是云环境中,实例元数据、短期令牌和服务发现接口都不应依赖应用开发者记得手工拦截,而应由网络策略和身份配置共同保护。
九、日志与事件复盘重点
一次 SSRF 事件复盘,不能只记录“某接口被访问了异常 URL”。建议保留足够的关联信息:
- 原始输入和规范化后的 URL,但要脱敏其中可能存在的凭据。
- 最终解析出的 IP、端口、协议和重定向链。
- 请求来源用户、任务编号、服务实例和执行时间。
- 连接失败、超时、证书错误和策略拒绝的具体原因。
- 响应体大小、状态码、Content-Type 和读取耗时。
- 网络设备是否观察到访问受保护网段的行为。
日志中不要直接记录完整的 Authorization、Cookie 或响应正文。对于疑似攻击请求,可以记录经过哈希或截断处理的标识,并将详细取证数据放在权限受控的审计存储中。
十、常见错误修复方式
| 错误方式 | 问题 | 改进方向 |
| 只拦截几个常见内网地址 | 地址表示、IPv6、DNS 变化和多网卡场景容易遗漏 | 使用标准地址分类,并在连接路径上强制执行校验 |
| 只检查域名字符串后缀 | 字符串边界与真实主机名不一致 | 先解析结构,再比较规范化后的主机名 |
| 允许自动跟随所有重定向 | 安全的首次地址可能跳转到禁止地址 | 关闭自动重定向,或逐跳校验并限制次数 |
| 只限制响应字符数 | 下载、解压和解析阶段仍可能消耗大量资源 | 同时限制字节数、速度、超时和解析资源 |
| 完全依赖应用层黑名单 | 代码路径、代理和解析器可能存在绕过 | 增加独立出口代理、网络 ACL 和监控 |
| 将用户请求头全部转发 | 可能泄露身份凭据和内部上下文 | 采用固定的最小请求头集合 |
总结
SSRF 的核心并不是某个特殊字符串,而是“不受信任输入影响了服务端的网络边界”。可靠的防护需要同时处理 URL 规范化、协议和端口限制、DNS 解析、重定向、连接复用、请求头、响应资源和网络出口。
从审计角度看,建议按“输入来源—解析过程—实际连接—响应处理—网络策略—日志告警”的顺序建立检查清单,并在隔离环境中用本地模拟服务验证每条边界。对 URL 预览、截图、远程文件导入、Webhook 测试和链接扫描等功能,都应复用同一套出站访问治理组件,而不是在各个业务模块中分别实现一套不完整的黑名单。
安全边界的目标不是让请求“看起来合法”,而是确保服务端最终建立的每一条连接都符合明确、可审计、默认拒绝的策略。
推荐意见