正文
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 服务。
上线前先查这些功能:
| 功能 | SSRF 风险 |
|---|---|
| 图片 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。
先扫代码和配置:
rg -n "fetch\\(|axios\\.|got\\(|request\\(|http\\.get|https\\.get|curl|wget|ffmpeg|ffprobe|playwright|puppeteer|webhook|callback|imageUrl|avatarUrl|previewUrl|metadata" .再按入口建一张表:
| 字段 | 示例 |
|---|---|
| 功能 | 头像 URL 下载 |
| 用户可控字段 | avatarUrl |
| 服务端动作 | fetch(avatarUrl) |
| 是否返回响应给用户 | 返回图片尺寸和错误信息 |
| 是否跟随重定向 | 是 |
| 能访问的网络 | 应用容器出网、内网服务、云 metadata |
| 是否有 allowlist | 无 |
通过标准:每一个“服务端替用户请求 URL”的入口都能说清楚目标范围、允许协议、允许端口、重定向策略、超时、大小限制和日志字段。
优先用 allowlist,不要靠黑名单
如果业务只需要请求固定服务,直接用 allowlist。比如 Webhook 只允许发到企业自有域名,图片只允许从对象存储或指定 CDN 拉取,内部集成只允许配置已登记的 Jira / Slack / SIEM 域名。
示例:
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");
}
}不要用这种判断:
if (input.includes("example.com")) {
await fetch(input);
}字符串包含判断挡不住子域名混淆、用户名密码段、编码、重定向和解析差异。OWASP SSRF Cheat Sheet 建议在能识别可信目标时使用 allowlist,并同时从应用层和网络层防护。
通过标准:固定集成走明确 allowlist;没有业务理由访问任意 URL 的功能,不提供任意 URL 抓取能力。
URL 解析必须走标准库
不要自己拼正则判断 URL。先用语言标准库解析,再检查协议、主机、端口和路径。
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;
}常见危险输入特征:
| 特征 | 风险 |
|---|---|
http:// | 明文传输,也更容易被中间网络劫持 |
file://、gopher://、ftp:// | 可能访问本地文件或非 HTTP 服务 |
| URL 里带用户名密码 | 解析和日志都容易出问题 |
| 非标准端口 | 可能探测内网服务 |
| 超长 URL | 可能触发解析器、日志或下游服务问题 |
| IPv6 / 编码 IP | 可能绕过简单字符串规则 |
通过标准:只允许业务需要的 scheme 和端口;解析失败直接拒绝;不把原始 URL 原样传给多个下游库各自解析。
DNS 解析后再判断 IP
只检查域名字符串不够。攻击者可以让域名解析到私网 IP、localhost、链路本地地址或云 metadata 地址。更麻烦的是 DNS rebinding:第一次解析看起来正常,真正请求时解析到内网。
需要检查解析后的每个 IP:
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");
}
}
}上线前至少拦这些地址范围:
| 地址 | 为什么拦 |
|---|---|
127.0.0.0/8 | localhost |
::1/128 | IPv6 localhost |
10.0.0.0/8 | 私网 |
172.16.0.0/12 | 私网 |
192.168.0.0/16 | 私网 |
169.254.0.0/16 | link-local,含云 metadata 常见地址 |
::/128、fc00::/7、fe80::/10 | IPv6 特殊和私有范围 |
通过标准:请求前解析域名,解析结果只要落入禁止范围就拒绝;真正发请求时绑定同一套解析结果或使用受控出网代理,避免校验和请求之间解析结果变化。
重定向后也要重新校验
很多 SSRF 防护只检查第一跳 URL。攻击者可以给一个看起来正常的外网 URL,让它 302 到内网地址。
更稳的方式是默认不自动跟随重定向:
const response = await fetch(url, {
redirect: "manual",
signal: AbortSignal.timeout(3000)
});如果业务确实需要跟随重定向,每一跳都要重新执行同样的校验:
| 校验点 | 要求 |
|---|---|
| scheme | 仍然只能是允许的协议 |
| host | 仍然在 allowlist 或公共 IP 范围内 |
| port | 仍然是允许端口 |
| DNS | 重新解析后不落入私网和 metadata |
| 跳数 | 最多 3 跳 |
| 响应大小 | 超过限制立即停止 |
通过标准:重定向不能把一个通过校验的外网 URL 变成内网请求。
图片代理只下载图片
图片代理最容易被当成“无害功能”。实际上它会让服务器访问用户提供的 URL,并且常常把响应返回给用户。
安全边界要明确:
| 检查项 | 通过标准 |
|---|---|
| 协议 | 只允许 https |
| 端口 | 只允许 443 |
| DNS/IP | 禁止 localhost、私网、metadata |
| 重定向 | 禁止或每一跳重新校验 |
| Content-Type | 只接受图片类型 |
| 大小 | 限制响应大小,比如 5 MB |
| 超时 | 连接和读取都有短超时 |
| 响应 | 不把上游错误正文原样返回给用户 |
示例:
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");
}不要让图片代理支持任意文件下载、HTML 预览、PDF 渲染或内网截图。这些应该是独立功能,走更严格的沙箱和网络策略。
Webhook 测试不要变成内网探针
Webhook 创建页面经常有“发送测试请求”按钮。这个按钮如果没有限制,就会让攻击者用你的服务器探测内网。
Webhook 目标应该满足:
- 只允许
https。 - 不允许 localhost、私网 IP、link-local、metadata。
- 不允许非标准端口。
- 不返回上游完整响应给用户。
- 只展示“请求已发送 / 失败类型 / requestId”。
- 每个用户、每个目标域名、每分钟限流。
危险写法:
const result = await fetch(userWebhookUrl, {
method: "POST",
body: JSON.stringify(testPayload)
});
return await result.text();更稳的返回:
return {
requestId,
status: response.ok ? "delivered" : "failed",
statusCode: response.status
};通过标准:用户不能用 Webhook 测试功能看到内网服务响应,也不能用响应时间稳定判断内网端口是否开放。
云 metadata 要从网络层封死
云 metadata 地址通常不可从公网直接访问,但应用服务器自己能访问。SSRF 一旦让服务器请求 metadata 地址,就可能读到实例身份、临时凭据或环境信息。
AWS 文档说明,EC2 Instance Metadata Service 有 IMDSv1 和 IMDSv2。IMDSv2 使用 session-oriented 请求,需要先获取 token,再携带 token 访问 metadata。AWS 还支持把实例配置为要求 IMDSv2。
应用层要拦:
169.254.169.254
fd00:ec2::254
metadata.google.internal
169.254.169.254/metadata网络层也要拦:
| 层 | 做法 |
|---|---|
| 云主机 | 要求 IMDSv2,禁用或限制旧 metadata 访问方式 |
| 容器 | 不让业务容器直达 metadata 地址 |
| Kubernetes | 用 NetworkPolicy / CNI / 出网代理限制 metadata |
| 出网代理 | 对 metadata、localhost、私网地址做 deny |
| IAM | 实例角色只给最小权限 |
通过标准:即使应用层 URL 校验漏掉,容器或主机网络也不能直接访问云 metadata;即使拿到临时凭据,权限也不足以读取无关资源。
Headless browser 和截图服务要隔离
PDF 生成、网页截图、URL 预览经常会启动浏览器打开用户提供的 URL。浏览器不仅会请求页面,还会加载图片、CSS、JS、字体、iframe 和重定向资源。
这类服务要比普通 fetch 更严格:
| 风险 | 防护 |
|---|---|
| 页面脚本请求内网 | 用出网代理和网络策略拦私网 |
| iframe / 子资源访问 metadata | 浏览器所在容器禁止访问 metadata |
| 页面读取本地文件 | 禁止 file:// 和本地路径 |
| 巨大页面拖垮服务 | 限制超时、内存、CPU、页面大小 |
| 截图返回内部错误 | 不把浏览器错误、HTML、headers 原样返回 |
通过标准:截图/渲染服务跑在隔离容器里,不能访问应用数据库、Redis、Kubernetes API、metadata、内网管理面板。
AI Agent 和 MCP 出网也要管
AI 应用更容易引入 SSRF:Agent 会读网页、调用工具、访问 MCP Server、抓取文档、解析 URL、下载附件。用户输入可以通过 prompt 间接控制工具参数。
高风险场景:
- 用户让 Agent “总结这个 URL”。
- MCP Server 暴露网页抓取工具。
- RAG 导入远程文档 URL。
- 自动化浏览器打开用户给的页面。
- Webhook / callback 配置由自然语言生成。
AI 工具层要明确:
| 控制点 | 要求 |
|---|---|
| 工具 schema | URL 参数标出允许协议、端口、域名范围 |
| 运行权限 | 抓网页工具不能访问内网和 metadata |
| 审批 | 访问陌生域名、内网、非标准端口需要人工确认 |
| 日志 | 记录目标 host、结果类型、requestId,不记录敏感响应 |
| 数据隔离 | 抓取结果不要直接混进可被外部用户读取的上下文 |
通过标准:Agent 不能靠一段 prompt 绕过服务端 URL 校验;MCP 工具也不能绕过应用出网策略。
出网代理比散落的判断更可靠
应用代码里做 URL 校验是必要的,但不能只靠它。更稳的架构是把所有用户控制的外部请求走一个受控出网代理。
出网代理负责:
- 统一解析和校验 URL。
- 禁止私网、localhost、link-local、metadata。
- 限制协议、端口、方法、请求头。
- 限制响应大小、超时、重定向跳数。
- 记录 requestId、用户、目标 host、结果。
- 对不同功能配置不同 allowlist。
应用只调用内部代理:
POST /internal/safe-fetch
{
"purpose": "image_proxy",
"url": "https://example.com/avatar.png"
}通过标准:新增图片代理、Webhook 测试、URL 预览时,不再每个业务模块各写一套出网校验。
日志要能追踪但不能泄露响应
SSRF 排查需要日志,但日志不能把内网响应、metadata、headers、token 原样写出来。
记录这些字段:
| 字段 | 示例 |
|---|---|
requestId | 当前请求链路 |
actorIdHash | 发起用户或租户 |
purpose | image_proxy / webhook_test / url_preview |
targetHost | 目标 host |
resolvedIpClass | public / private / blocked |
decision | allowed / blocked |
reason | private_ip / metadata / bad_scheme |
不要记录:
- 上游完整响应体。
- metadata 返回内容。
- 内网服务 banner。
- Authorization、Cookie、Webhook secret。
- AI 工具调用得到的敏感页面内容。
通过标准:安全团队能知道谁试图访问什么类型的目标,但日志本身不会变成第二次泄露。
上线前跑一张 SSRF 检查表
发布前至少确认这些项:
- [ ] 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再用只针对自己测试环境的输入做验证:
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通过标准:所有服务端 URL 抓取入口都能稳定拒绝 localhost、私网、metadata、非 HTTP 协议和危险重定向。
已经发现 SSRF 风险怎么办
如果已经上线了可疑 URL 抓取功能,先按暴露面收缩处理:
- 临时关闭 Webhook 测试、URL 预览、远程图片下载或任意 URL 导入。
- 查访问日志里是否有人请求过 localhost、私网 IP、metadata、非标准端口。
- 查云审计日志,确认是否有异常使用实例角色、临时凭据、对象存储、密钥管理或容器 API。
- 轮换可能被 metadata 或内网服务暴露的凭据。
- 给功能加 allowlist、DNS/IP 校验、重定向复检和出网代理。
- 把 metadata、Kubernetes API、Redis、Elasticsearch、管理面板从业务容器出网范围里移除。
- 给同类功能补测试,确保后面新增入口不会绕过同一层防护。
SSRF 的本质是信任边界错位:用户控制目标地址,服务器拥有更大的网络位置和云权限。开发者要防的不是某个固定 payload,而是任何“让服务器替用户请求未知目标”的能力。只要这个能力存在,就必须有 URL allowlist、IP 校验、重定向复检、云 metadata 隔离和出网审计。