2026-09
通常寫 URL Snapshot worker 都是用來抓取特定網址的資訊,例如 title, description 或是頁面上的資料,雖然功能簡單,但背後需要注意的狀況還真不少。
尤其是 SSRF(Server-Side Request Forgery)攻擊,特別容易發生在這類型的應用,最常見的案例是 Snapshot worker 抓到一個惡意的網址,然後嘗試去解析時,反而惡意網址會引導 Snapshot worker 從伺服器發出預期以外的攻擊。
攻擊者可能提交指向 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,請求就可能用來探測服務,甚至讀取內部資料,造成資安上的風險。
攻擊者可以控制一個外觀正常的網域名稱,卻讓它的 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 解析:公開 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 不抓。
Worker 會將擷取到的 <title>
後續會顯示在使用者的話面上。因此,內部 HTML
頁面的標題可能成為有限的資料外洩通道。
即使目標沒有回傳可用的 HTML,請求本身仍可能:
就算設有讀取量的上限,也無法阻止這個問題。
建立及編輯短網址時,務必先進行格式驗證。不過,格式合法只代表它是一個可解析的 HTTP/HTTPS 網址。
Worker 在實際抓取前仍須重新檢查目的地。原因包括:資料庫已有舊資料、DNS 回答會變動,以及轉址後會產生新的目的地。
建議政策如下:
http 與 https。80 與 HTTPS
443;其他連接埠若有業務需求,再以明確設定開放。Location
都從第一步重新檢查。如果使用 cURL,固定 DNS 結果時可評估
CURLOPT_RESOLVE。URL 仍維持原 hostname,TLS 憑證與 SNI
才能依原 hostname
正常驗證。每次轉址都應建立新的、經過驗證的目的地設定。
以下範例展示 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, addressesipaddress.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 資源。
安全重構不應意外改變快照的資料契約。至少需要保留:
TODO → PENDING → NORMAL/FAILED 狀態流程。新的安全政策會拒絕部分過去可抓取的網址,因此應將「被政策拒絕」與「一般網路抓取失敗」分別記錄,方便追蹤及評估相容性。
測試範圍應該涵蓋: