正文

SSRF 的危险点不在“用户能访问一个 URL”,而在“你的服务器替用户访问了一个 URL”。浏览器访问不到的内网地址、localhost 服务、Docker 网桥、Kubernetes Service、云 metadata、管理面板和数据库管理接口,服务端可能都能访问。只要图片代理、Webhook 测试、URL 预览、文件导入、PDF 截图、AI 工具调用里有一个入口处理不当,攻击者就能把你的后端当成内网代理。

真实事故已经说明,这不是小众漏洞。

2019-07-29,Capital One 披露一起网络安全事件,影响约 1 亿美国个人和 600 万加拿大个人。Capital One 的说明提到,攻击者在 2019-03-22 到 2019-03-23 获取了信用卡申请相关个人信息,并且约 14 万个美国 Social Security numbers、约 8 万个关联银行账号、约 100 万个加拿大 Social Insurance Numbers 受到影响。Wired 报道称,攻击者利用了云环境里的错误配置访问数据。这个事件常被安全社区作为“SSRF + 云 metadata / 云权限边界”风险的典型案例讨论。

OWASP API Security Top 10 2023 把 Server-Side Request Forgery 列为 API7。OWASP 的示例很贴近日常开发:用户上传头像时给一个图片 URL,或者创建 Webhook 时让服务端发送测试请求。如果服务端没有限制目标地址,攻击者就可能让它访问 localhost、内网端口或云 metadata 服务。

上线前先查这些功能:

图片 URL 上传服务端下载头像、封面、商品图时访问内网
URL 预览抓网页标题、favicon、Open Graph 图片时访问私网
Webhook 测试创建回调地址时服务端主动请求攻击者给的 URL
文件导入从远程 URL 导入 CSV、PDF、图片、音频
PDF / 截图服务Headless browser 打开用户提供的 URL
视频转码 / 媒体探测FFmpeg / ffprobe 访问远程资源
AI 工具调用Agent、MCP、RAG connector 请求外部资源
内部集成配置用户填 Jira、Slack、SIEM、CRM 的 callback URL

先列出服务端会请求哪些 URL

SSRF 经常藏在“便利功能”里,不一定叫 fetchUrl

先扫代码和配置:

再按入口建一张表:

通过标准:每一个“服务端替用户请求 URL”的入口都能说清楚目标范围、允许协议、允许端口、重定向策略、超时、大小限制和日志字段。

功能头像 URL 下载
用户可控字段avatarUrl
服务端动作fetch(avatarUrl)
是否返回响应给用户返回图片尺寸和错误信息
是否跟随重定向
能访问的网络应用容器出网、内网服务、云 metadata
是否有 allowlist
PowerShell
rg -n "fetch\\(|axios\\.|got\\(|request\\(|http\\.get|https\\.get|curl|wget|ffmpeg|ffprobe|playwright|puppeteer|webhook|callback|imageUrl|avatarUrl|previewUrl|metadata" .

优先用 allowlist,不要靠黑名单

如果业务只需要请求固定服务,直接用 allowlist。比如 Webhook 只允许发到企业自有域名,图片只允许从对象存储或指定 CDN 拉取,内部集成只允许配置已登记的 Jira / Slack / SIEM 域名。

示例:

不要用这种判断:

字符串包含判断挡不住子域名混淆、用户名密码段、编码、重定向和解析差异。OWASP SSRF Cheat Sheet 建议在能识别可信目标时使用 allowlist,并同时从应用层和网络层防护。

通过标准:固定集成走明确 allowlist;没有业务理由访问任意 URL 的功能,不提供任意 URL 抓取能力。

TypeScript
const allowedWebhookHosts = new Set([
  "hooks.slack.com",
  "example.siem.internal"
]);

function assertAllowedHost(url: URL) {
  if (!allowedWebhookHosts.has(url.hostname)) {
    throw new Error("Webhook host is not allowed");
  }
}
TypeScript
if (input.includes("example.com")) {
  await fetch(input);
}

URL 解析必须走标准库

不要自己拼正则判断 URL。先用语言标准库解析,再检查协议、主机、端口和路径。

常见危险输入特征:

通过标准:只允许业务需要的 scheme 和端口;解析失败直接拒绝;不把原始 URL 原样传给多个下游库各自解析。

http://明文传输,也更容易被中间网络劫持
file://gopher://ftp://可能访问本地文件或非 HTTP 服务
URL 里带用户名密码解析和日志都容易出问题
非标准端口可能探测内网服务
超长 URL可能触发解析器、日志或下游服务问题
IPv6 / 编码 IP可能绕过简单字符串规则
TypeScript
function parseUserUrl(input: string) {
  let url: URL;

  try {
    url = new URL(input);
  } catch {
    throw new Error("Invalid URL");
  }

  if (url.protocol !== "https:") {
    throw new Error("Only HTTPS URLs are allowed");
  }

  if (url.username || url.password) {
    throw new Error("URL credentials are not allowed");
  }

  if (url.port && !["443"].includes(url.port)) {
    throw new Error("Port is not allowed");
  }

  return url;
}

DNS 解析后再判断 IP

只检查域名字符串不够。攻击者可以让域名解析到私网 IP、localhost、链路本地地址或云 metadata 地址。更麻烦的是 DNS rebinding:第一次解析看起来正常,真正请求时解析到内网。

需要检查解析后的每个 IP:

上线前至少拦这些地址范围:

通过标准:请求前解析域名,解析结果只要落入禁止范围就拒绝;真正发请求时绑定同一套解析结果或使用受控出网代理,避免校验和请求之间解析结果变化。

127.0.0.0/8localhost
::1/128IPv6 localhost
10.0.0.0/8私网
172.16.0.0/12私网
192.168.0.0/16私网
169.254.0.0/16link-local,含云 metadata 常见地址
::/128fc00::/7fe80::/10IPv6 特殊和私有范围
TypeScript
import dns from "node:dns/promises";
import ipaddr from "ipaddr.js";

function isBlockedAddress(address: string) {
  const ip = ipaddr.parse(address);
  const range = ip.range();

  return [
    "loopback",
    "linkLocal",
    "private",
    "uniqueLocal",
    "unspecified",
    "broadcast",
    "carrierGradeNat"
  ].includes(range);
}

async function assertPublicHost(hostname: string) {
  const records = await dns.lookup(hostname, { all: true });

  for (const record of records) {
    if (isBlockedAddress(record.address)) {
      throw new Error("URL resolves to a blocked network range");
    }
  }
}

重定向后也要重新校验

很多 SSRF 防护只检查第一跳 URL。攻击者可以给一个看起来正常的外网 URL,让它 302 到内网地址。

更稳的方式是默认不自动跟随重定向:

如果业务确实需要跟随重定向,每一跳都要重新执行同样的校验:

通过标准:重定向不能把一个通过校验的外网 URL 变成内网请求。

scheme仍然只能是允许的协议
host仍然在 allowlist 或公共 IP 范围内
port仍然是允许端口
DNS重新解析后不落入私网和 metadata
跳数最多 3 跳
响应大小超过限制立即停止
TypeScript
const response = await fetch(url, {
  redirect: "manual",
  signal: AbortSignal.timeout(3000)
});

图片代理只下载图片

图片代理最容易被当成“无害功能”。实际上它会让服务器访问用户提供的 URL,并且常常把响应返回给用户。

安全边界要明确:

示例:

不要让图片代理支持任意文件下载、HTML 预览、PDF 渲染或内网截图。这些应该是独立功能,走更严格的沙箱和网络策略。

协议只允许 https
端口只允许 443
DNS/IP禁止 localhost、私网、metadata
重定向禁止或每一跳重新校验
Content-Type只接受图片类型
大小限制响应大小,比如 5 MB
超时连接和读取都有短超时
响应不把上游错误正文原样返回给用户
TypeScript
const response = await fetch(checkedUrl, {
  redirect: "manual",
  signal: AbortSignal.timeout(3000)
});

const contentType = response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
  throw new Error("Only image responses are allowed");
}

Webhook 测试不要变成内网探针

Webhook 创建页面经常有“发送测试请求”按钮。这个按钮如果没有限制,就会让攻击者用你的服务器探测内网。

Webhook 目标应该满足:

危险写法:

更稳的返回:

通过标准:用户不能用 Webhook 测试功能看到内网服务响应,也不能用响应时间稳定判断内网端口是否开放。

  • 只允许 https
  • 不允许 localhost、私网 IP、link-local、metadata。
  • 不允许非标准端口。
  • 不返回上游完整响应给用户。
  • 只展示“请求已发送 / 失败类型 / requestId”。
  • 每个用户、每个目标域名、每分钟限流。
TypeScript
const result = await fetch(userWebhookUrl, {
  method: "POST",
  body: JSON.stringify(testPayload)
});

return await result.text();
TypeScript
return {
  requestId,
  status: response.ok ? "delivered" : "failed",
  statusCode: response.status
};

云 metadata 要从网络层封死

云 metadata 地址通常不可从公网直接访问,但应用服务器自己能访问。SSRF 一旦让服务器请求 metadata 地址,就可能读到实例身份、临时凭据或环境信息。

AWS 文档说明,EC2 Instance Metadata Service 有 IMDSv1 和 IMDSv2。IMDSv2 使用 session-oriented 请求,需要先获取 token,再携带 token 访问 metadata。AWS 还支持把实例配置为要求 IMDSv2。

应用层要拦:

网络层也要拦:

通过标准:即使应用层 URL 校验漏掉,容器或主机网络也不能直接访问云 metadata;即使拿到临时凭据,权限也不足以读取无关资源。

云主机要求 IMDSv2,禁用或限制旧 metadata 访问方式
容器不让业务容器直达 metadata 地址
Kubernetes用 NetworkPolicy / CNI / 出网代理限制 metadata
出网代理对 metadata、localhost、私网地址做 deny
IAM实例角色只给最小权限
Text
169.254.169.254
fd00:ec2::254
metadata.google.internal
169.254.169.254/metadata

Headless browser 和截图服务要隔离

PDF 生成、网页截图、URL 预览经常会启动浏览器打开用户提供的 URL。浏览器不仅会请求页面,还会加载图片、CSS、JS、字体、iframe 和重定向资源。

这类服务要比普通 fetch 更严格:

通过标准:截图/渲染服务跑在隔离容器里,不能访问应用数据库、Redis、Kubernetes API、metadata、内网管理面板。

页面脚本请求内网用出网代理和网络策略拦私网
iframe / 子资源访问 metadata浏览器所在容器禁止访问 metadata
页面读取本地文件禁止 file:// 和本地路径
巨大页面拖垮服务限制超时、内存、CPU、页面大小
截图返回内部错误不把浏览器错误、HTML、headers 原样返回

AI Agent 和 MCP 出网也要管

AI 应用更容易引入 SSRF:Agent 会读网页、调用工具、访问 MCP Server、抓取文档、解析 URL、下载附件。用户输入可以通过 prompt 间接控制工具参数。

高风险场景:

AI 工具层要明确:

通过标准:Agent 不能靠一段 prompt 绕过服务端 URL 校验;MCP 工具也不能绕过应用出网策略。

  • 用户让 Agent “总结这个 URL”。
  • MCP Server 暴露网页抓取工具。
  • RAG 导入远程文档 URL。
  • 自动化浏览器打开用户给的页面。
  • Webhook / callback 配置由自然语言生成。
工具 schemaURL 参数标出允许协议、端口、域名范围
运行权限抓网页工具不能访问内网和 metadata
审批访问陌生域名、内网、非标准端口需要人工确认
日志记录目标 host、结果类型、requestId,不记录敏感响应
数据隔离抓取结果不要直接混进可被外部用户读取的上下文

出网代理比散落的判断更可靠

应用代码里做 URL 校验是必要的,但不能只靠它。更稳的架构是把所有用户控制的外部请求走一个受控出网代理。

出网代理负责:

应用只调用内部代理:

通过标准:新增图片代理、Webhook 测试、URL 预览时,不再每个业务模块各写一套出网校验。

  • 统一解析和校验 URL。
  • 禁止私网、localhost、link-local、metadata。
  • 限制协议、端口、方法、请求头。
  • 限制响应大小、超时、重定向跳数。
  • 记录 requestId、用户、目标 host、结果。
  • 对不同功能配置不同 allowlist。
Text
POST /internal/safe-fetch
{
  "purpose": "image_proxy",
  "url": "https://example.com/avatar.png"
}

日志要能追踪但不能泄露响应

SSRF 排查需要日志,但日志不能把内网响应、metadata、headers、token 原样写出来。

记录这些字段:

不要记录:

通过标准:安全团队能知道谁试图访问什么类型的目标,但日志本身不会变成第二次泄露。

  • 上游完整响应体。
  • metadata 返回内容。
  • 内网服务 banner。
  • Authorization、Cookie、Webhook secret。
  • AI 工具调用得到的敏感页面内容。
requestId当前请求链路
actorIdHash发起用户或租户
purposeimage_proxy / webhook_test / url_preview
targetHost目标 host
resolvedIpClasspublic / private / blocked
decisionallowed / blocked
reasonprivate_ip / metadata / bad_scheme

上线前跑一张 SSRF 检查表

发布前至少确认这些项:

再用只针对自己测试环境的输入做验证:

通过标准:所有服务端 URL 抓取入口都能稳定拒绝 localhost、私网、metadata、非 HTTP 协议和危险重定向。

Text
- [ ] All server-side URL fetching features are listed
- [ ] Fixed integrations use allowlists
- [ ] User-supplied URLs are parsed by standard URL libraries
- [ ] Only required schemes and ports are allowed
- [ ] DNS results are checked before requests
- [ ] localhost, private IP, link-local and metadata ranges are blocked
- [ ] Redirects are disabled or revalidated on every hop
- [ ] Image proxy only accepts image content and has size limits
- [ ] Webhook test requests do not return raw upstream responses
- [ ] Headless browser jobs run in an isolated network
- [ ] Cloud metadata access is blocked at network layer
- [ ] IMDSv2 is required where EC2 metadata is needed
- [ ] Outbound proxy or egress policy covers user-controlled requests
- [ ] Logs record decision and reason without sensitive response body
- [ ] AI/MCP URL tools use the same URL safety layer
Text
https://example.com/image.png        should pass if business allows it
http://127.0.0.1:8080/               should be blocked
http://169.254.169.254/              should be blocked
http://10.0.0.1/                     should be blocked
file:///etc/passwd                   should be blocked

已经发现 SSRF 风险怎么办

如果已经上线了可疑 URL 抓取功能,先按暴露面收缩处理:

SSRF 的本质是信任边界错位:用户控制目标地址,服务器拥有更大的网络位置和云权限。开发者要防的不是某个固定 payload,而是任何“让服务器替用户请求未知目标”的能力。只要这个能力存在,就必须有 URL allowlist、IP 校验、重定向复检、云 metadata 隔离和出网审计。

  • 临时关闭 Webhook 测试、URL 预览、远程图片下载或任意 URL 导入。
  • 查访问日志里是否有人请求过 localhost、私网 IP、metadata、非标准端口。
  • 查云审计日志,确认是否有异常使用实例角色、临时凭据、对象存储、密钥管理或容器 API。
  • 轮换可能被 metadata 或内网服务暴露的凭据。
  • 给功能加 allowlist、DNS/IP 校验、重定向复检和出网代理。
  • 把 metadata、Kubernetes API、Redis、Elasticsearch、管理面板从业务容器出网范围里移除。
  • 给同类功能补测试,确保后面新增入口不会绕过同一层防护。

参考来源

2019 Capital One Cyber IncidentCapital OneThe Alleged Capital One Hacker Didn't Cover Her TracksWiredAPI7:2023 Server Side Request ForgeryOWASP API Security Top 10Server Side Request Forgery Prevention Cheat SheetOWASP Cheat Sheet SeriesCWE-918: Server-Side Request ForgeryMITRE CWEUse the Instance Metadata Service to access instance metadataAWS DocsAccess instance metadata for an EC2 instanceAWS Docs

相关文章

数据库不要直接暴露到公网:Redis、ClickHouse、Elasticsearch 和向量库上线前怎么查工程实践 / 约 13 分钟API 接口上线前要查什么:鉴权、越权、限流、幂等和错误返回工程实践 / 约 13 分钟日志不是越详细越好:开发者如何避免把 token、手机号、订单和 prompt 打进日志工程实践 / 约 12 分钟GitHub Actions 不是能跑就行:第三方 Action、secrets、权限和日志怎么查工程实践 / 约 13 分钟Vibe Coding 小应用上线前要查哪些安全坑智能编程 / 约 12 分钟MCP Server 安装前怎么检查 tool poisoning 风险智能编程 / 约 12 分钟AI Agent 权限怎么设:哪些命令能自动跑,哪些必须人工确认智能编程 / 约 13 分钟Docker 镜像里最容易泄露什么:.env、构建缓存、SSH key 和多阶段构建工程实践 / 约 12 分钟

作者信息