wardrobe-os-ml / src /ssrf.py
fengchen31's picture
Sync from GitHub refs/heads/main @ b19ca53
32ebea4 verified
Raw
History Blame Contribute Delete
12.3 kB
"""SSRF guard for the ML service — image URL validation + safe download.
【這個模組在做什麼?】
判斷「這個 URL 可以 fetch 嗎」並做實際下載,是 async FastAPI handler
的 SSRF 防線。哪些 host / scheme 被擋掉的「策略」住在
`ssrf_policy.py`(由 packages/ssrf-policy/policy.yaml 驅動,Node 端共用);
本檔案只負責 transport 層的硬性限制 — follow_redirects / body cap /
timeout / PIL offload — 這四個 caller 不可以 override。
"""
from __future__ import annotations
import asyncio
import ipaddress
import socket
from io import BytesIO
from urllib.parse import urlparse
import httpx
from PIL import Image
from src.ssrf_policy import is_allowed_scheme, is_blocked_host
# 10 MB per image download — 比典型電商圖(~1 MB)寬,但擋掉惡意超大檔案
# 讓 handler 在記憶體載入前就失敗。
_MAX_IMAGE_BYTES = 10 * 1024 * 1024
# PIL 解壓縮炸彈防護 — 4 KB 壓縮 PNG 可宣告 50000×50000 RGB → 解壓 7.5 GB
# OOM-kill 整個 service。10 MB body cap 不擋這條,因為惡意檔案壓縮後很小。
# 在本模組頂端設一次(非靠 main.py 的 import 順序)— ssrf.py 是 download_image
# 唯一的 entry point,任何 caller 拿到 PIL Image 之前一定先過這條 import。
# 對齊 paste_parser.py + main.py 的 40_000_000 上限(feedback_pil_decompression_bomb_cap.md)。
Image.MAX_IMAGE_PIXELS = 40_000_000
# Slowloris defence layer 1 — granular per-recv httpx.Timeout
# Pre-fix code passed a scalar `timeout=timeout_s` which httpx applies to each
# socket op (connect / read / write / pool) **separately**. Attacker streaming
# 1 byte every (read - 0.1)s within a chunk loop holds the connection across
# many recv calls — `len(chunks) × read_timeout` worst case wall clock
# (10 MB cap / 65 KB chunks = 160 chunks, 160 × 10s = 1600s on a single
# download). Under `asyncio.to_thread` worker concurrency (`min(32, cpu+4)`)
# 32 simultaneous slowloris connections starve all other endpoints.
#
# Granular tiers split connect/pool (TCP handshake — should be fast) from
# read/write (per-recv data ops — can be slower for large bodies). Layer 2
# (asyncio.wait_for total ceiling) catches drip feed within per-recv budget.
_PER_RECV_TIMEOUT = httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=5.0)
# 預設總 wall-clock ceiling — F4 v2 valuation_observations 同步 await 路徑可
# 縮到 3s 做 fast-fail,新增 caller 也可 override 用更短上限。30s 給標準
# 10 MB image 充足餘裕(典型 1 MB CDN image 通常 < 1s 完成)。
_DEFAULT_TIMEOUT_S = 30.0
def validate_image_url(url: str) -> None:
"""Validate a URL before fetching. Raises ValueError on SSRF indicators.
檢查順序:
1. Scheme 必須在 allowed 清單(目前只有 https)。
2. hostname 非空。
3. hostname 不在 block list(精確或 CIDR)。
4. DNS resolve hostname → 每個解析到的 IP 也得通過 block list
(codebase review v19 ML-03,DNS rebinding mitigation)。
【DNS rebinding 攻擊面】
Attacker 註冊 `img.attacker.com` → DNS 先回 public IP → static
hostname check 通過 → httpx 連線時 attacker 切換 DNS 回
`169.254.169.254`(AWS metadata)/ `10.0.0.1`(內部服務)。
`follow_redirects=False` 救不到,因為是 DNS-level 切換。
這層 mitigation 做的是 **解析後立刻 re-check IP**:static check 通過、
DNS resolve、每個 IP 也跑 `is_blocked_host`。**TOCTOU 仍存在**
— 真正零窗口要 socket-level peer IP 驗證(httpx transport hook 或
自訂 resolver)— 但 static-resolve 已關掉絕大部分 rebind window,
留待後續 sprint 補完。
DNS resolve 失敗 → raise(fail-closed,跟既有 SSRF 嚴格一致)。
"""
parsed = urlparse(url)
if not is_allowed_scheme(parsed.scheme):
raise ValueError(f"Only https URLs allowed, got: {parsed.scheme or '(none)'}")
hostname = (parsed.hostname or "").lower()
if not hostname:
raise ValueError("URL missing hostname")
if is_blocked_host(hostname):
raise ValueError(f"Internal host not allowed: {hostname}")
# DNS resolve + per-IP block check (ML-03). Skip when hostname is already
# an IP literal — `is_blocked_host` already exercised the literal/CIDR
# paths, and `getaddrinfo` on a literal just round-trips it (extra DNS
# latency for no security gain). The literal/CIDR coverage above is the
# canonical path for IP-form URLs.
try:
ipaddress.ip_address(hostname.rstrip(".").strip("[]"))
is_ip_literal = True
except ValueError:
is_ip_literal = False
if is_ip_literal:
return
try:
addrs = socket.getaddrinfo(hostname, None)
except socket.gaierror as exc:
# Fail-closed: a hostname that won't resolve is not a hostname we
# are willing to fetch. Matches the strict SSRF posture elsewhere
# in this module.
raise ValueError(f"DNS resolution failed for: {hostname}") from exc
for entry in addrs:
sockaddr = entry[4] # (host, port[, flowinfo, scopeid])
ip_str = sockaddr[0]
if is_blocked_host(ip_str):
raise ValueError(f"Resolved IP {ip_str} for {hostname} is blocked")
def _decode_image(buf: BytesIO) -> Image.Image:
"""Synchronous PIL decode — call via asyncio.to_thread to keep event loop free.
【為什麼接 BytesIO 不接 bytes?】
`_do_download` 把 chunk 寫進單一 BytesIO accumulator(取代舊的
`chunks: list[bytes] + b"".join`),少一份完整 bytes 副本 — 10 MB cap
× 32 並發 worker peak 從 ~640 MB 降回 ~320 MB。PIL 的 `Image.open` 直
接吃 file-like,零成本接管。Buf 必須活到 `.convert("RGB")` 完成 —
Image.open 是 lazy 只 parse header;img.load() 顯式 force decode 把
data 拉進新 PIL object,之後 buf 可以被 GC。本函式 frame 持有 buf
到 return 為止,符合此約束;未來 refactor 不可把 buf 提早 close。
【為什麼要 offload?】
`Image.open` 是 lazy(只 parse header),但 `.convert("RGB")` 會 force
full decode + color transform:10 MB JPEG 約 100-300ms 純 CPU。跑在
event loop 上會把所有其他 coroutine 都凍住——4 個 async caller
(`/verify/local` 雙圖 + outfit_match + valuation observation) 全部
serialise 在 PIL CPU 後面。`img.load()` 顯式觸發 full decode,避免
`.convert` 內部 lazy IO 還埋在這條 thread 之外。
"""
# buf 寫完 position 在尾端;Image.open 內部會 seek 但顯式重置防 PIL 行為變動
buf.seek(0)
img = Image.open(buf)
img.load() # force full decode (else .convert lazy-reads from BytesIO)
return img.convert("RGB")
async def download_image(
url: str, timeout_s: float = _DEFAULT_TIMEOUT_S
) -> Image.Image:
"""Download and decode an image with SSRF validation + size cap + slowloris defence.
Use this instead of `requests.get` or `httpx.get` directly from any
async handler — it enforces the allow list, disables redirects (so an
allow-listed host can't bounce to an internal IP), caps the body at
10 MB during streaming, applies granular per-recv timeouts, wraps the
whole download in an asyncio.wait_for total ceiling, and offloads PIL
decode to a thread.
`timeout_s` 是**總 wall-clock ceiling**(asyncio.wait_for 上限)。預設
30s 給標準 10 MB image 充足餘裕;valuation_observations 在 predict
同步 await 路徑上用 3s 做 fast-fail。Per-recv 粒度(connect/read/write/pool)
住在 `_PER_RECV_TIMEOUT` 常數(5/10/10/5)—— caller 不可 override,
維持 sibling parity 與 sync `lgbm_valuation_image._fetch_and_embed` 一致。
【為什麼 stream 而非 get?】
sync sibling `_fetch_and_embed` post-review (security conf 80) 教訓:
`await client.get(url)` 會把整個 body 讀進記憶體才回。攻擊者宣告
Content-Length: 1024 但 server 實際 stream 100MB,post-hoc
`len(resp.content)` 已經 OOM;asyncio.to_thread 並發場景下多 request 各
buffer 10MB+ 會耗 thread pool memory。stream + chunk-loop 累計,超過
cap 立即 raise — 硬上限就是 cap,不是事後檢查
(feedback_streaming_body_cap_before_full_read.md)。
【為什麼分 per-recv granularity 與 total ceiling 兩層?】
Pre-fix 用 scalar `httpx.AsyncClient(timeout=timeout_s)`,httpx 把這個
值套到每個 socket op (connect / read / write / pool) **各自**做計時。
攻擊者每 (read - 0.1)s 送 1 byte 進 chunk loop,每個 recv 都剛好不超
per-recv 上限,但連線可被拉到 `len(chunks) × read_timeout` 才斷。
10MB cap / 65KB chunk = 160 chunk,1600s 拖一條連線 — 32 條並發直接
塞滿 worker pool。修法兩層:(1) `_PER_RECV_TIMEOUT` 拆 connect/pool
(TCP 握手) vs read/write (data 傳輸) 各自配額;(2) `asyncio.wait_for`
包整個 download 過程 — 即使每 recv 都在 per-recv 預算內,總 wall
clock 超 ceiling 也立刻中止。兩層必須 coexist。
"""
# offload to thread: validate_image_url runs blocking socket.getaddrinfo,
# which freezes the event loop and starves every Gemma capability sharing
# the loop when DNS is slow (uncached lookup / attacker-controlled slow
# auth NS). Per .claude/rules/ml-service.md §Async discipline.
await asyncio.to_thread(validate_image_url, url)
async def _do_download() -> BytesIO:
# BytesIO accumulator 取代 `chunks: list[bytes] + b"".join`:少一份
# 完整 bytes 副本,10 MB cap × 32 並發 worker peak 從 ~640 MB 降到
# ~320 MB。BytesIO.write 累進到單一內部緩衝(CPython 實作細節 —
# 用 C-level over-allocation,比每次重新 alloc 一份新 bytes 來得便宜,
# 但具體 growth 策略不該被本層 code 假設);_decode_image 直接收
# BytesIO。Sibling parity 與 sync `lgbm_valuation_image._fetch_and_embed`
# 一致。
buf = BytesIO()
total = 0
async with httpx.AsyncClient(
timeout=_PER_RECV_TIMEOUT, follow_redirects=False
) as client:
async with client.stream("GET", url) as resp:
resp.raise_for_status()
# 重要:aiter_bytes() 回傳「已解壓」bytes(httpx 透明解碼
# Content-Encoding: gzip / brotli)。禁止改為 aiter_raw() —
# raw 是「壓縮前」bytes,會讓 gzip bomb 用 10KB 壓縮資料宣告
# 1GB 解壓後內容繞過 _MAX_IMAGE_BYTES cap(post-review security
# P2,conf 80)。10MB cap 套在解壓後大小才正確。
async for chunk in resp.aiter_bytes(chunk_size=65536):
total += len(chunk)
if total > _MAX_IMAGE_BYTES:
raise ValueError(
f"Image too large: > {_MAX_IMAGE_BYTES} bytes "
"(streaming cap)"
)
buf.write(chunk)
return buf
try:
buf = await asyncio.wait_for(_do_download(), timeout=timeout_s)
except asyncio.TimeoutError as exc:
# Convert to ValueError so caller's uniform `except Exception` log
# path stays consistent with body-cap + SSRF reject (1-line
# `safe_url, exc` warning, no FastAPI default 500 stack trace).
raise ValueError(
f"Image download exceeded total ceiling: > {timeout_s}s"
) from exc
# PIL decode is CPU-bound (100-300ms for 10MB JPEG). Offload to thread
# to keep the event loop free for other coroutines — under 4-caller
# async load (`/verify/local` dual + outfit_match + valuation observation)
# PIL on the loop serialises every other inference. See _decode_image
# docstring for the load()-then-convert ordering rationale + BytesIO
# ownership lifetime constraint (buf must outlive .convert).
return await asyncio.to_thread(_decode_image, buf)