用户提交 https://example.invalid/report。首次 DNS 解析返回一个公网地址,因此应用批准了该请求。服务器返回重定向至 http://169.254.169.254/latest/meta-data/。获取客户端自动跟随重定向,最初的允许决定悄然变成了访问不同主机的权限。
这是许多 URL 预览和导入服务忽略的 SSRF 边界:一个 URL 并不等同于一个网络目的地。DNS 响应可能改变,重定向会引入新的目的地,看似公网的主机名可能解析到私有地址空间。
不变式应当更严格:
每次连接尝试必须指向策略允许的地址,且每个重定向都必须重新获得授权决策。
本文将构建该决策点。这是一个防御模式,而非针对特定框架漏洞的声明。
信任边界
将以下值视为不可信且彼此独立:
user URL
-> parsed URL
-> normalized hostname
-> DNS answer set
-> selected socket address
-> HTTP redirect target
-> next DNS answer set
Enter fullscreen mode Exit fullscreen mode
仅检查解析后的主机名会遗漏 DNS。仅检查首个 DNS 响应会遗漏多条记录。仅检查初始请求会遗漏重定向。检查 Host 头但允许 HTTP 库独立解析,会产生检查时/使用时的差距。
一个有效的策略包含四层:
| 层级 | 拒绝 |
|---|---|
| 协议 | 除显式支持的 https:/http: 之外的任何协议 |
| URL 语法 | 凭证、畸形端口、歧义主机名 |
| 解析 | 回环、私有、链路本地、多播、未指定地址 |
| 导航 | 超过跳转限制或再次违反策略的重定向 |
最小地址门控
Node 的 net.isIP() 可识别语法,但策略仍需 CIDR 分类。下例覆盖高风险 IPv4 范围及 IPv4 映射的 IPv6。生产代码应使用维护中的 IP/CIDR 库,并包含部署所需的完整 IPv6 策略。
import dns from "node:dns/promises";
import net from "node:net";
function blockedIPv4(address) {
const parts = address.split(".").map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
return true;
}
const [a, b] = parts;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127) ||
a >= 224
);
}
function addressAllowed(address) {
if (net.isIPv4(address)) return !blockedIPv4(address);
const normalized = address.toLowerCase();
if (normalized.startsWith("::ffff:")) {
return !blockedIPv4(normalized.slice(7));
}
// Fail closed here until the service has an explicit IPv6 CIDR policy.
return false;
}
async function authorize(urlText) {
const url = new URL(urlText);
if (!["https:", "http:"].includes(url.protocol)) throw new Error("scheme denied");
if (url.username || url.password) throw new Error("credentials denied");
const answers = await dns.lookup(url.hostname, { all: true, verbatim: true });
if (answers.length === 0) throw new Error("no addresses");
if (answers.some(({ address }) => !addressAllowed(address))) {
throw new Error("destination denied");
}
return { url, answers };
}
Enter fullscreen mode Exit fullscreen mode
为何当任一响应被禁止时就拒绝该主机名?因为否则地址选择将变成隐式的策略抽奖。攻击者可影响记录顺序,而运行时和代理可能与你的验证器选择不同的记录。
禁用自动重定向
授权必须掌控导航。使用手动重定向处理、解析每个目标,并设置较小的跳转预算。
async function fetchAuthorized(start, maxRedirects = 3) {
let current = start;
for (let hop = 0; hop <= maxRedirects; hop++) {
await authorize(current);
const response = await fetch(current, { redirect: "manual" });
if (response.status < 300 || response.status >= 400) return response;
const location = response.headers.get("location");
if (!location) throw new Error("redirect without location");
if (hop === maxRedirects) throw new Error("redirect limit exceeded");
current = new URL(location, current).href;
}
}
Enter fullscreen mode Exit fullscreen mode
这能堵住明显的重定向绕过,但并未将验证完全绑定到套接字。HTTP 客户端在 authorize() 之后会执行自己的 DNS 解析。DNS 重绑定攻击者可能在两次操作之间返回不同的响应。
将已批准地址绑定到连接
健壮的设计是在每跳解析一次、选取已批准地址,并将该确切地址提供给连接器,同时保留原始主机名用于 TLS Server Name Indication 和证书验证。
实现取决于 HTTP 栈。在 Node 中,可使用自定义 lookup/dispatcher 或接收已批准地址的 agent。不要通过将 URL 主机名替换为 IP 来解决重绑定:这可能以危险的方式破坏 TLS 验证和虚拟主机。
连接记录应保留证据:
{
"requestHost": "files.example.com",
"resolvedAddress": "203.0.113.18",
"family": 4,
"redirectHop": 1,
"policyVersion": "url-fetch-v3",
"outcome": "allowed"
}
Enter fullscreen mode Exit fullscreen mode
切勿记录 URL 凭证或敏感查询字符串。
回归测试夹具
安全门控需要否定性测试夹具,而非仅测试成功的公网 URL。
| 测试夹具 | 预期结果 |
|---|---|
| 公网主机、公网地址 | 允许 |
字面量 127.0.0.1
|
拒绝 |
解析到 10.0.0.8 的主机 |
拒绝 |
| 重定向到链路本地的公网 URL | 在第 1 跳拒绝 |
| 混杂公网/私有 DNS 响应 | 拒绝 |
| 限制为 3 次的 4 次重定向 | 拒绝 |
| 主机名改变但仍为公网 | 重新授权,然后允许 |
| DNS 响应在连接前改变 | 连接器必须使用已批准的地址 |
在测试中使用本地伪解析器和 HTTP 服务器。不要探测云元数据端点来验证策略。
预防、检测、恢复
| 阶段 | 控制 |
|---|---|
| 预防 | 出口防火墙加上每跳地址授权 |
| 检测 | 记录策略版本、跳转次数、主机名哈希、所选地址类别、结果 |
| 恢复 | 取消获取、使缓存的预览失效、轮换可能泄露的任何凭证 |
应用验证不应是唯一的屏障。应将 URL 获取工作进程运行在无法访问控制平面、元数据服务、数据库或内部管理接口的网段中。这样解析器错误将无法突破独立的出口边界。
限制
示例在 IPv6 上有意采用默认拒绝,而非假装三个字符串检查就能覆盖 IPv6 CIDR。它还省略了代理行为、压缩响应限制、内容类型验证、响应大小预算以及连接器特定的 DNS 固定。这些是独立的控制措施,而非削弱目的地授权的理由。
核心测试很简单:是否存在任何重定向跳转或 DNS 过渡,使套接字能够触达策略未显式批准的地址?如果存在,则 URL 验证器仅是建议性的——而非安全边界。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.