「使用者貼一個網址,伺服器幫他抓下來」。這個功能是這個產品的核心,也是整個專案裡最危險的一支程式。
發請求的是你的伺服器。瀏覽器在使用者的網路裡,碰不到你的機器;伺服器在你的網路裡,碰得到一堆不該給外人看的東西。使用者只要貼一個指向這些地方的網址,你的伺服器就會替他走進去,還把看到的東西帶回來給他。
這個攻擊叫 SSRF,Server-Side Request Forgery,伺服器端請求偽造。名字裡的偽造指的是位置:請求是從你的伺服器、你的網段發出去的,攻擊者只是叫它去打。他不需要打進你的機器,只要叫你的機器去打你的內網。

由上到下四層:驗位址、逐跳驗轉址、連線時釘住 IP、瀏覽器走過濾代理。這一篇由上往下走一遍。最下面那道大小與逾時,是同一支程式另外管的。
你的伺服器在雲端上,跟資料庫在同一個私有網段。它還可能碰得到雲端供應商的 metadata 服務,在很多平台上,那個端點會吐出這台機器的憑證。
現在使用者送來這些網址:
http://localhost:5432 你的資料庫
http://10.0.0.5/admin 內網的管理介面
http://169.254.169.254/... 雲端的 metadata
http://[::1]:6379 IPv6 的本機
如果你只是拿到網址就 fetch,這些全都會打出去,抓回來的內容會存進 raw/。
第一步是解析網址、擋掉不該碰的目的地:
const V4_BLOCKS: [string, number][] = [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
['224.0.0.0', 3], // 224/4 multicast + 240/4 reserved + 255.255.255.255
];
IPv6 那邊另外處理 ::1、fc00::/7、fe80::/10,還有三種會把 IPv4 藏進 IPv6 的寫法:::ffff:0:0/96(映射)、64:ff9b::/96(NAT64)、2002::/16(6to4)。漏掉後面這三種,前面十三條就等於沒寫,攻擊者用 http://[::ffff:127.0.0.1]/ 一樣打得到本機。
100.64.0.0/10 是 CGNAT,某些雲的內網用這一段;169.254.0.0/16 是 link-local,雲端 metadata 就住在這裡。
還要擋協定:只允許 http 和 https,file://、gopher:// 這些一律拒絕。主機名也有黑名單,像 metadata.google.internal。
只驗第一個網址是不夠的,因為轉址。使用者給你 https://某個看起來正常的網站/redirect?to=http://169.254.169.254/,那個網站回一個 302,你的 HTTP 客戶端預設會自動跟過去。你只驗了第一個網址。
所以我關掉自動跟隨轉址,改成自己一跳一跳走:
let url = await assertPublicHttpUrl(raw, opts);
for (let hop = 0; ; hop++) {
const res = await undiciFetch(url.href, {
headers: opts.headers, redirect: 'manual', signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
dispatcher: opts.allowPrivate ? openAgent : strictAgent,
});
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
await res.body?.cancel().catch(() => {});
if (hop >= maxRedirects) throw new NoteError('BAD_PATH', { 'zh-TW': '轉址次數過多', en: 'Too many redirects' });
url = await assertPublicHttpUrl(new URL(res.headers.get('location')!, url).href, opts);
continue;
}
…
redirect: 'manual' 是整段的前提,每一跳的 location 都要再過一次 assertPublicHttpUrl。跳太多次就中止,而且轉址回應的 body 要記得 cancel(),不然連線會掛著。
審查我這段程式的人就是用這招測的:拿一個公開的轉址服務,指向 127.0.0.1。第一版被打穿了。
還有一種攻擊叫 DNS rebinding。流程是這樣:你解析網域拿到一個公開 IP,驗證通過;接著你發出真正的請求,而在這兩個動作之間,攻擊者控制的 DNS 把同一個網域改指向 127.0.0.1。你驗的是 A,連的是 B。
做法是接管 HTTP 客戶端自己的 DNS 查詢,在真正要撥接的那一刻再解析一次,只把非私有的位址交出去:
const strictAgent = new Agent({
connect: {
lookup: ((hostname, options, callback) => {
dnsLookup(hostname, { all: true }).then(addrs => {
const ok = addrs.filter(a => !isPrivateIp(a.address));
if (!ok.length) { callback(Object.assign(new Error(`不允許連線到內部位址:${hostname}`), { code: 'EPRIVATE' }), []); return; }
callback(null, ok.map(a => ({ address: a.address, family: a.family })));
}).catch(err => callback(err, []));
}) as never,
},
});
關鍵在最後那行 callback 只回傳過濾後的位址。這個 lookup 是連線時才呼叫的,所以它看到的就是真的要撥出去的那個 IP,中間就算 DNS 被換掉,換過去的私有位址也會在這裡被濾掉。
之前我們已經講過,無頭瀏覽器會自己去載入網頁裡的所有子資源,那些請求不會經過前面的 safeFetch。啟動 Chromium 時指定一個本機的代理,瀏覽器就沒有直接連外的能力,所有流量都必須經過它。代理對每一個請求做解析、驗證,並連到解析出來的那個 IP;轉址是瀏覽器再發一次,所以每一跳也會再過代理。圖片、字型、XHR、WebSocket 全部包含在內。
我準備了兩個代理,一個嚴格版、一個允許私有網段,允許私有網段的那個只有測試用的 fixture 伺服器會走。
還有兩個不是 SSRF 但同一支程式該處理的事:回應大小上限(我設五 MB,超過就中斷串流,不是讀完再檢查)跟逾時(十五秒)。沒有這兩個,一個永遠不結束的回應就能吃掉你的記憶體。
攻擊者打不進你的內網,就貼一個網址,叫你的伺服器幫他去抓資料庫或雲端憑證,這就是 SSRF。我連出去之前先看要去哪裡,是內網就停,他什麼都拿不到。使用者貼一般網站,該抓還是抓。