安全地擷取 LLM 建議的網址:一個以 Go 語言實作的 SSRF 防護機制
模型回傳的 URL 是不可信任的輸入。一個具備連線時 IP 檢查、DNS 重新綁定攻擊防禦、逐跳重新導向驗證以及頁面驗證的 Go 擷取器。
越來越多的後端現在會要求語言模型尋找 URL,然後擷取它:一個以搜尋為基礎的模型會建議官方網站,一個代理程式會跟隨它從文件中提取的連結,一個管線會用模型挑選的頁面來豐富記錄。當您的伺服器對來自模型的 URL 發出 HTTP 請求時,您就已經建立了一個典型的 SSRF 攻擊面,而通常「驗證主機名稱」的建議是不夠的。這篇文章將逐步介紹一個生產環境中的 Go 擷取器,它將模型的輸出視為不可信的輸入:靜態 URL 檢查、針對 DNS 重新綁定攻擊的撥號時 IP 釘選、一個涵蓋標準函式庫輔助程式所遺漏範圍的位址黑名單、每次跳轉的重新導向重新驗證,以及最後檢查您擷取的頁面是否確實是您想要的頁面。
為何模型回傳的 URL 是不受信任的輸入
模型回傳的 URL 是由三個您無法控制的方所塑造。首先,模型本身可能會產生幻覺或選擇一個看起來相似的網域。其次,當模型使用搜尋工具時,回傳的連結通常是搜尋服務提供者擁有的追蹤重新導向器,所以您看到的 URL 並非您將會到達的 URL。第三,也是最糟的情況,模型讀取的內容可以引導它:一個頁面可能包含說服模型發出攻擊者所選 URL 的文字。提示詞注入 (Prompt injection) 將「模型建議了一個連結」變成了「攻擊者透過額外步驟建議了一個連結」。
從伺服器的角度來看,這與使用者提交的 webhook URL 是相同的威脅。關鍵的攻擊是伺服器端請求偽造 (Server-Side Request Forgery):讓您的後端對只有它能觸及的目標發出請求。典型的獎品是雲端中繼資料端點(其會發放服務帳號權杖),以及信任任何來自私人網路呼叫者的內部服務。如果您的擷取器在雲端虛擬機或容器平台上執行,這兩者都只需一個 HTTP 請求即可觸及。
因此,設計規則很簡單:擷取器必須自行強制執行,確保其開啟的每個連線都導向一個公開位址,無論 URL 怎麼說,也無論您第二次詢問時 DNS 怎麼說。
靜態 URL 檢查會先進行,但光這樣是不夠的
低成本的檢查會優先執行,因為它們能在進行任何網路作業前,就先拒絕掉大部分的無效資料:
func validateFetchURL(u *url.URL) error {
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("scheme not allowed: %q", u.Scheme)
}
if u.User != nil {
return fmt.Errorf("userinfo in URL rejected")
}
switch u.Port() {
case "", "80", "443", "8080":
default:
return fmt.Errorf("port not allowed: %s", u.Port())
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("empty host")
}
if strings.EqualFold(host, "metadata.google.internal") {
return fmt.Errorf("metadata host rejected")
}
return nil
}
Scheme 釘選杜絕了 file、gopher 及類似的協定。拒絕 userinfo 杜絕了 https://[email protected]/ 這種混淆伎倆。連接埠允許清單可避免擷取器被用作通用的連接埠掃描器。一個具名的元資料主機值得作為特例處理,因為這是攻擊者在 GCP 上首先嘗試的主機名稱。
此函式刻意不做的是解析主機名稱並檢查 IP。這在這裡似乎很自然,而這正是會開啟下一個漏洞的錯誤:如果您在驗證期間解析,然後讓 HTTP 客戶端在連線期間再次解析,這兩個答案不一定相符。
解析一次,檢查每個 IP,連線至您所檢查的位址
DNS 重新綁定是一種「檢查時到使用時」(time-of-check to time-of-use)的漏洞。由攻擊者控制的名稱伺服器會以一個無害的公開位址來回應您的驗證查詢,然後再以中繼資料位址或內部 RFC 1918 位址來回應客戶端的連線查詢。您的檢查通過了;您的請求卻仍然送到了內部目標。低 TTL 值讓這種攻擊在實務上變得可靠。
修復方法是結構性的,而不是再增加一次檢查:在撥號器(dialer)內部執行解析和策略決策,然後連線到您剛才核准的那個確切 IP。Go 語言讓這個過程變得很簡潔,因為 http.Transport 接受自訂的 DialContext:
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil || len(ips) == 0 {
return nil, fmt.Errorf("resolve failed for %s", host)
}
for _, ip := range ips {
if !publicIP(ip) {
return nil, fmt.Errorf("non-public IP blocked: %s", ip)
}
}
d := &net.Dialer{Timeout: 10 * time.Second}
var lastErr error
for _, ip := range ips {
conn, dialErr := d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
}
安全性的關鍵在於兩個細節。每個回傳的位址都必須通過策略檢查,而不僅僅是第一個,因為客戶端可能會容錯移轉到其中任何一個。而且,撥號是連到 ip.String(),而不是回到主機名稱,因此攻擊者沒有第二次解析可以進行污染。TLS 仍然有效:傳輸層會使用原始主機名稱進行 SNI 和憑證驗證的交握,因此將 TCP 連線釘選到經過審查的 IP 在正確性上沒有任何損失。
私有範圍檢查會遺漏的位址
publicIP 最直觀的實作是呼叫 ip.IsPrivate() 和 ip.IsLoopback() 然後就停止了。這會留下實際的漏洞。以下是一個通過審查的述詞:
func publicIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
return false
}
if v4 := ip.To4(); v4 != nil && v4[0] == 169 && v4[1] == 254 {
return false
}
if v6 := ip.To16(); v6 != nil && ip.To4() == nil &&
v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b {
return false
}
return true
}
鏈路本機檢查之所以重要,是因為 169.254.169.254 這個主要雲端服務上的元資料位址是鏈路本機位址,而非 RFC 1918 私有位址。IsLinkLocalUnicast 涵蓋了這種情況,而明確的位元組檢查是一種備援措施,用以確保這個約定不會因重構而遺失。
最後一個區塊是大多數擷取器會忽略的:NAT64 的周知前綴 64:ff9b::/96。在 NAT64 網路上,該前綴中的 IPv6 位址會在其低 32 位元中編碼一個 IPv4 位址,而轉換器會很樂意將您的連線轉發到該位址。無法讓私有 IPv4 位址通過您篩選器的攻擊者,可以改為提交其 NAT64 編碼的 IPv6 形式。IsPrivate 會說該位址沒問題,因為作為一個 IPv6 位址,它是全域單播位址。您必須自行拒絕該前綴。IPv4 對映的 IPv6 位址(以 ::ffff: 為前綴的形式)在 Go 中比較不算是個陷阱,因為 net 套件會在標準述詞執行前將其正規化,但 NAT64 前綴則沒有這種幫助。
重新導向、代理與回應上限
經過審查的第一跳,並不能證明關於第二跳的任何事情。搜尋重新導向器是模型建議連結的常見情況,因此擷取器必須跟隨重新導向,而每一跳都是一個可能到達內部位置的新機會。用戶端會在每一跳重新執行相同的靜態驗證,並對鏈結設定上限:
client := &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
Proxy: nil,
DialContext: safeDialContext,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return validateFetchURL(req.URL)
},
}
Proxy: nil 很容易被忽略。預設的 transport 會遵循 HTTP_PROXY 環境變數,而代理連線會連線到代理伺服器,而非目標,這意味著您精心設計的 dialer 只會檢查代理伺服器的位址,而不會檢查其他東西。關閉代理可讓連線時的防護保持權威性。
回應端也同樣不被信任。透過 io.LimitReader 讀取並設定一個硬性的位元組上限,這樣惡意伺服器就無法傳送數 GB 的資料給您;同時要求 HTTP 200 狀態碼,並檢查 Content-Type 是否符合您預期要解析的類型。記錄重新導向後經過正規化的最終 URL,並將其視為您所擷取內容的標準位址;儲存重新導向前的 URL 意味著儲存的是重新導向器。
驗證您取得了您所請求的頁面
目前為止的一切都是在保護您的網路。但這對正確性毫無幫助:模型可以交給您一個完全公開、完全安全,但完全錯誤的頁面。如果擷取操作的目的是為了確認某個 URL 是特定實體的官方網站,那麼擷取器應該在任何下游程序信任它之前,先檢查此一聲明。
兩種低成本的檢查可以攔截大多數錯誤的答案。首先是語言:如果您預期的是韓文頁面,則要求在去除標籤、註解、以及 script 和 style 區塊後的可見文字中,至少要有一個韓文字元 (Hangul character)。其次是名稱鄰近性:將預期的實體名稱和頁面文字都進行正規化,方法是移除空白並轉為小寫,然後要求該名稱必須出現在文字中。正規化很重要,因為真實頁面對名稱的間距處理不一致,而同一個名稱無論內部有無空格都應該要能匹配。
func normalizeForMatch(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if !unicode.IsSpace(r) {
b.WriteRune(r)
}
}
return b.String()
}
在驗證的同時,請維護一份純網域封鎖清單,用於記錄在您的領域中絕不可能是正確答案的主機,例如社群個人資料、地圖列表和部落格平台,並在擷取前和每次重導轉跳時都進行檢查,因為重導向器會不斷地跳轉到那些主機。這是一個相關性篩選器,而非安全控制,但它在同一個地方運行,並且能省下一次擷取。
此設計不試圖解決的問題
坦白說,此設計有幾個限制。撥號器信任您的解析器:如果攻擊者完全控制您的 DNS 路徑,他們控制的就不僅僅是這個擷取器。IPv6 除了眾所周知的轉譯前綴外,還有其他的轉譯前綴;如果您的網路使用自訂的 NAT64 前綴,請將其加入封鎖清單。頁面驗證是一種啟發式方法,一個堅決的攻擊者可以在他們控制的頁面上放置預期的名稱;應將其視為減少意外的錯誤註冊,而非身份驗證。而且,如果您接著將 HTML 交給具有二次方行為的剖析器,回應上限也無濟於事,所以也要保持剖析有界。
這個模式可以推廣到 LLM 輸出之外的場景。Webhook URL、使用者提交的 RSS 資訊來源、連結預覽和 OAuth 重新導向目標都需要相同的架構:預先進行靜態檢查、在撥號時對字面位址強制執行策略、重新驗證每個躍點、限制回應大小和類型,以及針對使用案例進行網域層級的健全性檢查。一旦這個防護機制以一個帶有自訂撥號器的小型套件形式存在,對於程式碼庫中的任何客戶端來說,使用它只需更換一個 Transport 即可,這就是安全審查發現與預設值之間的區別。