SSRF(服务端请求伪造)在实际项目中并不总是以一个明显的“URL 参数直接传给 HTTP 客户端”形式出现。更常见的情况是:业务允许导入远程图片、解析 Webhook、抓取网页标题,或者通过内部服务生成预览;随着代码分层和功能迭代,用户输入可能经过多次拼接、解码和重定向,最终形成一条不容易被发现的服务端请求链路。
这类问题的风险也不能只看“能不能访问内网”。真正需要评估的是请求发起位置、解析和连接过程、重定向行为、响应处理方式,以及云环境中的凭证和管理接口是否暴露。本文以一次典型的代码审计为例,整理一套偏工程化的发现、复现、修复和验证方法。示例均使用本地测试环境,不涉及对第三方系统的探测。
一、问题场景:图片预览功能为何容易出问题
某内容系统提供远程图片预览功能,接口接收一个图片地址,服务端下载图片后生成缩略图。初始实现大致如下:
def fetch_image(url: str) -> bytes:
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.content从业务角度看,这段代码很直观,但安全边界几乎没有建立:
- 协议没有限制,调用方可能尝试非 HTTP 协议处理器。
- 域名解析结果没有校验,域名可能解析到本机、内网或链路本地地址。
- 即使首次解析结果安全,连接前再次解析时也可能发生地址变化。
- 默认跟随重定向,初始地址安全并不代表最终地址安全。
- 没有限制响应大小,可能造成内存或磁盘资源耗尽。
- 如果异常信息直接返回给前端,可能泄露内部地址、连接细节或响应内容。
二、审计重点:不要只搜索一个函数
审计 SSRF 时,建议从“输入源—规范化—请求构造—网络连接—响应处理”五个环节建立数据流,而不是只搜索 requests.get 或 HttpClient.execute。
1. 识别外部可控输入
重点检查以下输入来源:
- JSON、表单、GraphQL 和 RPC 请求中的 URL、回调地址、图片地址。
- 导入文件中的远程引用,例如 Markdown、RSS 或配置文件。
- 数据库中由用户创建的 Webhook、通知地址和头像地址。
- 消息队列、定时任务或管理后台中的抓取任务参数。
一旦发现 URL 被保存到数据库,不能认为风险已经结束。持久化字段往往会在后续异步任务中被重新取出并请求,审计范围应覆盖同步接口、队列消费者和定时任务。
2. 追踪请求封装层
实际项目通常有统一的网络访问封装,例如 download、fetch、proxy、preview 等方法。建议沿调用链向下追踪,重点确认:
- 是否允许调用方覆盖代理、请求头和超时时间。
- 是否对重定向后的每一跳重新执行安全校验。
- 是否先解析域名再建立连接,还是由底层库自动解析。
- 是否把响应内容交给 XML、图片、压缩包等二次解析器。
- 是否将响应头、状态码和异常原文返回给外部调用方。
3. 关注“看起来不是 URL”的输入
有些危险输入不一定直接叫 url。例如 host、endpoint、callback、source、avatar、image、feed、webhook 等字段,经过拼接后可能构成完整请求地址。对这类字段,应该结合数据流和实际调用点判断,而不能只依赖变量命名。
三、地址校验的关键:校验最终连接目标,而不是只校验字符串
常见的错误做法是使用字符串前缀判断,例如判断地址是否以某个域名开头。这种方式无法可靠处理用户信息、端口、大小写、尾随点、IPv6、域名解析和重定向等情况。
较稳妥的策略通常包括以下步骤:
- 只接受明确允许的协议,例如 http 和 https。
- 解析 URL,要求存在主机名,拒绝用户名、密码和不必要的自定义端口。
- 对主机名做规范化处理,避免大小写、尾随点和国际化域名造成判断差异。
- 解析所有 A 和 AAAA 记录,对每个地址执行内网、回环、链路本地、组播和保留地址检查。
- 实际连接时绑定经过校验的 IP,同时保留正确的 Host 和 TLS SNI,避免重新解析到其他地址。
- 禁用或接管自动重定向,并对每次跳转重新执行完整校验。
下面是一个用于本地服务的简化校验示例。它表达的是安全边界和测试思路,生产环境还需要结合网络库的连接绑定能力实现完整的 DNS 重绑定防护。
from ipaddress import ip_address
from urllib.parse import urlparse
import socket
ALLOWED_SCHEMES = {"http", "https"}
def is_public_address(value: str) -> bool:
address = ip_address(value)
return not (
address.is_private
or address.is_loopback
or address.is_link_local
or address.is_multicast
or address.is_reserved
or address.is_unspecified
)
def validate_target(raw_url: str) -> tuple[str, int]:
parsed = urlparse(raw_url)
if parsed.scheme.lower() not in ALLOWED_SCHEMES:
raise ValueError("unsupported scheme")
if not parsed.hostname or parsed.username or parsed.password:
raise ValueError("invalid authority")
host = parsed.hostname.rstrip(".").lower()
port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
addresses = {item[4][0] for item in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)}
if not addresses or not all(is_public_address(item) for item in addresses):
raise ValueError("target address is not allowed")
return host, port这里有一个容易被忽略的细节:只检查 DNS 查询结果仍然不一定足够。若 HTTP 客户端稍后根据域名重新解析,攻击者控制的 DNS 服务可能返回不同地址。因此生产实现应选择支持“预解析地址并绑定连接”的客户端,或者在网络层增加出口代理和目的地址策略,避免应用层单独承担全部风险。
四、重定向、解析差异与协议处理
1. 重定向必须逐跳校验
很多 HTTP 客户端默认自动跟随 301、302、303 和 307。若第一跳是公开地址,响应再跳转到受限地址,单次入口校验就会失效。建议默认关闭自动重定向;如果业务确实需要跟随,应限制最大跳数,并对 Location 解析、协议、主机、端口和解析结果逐跳校验。
def fetch_with_policy(url: str, session) -> bytes:
current = url
for _ in range(3):
validate_target(current)
response = session.get(
current,
allow_redirects=False,
timeout=(3, 5),
stream=True,
)
if response.status_code not in {301, 302, 303, 307, 308}:
return read_limited_body(response, limit=5 * 1024 * 1024)
location = response.headers.get("Location")
if not location:
raise ValueError("redirect location is empty")
current = urljoin(current, location)
raise ValueError("too many redirects")上面的示例省略了导入和响应体读取函数,重点是控制重定向边界。还需要注意:不同语言和库对 URL 解析、反斜杠、编码字符、空白字符以及 IPv6 表示法的处理可能不同,校验器与实际 HTTP 客户端最好使用同一套规范化逻辑,避免“校验看到的地址”和“连接实际使用的地址”不一致。
2. 不要把非 HTTP 协议交给通用请求组件
如果业务只需要获取网页或图片,就不应让通用组件处理 file、ftp、gopher 等协议。某些库还支持自定义协议处理器,或者会根据输入自动选择不同的适配器。限制协议只是第一步,最好在依赖配置和代码层同时确认不加载无关协议处理能力。
五、响应处理同样是攻击面
SSRF 风险不仅在于“请求发出去了什么”,也在于“服务端拿回来后做了什么”。审计时应继续检查以下链路:
- 响应是否被完整读入内存,是否有最大响应体限制。
- Content-Type 是否可信,是否会根据文件内容自动识别类型。
- 图片、XML、PDF、压缩包等解析器是否存在二次解析风险。
- 响应是否被原样返回,是否可能成为内部服务内容代理。
- 是否把上游响应头中的 Location、Set-Cookie 或缓存字段传递给客户端。
一个相对稳妥的下载流程应采用流式读取、大小上限和内容类型白名单。例如图片预览只允许 image/jpeg、image/png 等有限类型,并在服务端重新编码后再输出,不直接返回上游字节流。
def read_limited_body(response, limit: int) -> bytes:
total = 0
chunks = []
for chunk in response.iter_content(chunk_size=8192):
if not chunk:
continue
total += len(chunk)
if total > limit:
raise ValueError("response body is too large")
chunks.append(chunk)
return b"".join(chunks)六、网络层防护:不要只依赖应用代码
应用层校验适合表达业务允许访问的范围,但无法替代网络隔离。生产环境中建议同时落实以下措施:
- 将抓取服务放在独立的网络分区,默认禁止访问数据库、缓存、消息队列和管理平面。
- 通过出口代理统一执行域名、端口、目的地址和审计策略。
- 限制应用运行身份的云平台权限,不让普通业务实例读取高价值实例凭证。
- 在防火墙或安全组层面拒绝回环、链路本地、私有网段和云管理地址。
- 对外联请求记录请求任务、规范化主机、解析地址、最终状态和失败原因,但避免记录敏感查询参数。
日志中的地址信息应经过脱敏和权限控制。安全团队需要能够根据任务 ID 还原完整请求链路,同时避免把内部拓扑、凭证参数或上游响应直接写入普通业务日志。
七、验证方法:建立可重复的本地测试
修复后不要只通过一次接口调用判断问题已经解决。可以在本地搭建几个隔离测试服务,覆盖不同的边界条件:
- 公开地址直接访问,应符合业务预期。
- 解析到回环、私有网段、链路本地和 IPv6 保留地址时,应在连接前失败。
- 公开地址重定向到受限地址时,应在第二跳被拒绝。
- 多次解析返回不同地址时,应保持连接目标与校验结果一致。
- 响应体超过限制时,应中止读取并释放连接。
- 非允许协议、缺失主机、带用户信息或异常端口的 URL,应统一拒绝。
测试重点是验证“是否建立了连接”,而不只是观察接口返回码。可以在隔离网络中使用一个仅用于测试的监听服务记录连接来源和 Host,不要将测试请求指向真实内网系统。
def test_redirect_to_private_target_is_rejected(client, redirect_server):
public_url = redirect_server.url_for("
result = client.preview({"url": public_url})
assert result.status_code == 400
assert redirect_server.connection_count == 1
除单元测试外,还应加入静态检查和集成测试。例如在代码审查中搜索未经统一封装的网络请求调用:
rg 'requests\.(get|post|request)|urlopen|HttpClient|WebClient' src/搜索结果不能直接等同于漏洞,但可以帮助发现绕过统一安全组件的新增代码。对于 Java、Go、Node.js 等技术栈,应根据项目实际客户端补充对应规则,并把检查接入代码审查或持续集成流程。
八、常见修复误区
- 只做字符串黑名单:黑名单容易遗漏地址表示形式,也无法处理解析和重定向。
- 只禁止常见私网段:还应考虑回环、链路本地、组播、保留地址和 IPv6。
- 只校验第一次请求:重定向和多阶段抓取会产生新的目标。
- 只在前端校验:前端校验可以改善交互,但不能作为服务端安全边界。
- 修复后仍允许任意代理设置:攻击者可能通过代理改变实际请求路径。
- 错误信息过于详细:应对外返回稳定的业务错误码,对内记录足够的诊断信息。
九、结论
SSRF 的治理核心不是增加一个 URL 黑名单,而是把“服务端是否允许访问某个网络目标”变成明确、可审计、可测试的策略。一次完整的修复应同时覆盖输入源追踪、协议限制、主机解析、连接绑定、重定向控制、响应大小、网络隔离和日志审计。
从代码审计角度看,最有价值的证据不是某个请求函数的存在,而是一条能够闭合的数据流:外部输入如何进入任务,经过哪些规范化和校验,最终连接到哪个地址,响应又被哪些组件继续处理。只要围绕这条链路建立测试和防护,类似的远程抓取、Webhook、文件导入和预览功能就能复用同一套安全基线,减少问题在不同业务模块中反复出现。
推荐意见