jaryn

使用者提交 https://example.invalid/report。首次 DNS 查詢回傳公有位址,因此應用程式核准此請求。伺服器回應重新導向至 http://169.254.169.254/latest/meta-data/。取用客戶端自動跟隨,而原始核准決定靜默地轉為接觸不同主機的權限。

這正是許多 URL 預覽與匯入服務忽略的 SSRF 邊界:一個 URL 並非單一網路目的地。DNS 回應可能改變,重新導向會引入新目的地,而看似公有的主機名稱可能解析到私有空間。

不變條件應更嚴格:

每一次連線嘗試都必須指向政策允許的位址,且每一次重新導向都必須重新經過授權決定。

本文建構此決策點。這是一種防禦模式,並非針對特定框架漏洞的主張。

信任邊界

將這些值視為不可信任且各自獨立:

使用者 URL
  -> 剖析後的 URL
  -> 正規化主機名稱
  -> DNS 回應集合
  -> 選定的通訊端位址
  -> HTTP 重新導向目標
  -> 下一個 DNS 回應集合

進入全螢幕模式 離開全螢幕模式

僅檢查剖析後的主機名稱會遺漏 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));
  }

  // 在服務擁有明確的 IPv6 CIDR 政策前,此處採取封閉失敗。
  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 };
}

進入全螢幕模式 離開全螢幕模式

為什麼只要「任一」回應被禁止就要拒絕主機名稱?因為否則位址選擇會變成隱含的政策抽獎。攻擊者可以影響記錄順序,而執行環境與代理伺服器可能選擇與驗證器不同的位址。

停用自動重新導向

授權必須掌控導航。請使用手動重新導向處理、解析每個目標,並設定較小的跳躍次數上限。

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;
  }
}

進入全螢幕模式 離開全螢幕模式

這能封鎖明顯的重新導向繞過,但它「並未」完整將驗證綁定至通訊端。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"
}

進入全螢幕模式 離開全螢幕模式

切勿記錄 URL 憑證或敏感查詢字串。

回歸測試資料

安全閘道需要負面測試資料,而不僅是成功的公有 URL。

測試資料 預期結果
公有主機、公有位址 允許
字面值 127.0.0.1 拒絕
解析至 10.0.0.8 的主機 拒絕
公有 URL 重新導向至連結區域 於第 1 次跳躍時拒絕
混雜公有/私有 DNS 回應 拒絕
四次重新導向但限制為三次 拒絕
主機名稱變更但仍為公有 重新授權,然後允許
DNS 回應在連線前改變 連接器必須使用核准的位址

在測試中使用本機假解析器與 HTTP 伺服器。請勿探測雲端中繼資料端點來證明政策。

預防、偵測、恢復

階段 控制措施
預防 出口防火牆加上每跳躍位址授權
偵測 記錄政策版本、跳躍次數、主機名稱雜湊、選定位址類別、結果
恢復 取消取得、使快取預覽失效、輪替任何可能暴露的憑證

應用程式驗證不應是唯一的防線。請將 URL 取得工作程式執行在無法接觸控制平面、中繼資料服務、資料庫或內部管理介面的網路區段。如此一來,剖析器錯誤仍會被獨立的出口邊界阻擋。

限制

此範例刻意對 IPv6 採取封閉失敗,而非假裝三個字串檢查即可涵蓋 IPv6 CIDR。它亦省略代理行為、壓縮回應限制、內容類型驗證、回應大小預算,以及連接器特定的 DNS 固定。這些是獨立的控制措施,而非弱化目的地授權的理由。

核心測試很簡單:任何重新導向跳躍或 DNS 轉換是否能讓通訊端接觸到政策未明確核准的位址?若答案為是,則 URL 驗證器僅具參考性,而非安全邊界。