URL Snapshot worker 的 SSRF 風險與防護設計

Roga Lin

2026-09

前言

通常寫 URL Snapshot worker 都是用來抓取特定網址的資訊,例如 title, description 或是頁面上的資料,雖然功能簡單,但背後需要注意的狀況還真不少。

尤其是 SSRF(Server-Side Request Forgery)攻擊,特別容易發生在這類型的應用,最常見的案例是 Snapshot worker 抓到一個惡意的網址,然後嘗試去解析時,反而惡意網址會引導 Snapshot worker 從伺服器發出預期以外的攻擊。

以下列舉各種可能的攻擊情境

網址形式為 Private IP Address

攻擊者可能提交指向 loopback、私有網路、link-local 或其他非公開位址的 IP 位置,例如:

http://127.0.0.1/
http://10.0.0.1/
http://192.168.1.1/
http://169.254.169.254/
http://[::1]/

實際上,攻擊可達性取決於 worker 的部署環境。若 worker 能連到內部管理介面、監控服務或雲端 metadata service,請求就可能用來探測服務,甚至讀取內部資料,造成資安上的風險。

網址形式正常,但解析後是 Private IP Address

攻擊者可以控制一個外觀正常的網域名稱,卻讓它的 DNS A 或 AAAA 記錄指向私有位址。

https://snapshot.example/
          ↓ DNS
      10.0.0.25

如果只從檢查網域名稱,,可能會中招,所以同時解析出來的 IPv4 與 IPv6 都需要一併檢查。

公開網址轉址到內網

初始網址可能連向公開網站,但該網站卻回傳:

HTTP/1.1 302 Found
Location: http://127.0.0.1:8080/admin

假設 worker 啟用 cURL 的自動跟隨轉址。HTTP client 會處理下一個 Location,應用程式沒有機會對每一跳重新執行目的地檢查。

轉址也可能更換協定。libcurl 的預設自動轉址協定範圍包含 HTTP、HTTPS、FTP 與 FTPS;因此應明確限制協定,並由應用程式逐跳驗證。

DNS 檢查與實際連線使用不同位址

即使程式新增了 DNS 檢查,仍可能出現以下時序:

第一次 DNS 解析:公開 IP → 檢查通過
第二次 DNS 解析:內網 IP → HTTP client 實際連線

這是檢查與使用之間的落差,有可能時間差,也有可能是 DNS Round Robin 的結果不同。安全設計必須讓 HTTP client 使用剛才驗證過的 IP,同時保留原始 hostname 進行 HTTP Host、TLS SNI 與憑證名稱驗證。

網址解析差異與特殊表示法

完整網址包含 scheme、userinfo、hostname、port、path、query 與 fragment。不同解析器對反斜線、編碼字元、IPv6、非標準 IP 表示法及 @ 的處理可能不同。

安全檢查與 HTTP client 若將同一段字串解析成不同 hostname,就可能檢查一個目的地、連上另一個目的地。應拒絕含糊或不需要的表示法,並讓驗證與連線使用同一套解析結果。

舉例來說,以下是一段帶有 userinfo 的網址:

https://alice:secret@example.com/page
        └─ userinfo ─┘ └─ 主機 ─┘

考量到這種網址可能引發更多潛在的問題,我建議直接一律 ignore 不抓。

資訊外洩與有副作用的 GET

Worker 會將擷取到的 <title> 後續會顯示在使用者的話面上。因此,內部 HTML 頁面的標題可能成為有限的資料外洩通道。

即使目標沒有回傳可用的 HTML,請求本身仍可能:

就算設有讀取量的上限,也無法阻止這個問題。

建議的設計

將 URL 格式與網路目的地分開檢查

建立及編輯短網址時,務必先進行格式驗證。不過,格式合法只代表它是一個可解析的 HTTP/HTTPS 網址。

Worker 在實際抓取前仍須重新檢查目的地。原因包括:資料庫已有舊資料、DNS 回答會變動,以及轉址後會產生新的目的地。

建議政策如下:

  1. 只接受 httphttps
  2. 預設只允許 HTTP 80 與 HTTPS 443;其他連接埠若有業務需求,再以明確設定開放。
  3. 拒絕 userinfo、控制字元、反斜線及解析結果含糊的網址。
  4. 解析所有 A 與 AAAA 回答。
  5. 拒絕 loopback、私有、link-local、multicast、unspecified、保留及其他不符合公開網路政策的 IP。
  6. 任一 DNS 回答不符合政策時,拒絕整個 hostname。
  7. 使用已驗證的 IP 建立連線,防止 HTTP client 再次解析到其他位址。
  8. 關閉自動轉址;每個新的 Location 都從第一步重新檢查。
  9. 設定轉址次數、請求時間及回應大小上限。
  10. 在網路層限制 worker 的 outbound 連線,作為第二道防線。

如果使用 cURL,固定 DNS 結果時可評估 CURLOPT_RESOLVE。URL 仍維持原 hostname,TLS 憑證與 SNI 才能依原 hostname 正常驗證。每次轉址都應建立新的、經過驗證的目的地設定。

Python 範例:檢查網址及 DNS 回答

以下範例展示 SafeUrlPolicy 的一部分。它只執行連線前檢查,不會發出 HTTP 請求。正式的 SnapshotFetcher 還必須固定已驗證的連線 IP,並自行處理轉址。

import ipaddress
import socket
from urllib.parse import urlsplit


def validate_public_http_url(url: str) -> tuple[str, int, tuple[str, ...]]:
    if url != url.strip():
        raise ValueError("網址前後不可包含空白")

    if any(ord(char) < 32 or ord(char) == 127 for char in url):
        raise ValueError("網址包含控制字元")

    if "\\" in url:
        raise ValueError("網址包含反斜線")

    parts = urlsplit(url)

    if parts.scheme not in {"http", "https"}:
        raise ValueError("只允許 HTTP 或 HTTPS")

    if not parts.hostname:
        raise ValueError("缺少 hostname")

    if parts.username is not None or parts.password is not None:
        raise ValueError("不允許 userinfo")

    try:
        port = parts.port
    except ValueError as error:
        raise ValueError("連接埠無效") from error

    expected_port = 443 if parts.scheme == "https" else 80

    if port is not None and port != expected_port:
        raise ValueError("不允許此連接埠")

    try:
        answers = socket.getaddrinfo(
            parts.hostname,
            expected_port,
            type=socket.SOCK_STREAM,
        )
    except socket.gaierror as error:
        raise ValueError("DNS 解析失敗") from error

    addresses = tuple(sorted({answer[4][0] for answer in answers}))

    if not addresses:
        raise ValueError("沒有可用的 DNS 位址")

    for address in addresses:
        if not ipaddress.ip_address(address).is_global:
            raise ValueError(f"目的地包含非公開位址:{address}")

    return parts.hostname, expected_port, addresses

ipaddress.is_global 提供實用的基礎判斷。正式部署還應確認使用中的 Python 版本、特殊位址分類,以及組織自身的網路政策。這段程式的回傳值必須交給能夠固定連線至已驗證 IP的 HTTP transport;若 transport 自行再次解析 DNS,就會重新產生檢查與實際連線不一致的問題。

完整元件架構

SnapshotWorker Command
  └─ SnapshotProcessor
       ├─ UrlModel
       ├─ SafeUrlPolicy
       └─ SnapshotFetcher
            ├─ DNS resolve
            ├─ IP validation
            ├─ pinned connection
            ├─ redirect validation
            └─ response size limit

各元件的責任:

元件 責任
SnapshotWorker Command 啟動長駐工作、控制輪詢、輸出訊息
SnapshotProcessor 協調單筆工作的領取、抓取資料更新與失敗處理
UrlModel 資狀儲存
SafeUrlPolicy 定義允許的網址、協定、連接埠與 IP 政策
SnapshotFetcher 解析 DNS、驗證 IP、固定連線、逐跳處理轉址、限制回應大小與逾時

每次當 SnapshotFetcher 遇到轉跳 (301,302) 的轉址,都應重新呼叫 SafeUrlPolicy 驗證,也應限制最大轉址次數,避免循環轉址耗盡 worker 資源。

保留既有行為

安全重構不應意外改變快照的資料契約。至少需要保留:

新的安全政策會拒絕部分過去可抓取的網址,因此應將「被政策拒絕」與「一般網路抓取失敗」分別記錄,方便追蹤及評估相容性。

驗證項目與上線順序

測試範圍應該涵蓋:

參考資料