review修复: SSRF重定向绕过+无价歧义标注

- fetch逐跳校验重定向(每跳过域名+DNS私有IP双校验,防redirect到内网)
- 文档未列价的模型落0时description显式标注(避免0=免费歧义)
- 清理无用import
This commit is contained in:
yumoqing 2026-09-02 16:20:17 +08:00
parent e95e618372
commit dbfeb1498e
2 changed files with 66 additions and 15 deletions

View File

@ -15,6 +15,7 @@ pipeline-service: platform_ability — 平台内部 agent 能力包pipeline_i
llm_vendor.endpoints + llm_api_profile 模板 + llm_model 含四价
"""
import asyncio
import json
import logging
import re
@ -105,6 +106,7 @@ async def _h_platform_llm_status(sor, params, ctx):
# ────────────────────── 工具 2抓取模型 API 文档 ──────────────────────
_MAX_DOC_CHARS = 60000
_MAX_REDIRECTS = 5
def _validate_doc_url(url: str) -> str:
@ -133,25 +135,69 @@ def _html_to_text(html: str) -> str:
return txt.strip()
def _is_private_ip(ip: str) -> bool:
"""DNS 解析后的二次防线:解析到私有/环回/链路本地地址一律拒绝DNS rebinding 防护)。"""
import ipaddress
try:
a = ipaddress.ip_address(ip)
except ValueError:
return True # 解析不出就当私有(拒绝)
return (a.is_private or a.is_loopback or a.is_link_local
or a.is_reserved or a.is_multicast or a.is_unspecified)
async def _fetch_url_safe(url: str, max_redirects: int = _MAX_REDIRECTS):
"""带重定向逐跳校验的抓取(每一跳都过域名+DNS双重校验
返回 (text, content_type)失败抛 ValueError
"""
import aiohttp
current = url
for _ in range(max_redirects + 1):
verr = _validate_doc_url(current)
if verr:
raise ValueError(verr)
host = re.sub(r"^https?://", "", current).split("/")[0].split(":")[0].lower()
# DNS 解析校验(同步阻塞短调用,可接受;防 DNS rebinding 指向内网)
try:
infos = await asyncio.get_event_loop().getaddrinfo(host, None)
except Exception as e:
raise ValueError("文档域名无法解析:%s" % str(e)[:120])
for info in infos:
ip = str(info[4][0])
if _is_private_ip(ip):
raise ValueError("文档域名解析到内网地址(%s),拒绝访问" % ip)
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as sess:
async with sess.get(current, headers={"User-Agent": "Mozilla/5.0"},
allow_redirects=False, ssl=False) as resp:
if resp.status in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location", "")
if not loc:
raise ValueError("重定向缺少 Location")
if loc.startswith("/"):
scheme = "https" if current.startswith("https") else "http"
loc = "%s://%s%s" % (scheme, host, loc)
current = loc
continue
if resp.status != 200:
raise ValueError("抓取失败HTTP %d" % resp.status)
ctype = resp.headers.get("Content-Type", "")
raw = await resp.text(errors="replace")
return raw, ctype
raise ValueError("重定向次数超限(>%d" % max_redirects)
async def _h_fetch_model_doc(sor, params, ctx):
"""抓取模型 API 文档页面 → 纯文本(供 LLM 提取配置规格)。"""
err = await _require_owner(sor, ctx)
if err:
return err
url = (params.get("url") or "").strip()
verr = _validate_doc_url(url)
if verr:
return verr
import aiohttp
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as sess:
async with sess.get(url, headers={"User-Agent": "Mozilla/5.0"},
allow_redirects=True, ssl=False) as resp:
if resp.status != 200:
return "抓取失败HTTP %d" % resp.status
ctype = resp.headers.get("Content-Type", "")
raw = await resp.text(errors="replace")
raw, ctype = await _fetch_url_safe(url)
except ValueError as e:
return str(e)
except Exception as e:
return "抓取失败:%s" % str(e)[:200]
if "html" in ctype.lower() or raw.lstrip()[:15].lower().startswith(("<!doctype", "<html")):
@ -332,9 +378,14 @@ async def _h_apply_llm_config(sor, params, ctx):
skipped.append("(缺 vendor_model_id)")
continue
cap = m.get("capability") or "t2t"
price_in, price_out = _f(m.get("price_input")), _f(m.get("price_output"))
raw_pi, raw_po = m.get("price_input"), m.get("price_output")
price_in, price_out = _f(raw_pi), _f(raw_po)
cost_in = _f(m.get("cost_input"), price_in)
cost_out = _f(m.get("cost_output"), price_out)
desc = (m.get("description", "") or "").strip()
# 文档未给价null→ 落 0 并显式标注避免「0=免费」歧义
if raw_pi is None or raw_po is None:
desc = "文档未列定价暂落0需人工补录" + desc
recs = await sor.sqlExe(
"SELECT id FROM llm_model WHERE vendor_id=${v}$ AND vendor_model_id=${m}$",
{"v": vendor_id, "m": vmid})
@ -346,7 +397,7 @@ async def _h_apply_llm_config(sor, params, ctx):
"cost_input=${ci}$, cost_output=${co}$, description=${d}$, updated_at=NOW() "
"WHERE id=${i}$",
{"pi": price_in, "po": price_out, "ci": cost_in, "co": cost_out,
"d": m.get("description", "") or "", "i": mid})
"d": desc, "i": mid})
await sor.sqlExe("COMMIT", {})
updated.append(vmid)
continue
@ -358,7 +409,7 @@ async def _h_apply_llm_config(sor, params, ctx):
"ppid": "", "price_input": price_in, "price_output": price_out,
"cost_input": cost_in, "cost_output": cost_out,
"default_params": "{}", "status": "active",
"description": m.get("description", "") or "", "org_id": "0"})
"description": desc, "org_id": "0"})
created.append(vmid)
rt_note = ""