使用者提交 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 驗證器僅具參考性,而非安全邊界。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.