跳转到帖子

URL 预览、网页截图、远程文件导入、Webhook 测试等功能,通常都需要由服务端主动访问用户提交的地址。这类功能本身并不危险,但一旦缺少严格的目标校验,就可能演变为服务端请求伪造(SSRF)。

SSRF 的风险不只在于“访问了一个不该访问的 URL”,更在于请求发生在服务器所在的网络环境中:服务器可能具备访问内网管理面板、服务注册中心、云平台元数据接口或本地调试端口的权限。因此,审计这类问题时,重点不是寻找一个能发起请求的接口,而是完整分析“输入如何到达网络连接点”,以及应用是否在每个跳转和解析阶段持续执行安全策略。

一、典型场景与审计目标

常见代码形态包括:

  • 用户提交 URL,后端抓取标题并生成预览卡片;
  • 后台导入远程图片、压缩包或文档;
  • Webhook 配置页面允许管理员测试回调地址;
  • 服务端根据 URL 生成二维码、截图或转码结果;
  • 代码审计中出现 HTTP 客户端、文件下载器或 XML 外部实体解析器。

审计目标可以拆成四个问题:

  • 外部输入是否能够影响请求目标、协议、端口或请求方法?
  • 应用是否允许访问本机、内网、环回地址、链路本地地址或 Unix Socket?
  • 域名解析、重定向和连接建立之间是否存在校验失效?
  • 即使请求发出,响应内容、响应头和错误信息是否会回显给用户?

二、从数据流定位危险调用点

实际审计中,不建议只搜索 requests.get 或 HttpClient。更稳定的方法是沿着数据流从输入源追踪到网络连接点。

常见输入源包括 HTTP 参数、JSON 字段、数据库中的可编辑配置、消息队列内容和后台任务参数。危险汇点则包括 HTTP 客户端、浏览器自动化框架、FTP 客户端、Redis 或数据库客户端,以及支持远程资源加载的解析器。

可以先进行一次粗粒度搜索,再回到调用链确认是否存在实际可控性:

grep -RInE "requests\.(get|post|request)|urlopen|HttpClient|RestTemplate|WebClient|axios|fetch\(" src/

随后重点确认以下几点:

  • URL 是否来自用户输入,还是仅来自固定配置;
  • 是否存在统一的 validateUrl、isAllowedHost 等校验函数;
  • 校验是在 DNS 解析前还是解析后执行;
  • HTTP 客户端是否自动跟随重定向;
  • 是否对响应大小、连接超时和读取超时做了限制。

三、为什么简单的黑名单经常失效

很多项目最初会采用字符串黑名单,例如禁止 127.0.0.1、localhost 或某几个内网网段。这种方案的问题在于,URL 解析、域名解析和实际建连是三个不同阶段,字符串层面的判断无法覆盖全部表示形式。

需要特别关注以下边界:

  • IP 表示差异:同一个地址可能存在十进制整数、十六进制、IPv6、IPv4 映射 IPv6 等表示形式。
  • 域名解析变化:域名在校验时解析到公网地址,实际建立连接时可能解析到不同地址。
  • 重定向:初始 URL 是允许的,但响应的 Location 指向了受限网络。
  • 非 HTTP 协议:部分客户端支持 file、gopher、ftp 等协议,导致风险边界扩大。
  • 端口绕过:只限制主机而不限制端口,可能将请求打到内部管理服务。
  • 代理配置:客户端可能读取环境变量中的代理设置,实际流量并未按预期路径发送。

安全校验应针对“最终建立的连接”执行,而不是只针对用户提交的原始字符串执行。

四、推荐的安全校验流程

一个相对稳妥的 URL 访问流程可以分为以下步骤:

  1. 解析 URL,并拒绝缺少主机、端口异常或格式不规范的输入;
  2. 只允许业务明确需要的协议,通常仅保留 HTTPS,必要时再开放 HTTP;
  3. 限制端口到明确的允许集合,例如只允许 443 或 80;
  4. 解析域名得到全部 A 和 AAAA 记录;
  5. 对每一个解析结果执行公网地址校验,拒绝环回、私有、链路本地、组播和未指定地址;
  6. 建立连接时固定使用已校验的地址,避免校验和建连之间再次发生 DNS 变化;
  7. 处理每次重定向前重新执行完整校验;
  8. 限制响应大小、连接时间、读取时间和最大重定向次数;
  9. 关闭不必要的协议升级、代理继承和本地文件访问能力。

下面是一个用于说明校验思路的 Python 示例。它只负责判断目标地址是否属于允许范围,不包含实际下载逻辑:

from ipaddress import ip_address, ip_network
from urllib.parse import urlparse
import socket

ALLOWED_SCHEMES = {"https"}
ALLOWED_PORTS = {443}

BLOCKED_NETWORKS = [
    ip_network("0.0.0.0/8"),
    ip_network("10.0.0.0/8"),
    ip_network("100.64.0.0/10"),
    ip_network("127.0.0.0/8"),
    ip_network("169.254.0.0/16"),
    ip_network("172.16.0.0/12"),
    ip_network("192.168.0.0/16"),
    ip_network("::/128"),
    ip_network("::1/128"),
    ip_network("fc00::/7"),
    ip_network("fe80::/10"),
]

def is_public_ip(value: str) -> bool:
    ip = ip_address(value)
    return not any(ip in network for network in BLOCKED_NETWORKS)

def validate_target(raw_url: str):
    parsed = urlparse(raw_url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        raise ValueError("unsupported scheme")
    if not parsed.hostname or parsed.username or parsed.password:
        raise ValueError("invalid authority")

    port = parsed.port or 443
    if port not in ALLOWED_PORTS:
        raise ValueError("unsupported port")

    results = socket.getaddrinfo(
        parsed.hostname,
        port,
        type=socket.SOCK_STREAM,
    )
    addresses = {item[4][0] for item in results}
    if not addresses or not all(is_public_ip(addr) for addr in addresses):
        raise ValueError("target is not public")

    return parsed, addresses

这个示例仍然不是完整的生产实现。生产环境中还需要让实际连接使用已经校验过的地址,并正确设置 TLS 的主机名校验。否则,单独调用 DNS 进行检查,随后再让 HTTP 客户端重新解析域名,仍然可能出现检查与建连不一致的问题。

五、DNS 解析与连接固定

SSRF 防护中比较容易被忽略的是 DNS 的时间窗口。典型的不安全流程如下:

解析域名 -> 判断为公网地址 -> 交给 HTTP 客户端 -> HTTP 客户端再次解析域名并连接

问题在于,第二次解析的结果可能与第一次不同。更可靠的做法是:

  • 在应用层解析并校验所有地址;
  • 从校验结果中选择一个地址建立 TCP 连接;
  • TLS 握手时仍使用原始域名作为 SNI 和证书校验名称;
  • 连接失败时,不应无条件重新解析并尝试未校验地址;
  • 对 DNS 响应设置合理的超时和数量上限。

不同语言的 HTTP 客户端实现差异较大,建议优先使用支持自定义 DNS Resolver、Dialer 或连接器的库,并阅读其重定向、代理和地址选择逻辑。不要仅凭函数名判断其是否安全。

六、重定向、响应和解析器风险

1. 重定向必须重新校验

如果业务不需要重定向,最简单的方案是直接关闭自动跟随。确实需要跟随时,应限制次数,并对每个 Location 目标重新执行协议、端口、域名解析和地址范围校验。

for redirect_count in range(MAX_REDIRECTS + 1):
    target, resolved = validate_target(current_url)
    response = fetch_once(target, resolved, timeout=5)

    if response.status_code not in {301, 302, 303, 307, 308}:
        break

    next_url = response.headers.get("Location")
    if not next_url:
        break
    current_url = resolve_relative_url(current_url, next_url)
else:
    raise ValueError("too many redirects")

2. 限制响应体和内容类型

很多预览功能只需要状态码、标题或少量正文,却直接把整个响应读入内存。这会带来内存消耗、压缩炸弹和超大文件风险。建议设置:

  • 连接超时、读取超时和总耗时;
  • 最大响应体大小;
  • 最大响应头数量和单个头长度;
  • 允许的 Content-Type;
  • 禁止自动解压,或对解压后的大小再次限制。

3. 不要忽略其他远程资源加载点

XML、SVG、Office 文档、图片处理库和浏览器自动化框架都可能主动加载外部资源。代码审计时,应确认 XML 外部实体、XInclude、远程字体、脚本、iframe 和浏览器网络请求是否被关闭或隔离。

七、一个安全的复现与验证方法

在授权测试或内部开发环境中,验证 SSRF 防护不需要访问真实内网服务。可以搭建两个本地测试服务:

  • 公开测试服务:记录请求方法、路径和请求头;
  • 受限测试服务:用于确认应用是否错误访问了不应访问的目标。

测试重点不是读取敏感内容,而是观察应用是否:

  • 拒绝不支持的协议和端口;
  • 拒绝解析到受限地址的域名;
  • 在重定向后再次执行校验;
  • 正确处理 IPv4、IPv6 和多 A 记录;
  • 在超时或超大响应下及时中止;
  • 不向前端回显内部连接错误、响应头或完整响应体。

建议将这些情况写成自动化测试,避免后续更换 HTTP 客户端或修改代理配置时防护退化。

八、日志与检测建议

SSRF 防护不仅是代码问题,也需要配合可观测性。建议记录以下字段:

  • 请求任务或业务操作的唯一标识;
  • 规范化后的目标域名和端口;
  • 解析到的地址类别,但避免记录不必要的敏感响应内容;
  • 拒绝原因,例如协议不允许、地址属于私网或重定向超限;
  • 连接耗时、响应大小和最终状态码;
  • 重定向次数和最终失败阶段。

日志中不要直接记录包含凭据的完整 URL,也不要把远端响应原样写入日志。对于短时间内大量请求不同内网地址、频繁触发拒绝规则或持续访问异常端口的行为,可以结合账号、任务和源地址进行告警。

九、常见修复误区

误区问题改进方式
只拦截 localhost无法覆盖私有地址、IPv6 和特殊地址基于标准 IP 类型和地址范围统一判断
只校验原始字符串忽略 DNS 解析和连接阶段校验所有解析结果,并固定已校验连接
允许自动重定向第二跳可能进入受限网络关闭重定向或逐跳重新校验
仅限制响应状态码仍可能下载超大响应或触发解析器风险同时限制大小、类型和处理时间
依赖前端校验请求可被直接构造,前端规则不可信所有策略必须在服务端执行
只修复单个接口其他下载、截图、导入功能可能复用同类缺陷建立统一的安全访问组件和代码规范

十、总结

SSRF 的核心不是某个特殊 URL 写法,而是服务端把不可信输入带入了网络访问能力。可靠的治理方式应同时覆盖输入解析、协议限制、DNS 解析、连接建立、重定向、响应处理和网络出口控制。

从工程实践看,最值得优先落实的是三点:第一,所有远程访问统一经过安全组件,不让业务代码自行拼装请求;第二,默认拒绝私有、环回、链路本地和未明确允许的地址,并在每次重定向时重新校验;第三,在网络层增加出口白名单和隔离策略,把应用层遗漏的风险限制在较小范围内。

代码审计时,如果只看到一个“允许用户填写 URL”的字段,不要马上下结论。应继续追踪它是否进入 DNS、HTTP、浏览器或文件解析器,确认校验发生的位置以及最终连接使用的地址。只有把完整数据流和实际网络行为对应起来,才能判断风险是否真实存在,也才能给出不会被轻易绕过的修复方案。

服务器与云环境安全示意图
服务器、云资产与权限边界梳理示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…