jaryn

用户提交 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 验证器仅是建议性的——而非安全边界。