服务端请求伪造会让服务器发出由攻击者选择或影响的请求。缩略图服务、导入器、Webhook 测试器或文档渲染器,可能到达请求用户无法直接访问的目标。服务器贡献的是自己的网络位置,以及这条具体抓取路径实际附带的凭据;取一个地址并不意味着进程可见的所有账号凭据都会自动加入请求。评估风险应从真实连接与认证方式开始。
先看清委托了什么权限
内部服务、回环管理端点和云元数据服务,在可达时都是重要目标。后果取决于认证、响应是否可见、允许的方法和网络策略。盲 SSRF 即使不返回目标正文,也可能触发动作或暴露时间信息。不能只凭功能名称给严重程度排位,而应确认它能联系什么,以及一次联系可以产生什么结果,尤其注意有副作用的内部接口。
在 EC2 上,实例附加了角色且相关访问被允许时,角色凭据可能通过元数据提供。角色名称列表与具体角色的凭据文档是不同请求。这些临时凭据代表该角色获得的权限,并不等于无限 AWS 访问,也不保证是实例上每个应用实际采用的凭据。目标没有角色、端点不可达或权限不足时,风险形态会不同,需要依照部署事实判断。
IMDSv2 要求先用 PUT 获取令牌,再在后续元数据请求中提供令牌。强制使用它可以挡住简单、没有认证头的单次 GET 路径,但不能解决能够控制必要方法和请求头的任意 SSRF。应结合最小权限角色与有效网络限制。元数据还可能通过 IPv4,以及启用并受支持时的 IPv6 到达;检查时不能只处理一种地址族。
解析、解析域名,并保留已检查的目标
允许列表应描述功能真正需要的目标。如果可以,接收资源标识而不是任意 URL。否则,应使用与 HTTP 客户端解释一致的解析器,限制协议、主机和端口。不需要的 userinfo 和歧义语法应拒绝,不能因为字符串某处出现了允许的域名,就认定目的地已经获准。解析函数有返回值,也不等于输入符合业务策略。
例如,位于 @ 前面的 trusted.example 是用户信息,不证明目标主机可信。旧式数字 IPv4 写法在不同解析器和解析库中可能有不同解释。IPv6 回环是 ::1,并不是 127.0.0.1,所以搜索一个固定字符串远远不够。一个运行时的解析结果,也不能直接成为另一个客户端行为的可移植保证。验证应围绕实际使用的组件版本建立。
解析允许的域名后,要按照预期网络策略检查返回的全部地址。只允许公网抓取时,应拒绝回环、私有、链路本地、多播以及其他禁止范围。地址库和登记规则会演进,因此要测试真正部署的版本。全球可路由也不代表目标是获准的、可信的或无害的服务;地址分类与业务允许列表解决的是不同问题,应同时保留。
坏掉的样子
先检查 DNS,再让客户端自行重新解析,会留下检查与使用之间的空隙。应通过受支持的传输机制固定经过验证的连接地址,同时保留原本的 TLS 主机名、证书校验和 HTTP authority。若流量经过代理,代理可能再次解析,因此必须检查真正的出口路径,不能假设本地预检就控制了最终连接。固定地址也不能以关闭证书验证为代价。
重定向引入新目标。没有需要就关闭;需要时则在跟随每一跳之前重新执行验证,包括解析与地址选择。已经允许的域名不能为任意 Location 背书。出口策略是独立的限制层,应与工作负载需要的服务相符,并覆盖相关地址族。入口允许列表、解析检查和网络隔离相互补充,不能把其中一个配置项当成万能保证。
返回二百并不单独证明 SSRF,返回四百类错误也不能证明禁止的请求没有发生。应用可能包装错误、返回缓存,或者先联系目标再拒绝。应通过可控观察端点确认实际请求,区分响应处理和网络行为。不要为了让示例显得可信,就探测真实元数据凭据;安全测试应该使用合成响应,并明确获得对测试目标的授权。
快速验证
保存为 ssrf_policy.py,执行 python3 ssrf_policy.py。这个刻意收窄的离线策略用传入的地址快照代替 DNS。公网地址只是输入值,程序不会联系它。所有决定都发生在内存中,不使用凭据、不创建套接字、不访问元数据,也不更改网络控制。这样可以验证拒绝规则,而不把真实服务暴露给实验请求。
import ipaddress
from urllib.parse import urlsplit
ALLOWED = {'cdn.example.test'}
def approve(url, addresses):
# Deliberately narrow input language for this offline model.
if any(ord(c) <= 32 or ord(c) >= 127 for c in url) or '\\' in url:
raise ValueError('unsupported URL characters')
parts = urlsplit(url)
if parts.scheme != 'https' or parts.hostname not in ALLOWED:
raise ValueError('scheme or host denied')
if parts.username is not None or parts.password is not None or parts.fragment:
raise ValueError('userinfo or fragment denied')
if parts.port not in (None, 443):
raise ValueError('port denied')
if not addresses:
raise ValueError('empty resolution')
checked = []
for text in addresses:
address = ipaddress.ip_address(text)
if '%' in text or address.is_multicast or not address.is_global:
raise ValueError('non-public address denied')
checked.append(str(address))
return parts.hostname, tuple(checked)
def rejected(label, url, addresses):
try:
approve(url, addresses)
except ValueError:
print('rejected:', label)
return
raise RuntimeError('unexpected acceptance: ' + label)
url = 'https://cdn.example.test/logo.png'
# Address values only. There is no DNS lookup, socket, or HTTP request.
public_snapshot = ['8.8.8.8']
name, pinned = approve(url, public_snapshot)
rejected('userinfo on allowed host', 'https://attacker@cdn.example.test/logo.png', public_snapshot)
rejected('userinfo host confusion', 'https://cdn.example.test@169.254.169.254/', public_snapshot)
rejected('decimal host', 'https://2130706433/', ['127.0.0.1'])
rejected('loopback IPv6', 'https://[::1]/', ['::1'])
rejected('link-local answer', url, ['169.254.169.254'])
rejected('mixed answers', url, ['8.8.8.8', '127.0.0.1'])
rejected('multicast answer', url, ['224.0.0.1'])
rejected('redirect destination', 'https://internal.example.test/', ['10.0.0.1'])
rejected('changed DNS answer', url, ['127.0.0.1'])
if pinned != ('8.8.8.8',) or name != 'cdn.example.test':
raise RuntimeError('approved snapshot changed')
print('PASS: public fixture accepted; nine denied cases; approved address snapshot retained')
预期打印九条拒绝结果和 PASS。允许的合成域名加一个公网地址建立成功路径。允许主机上的用户信息、用户信息造成的主机混淆、允许列表外的数字与 IPv6 主机、链路本地和混合答案、多播、重定向主机,以及改变后的 DNS 答案都会失败。此前批准的地址元组仍保持不变,说明检查得到的数据不会因为另一次判断而被悄悄替换。
这里测试的是决策与数据保存,不是生产抓取实现。它没有执行 DNS、传输层固定、代理、TLS、真实重定向或响应大小限制,因此通过测试不能证明某个 HTTP 库确实连接了批准地址。集成测试仍须验证这些连接关系,并覆盖超时和资源上限,使用隔离目的地和合成响应,避免把局部规则通过误写成整条网络路径已得到证明。
来源
- OWASP: SSRF Prevention Cheat Sheet,预期目标、地址验证与重定向,已核验 2026-10-15。
- AWS: Instance Metadata Service,IMDSv2 令牌请求与端点行为,已核验 2026-10-15。
- RFC 3986: URI Generic Syntax,authority、userinfo 与主机语法,已核验 2026-10-15。
- Python: ipaddress,地址分类与版本变化,已核验 2026-10-15。