review修复: SSRF重定向绕过+无价歧义标注
- fetch逐跳校验重定向(每跳过域名+DNS私有IP双校验,防redirect到内网) - 文档未列价的模型落0时description显式标注(避免0=免费歧义) - 清理无用import
This commit is contained in:
parent
e95e618372
commit
dbfeb1498e
Binary file not shown.
@ -15,6 +15,7 @@ pipeline-service: platform_ability — 平台内部 agent 能力包(pipeline_i
|
|||||||
(llm_vendor.endpoints + llm_api_profile 模板 + llm_model 含四价)
|
(llm_vendor.endpoints + llm_api_profile 模板 + llm_model 含四价)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@ -105,6 +106,7 @@ async def _h_platform_llm_status(sor, params, ctx):
|
|||||||
# ────────────────────── 工具 2:抓取模型 API 文档 ──────────────────────
|
# ────────────────────── 工具 2:抓取模型 API 文档 ──────────────────────
|
||||||
|
|
||||||
_MAX_DOC_CHARS = 60000
|
_MAX_DOC_CHARS = 60000
|
||||||
|
_MAX_REDIRECTS = 5
|
||||||
|
|
||||||
|
|
||||||
def _validate_doc_url(url: str) -> str:
|
def _validate_doc_url(url: str) -> str:
|
||||||
@ -133,25 +135,69 @@ def _html_to_text(html: str) -> str:
|
|||||||
return txt.strip()
|
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):
|
async def _h_fetch_model_doc(sor, params, ctx):
|
||||||
"""抓取模型 API 文档页面 → 纯文本(供 LLM 提取配置规格)。"""
|
"""抓取模型 API 文档页面 → 纯文本(供 LLM 提取配置规格)。"""
|
||||||
err = await _require_owner(sor, ctx)
|
err = await _require_owner(sor, ctx)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
url = (params.get("url") or "").strip()
|
url = (params.get("url") or "").strip()
|
||||||
verr = _validate_doc_url(url)
|
|
||||||
if verr:
|
|
||||||
return verr
|
|
||||||
import aiohttp
|
|
||||||
try:
|
try:
|
||||||
timeout = aiohttp.ClientTimeout(total=30)
|
raw, ctype = await _fetch_url_safe(url)
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as sess:
|
except ValueError as e:
|
||||||
async with sess.get(url, headers={"User-Agent": "Mozilla/5.0"},
|
return str(e)
|
||||||
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")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return "抓取失败:%s" % str(e)[:200]
|
return "抓取失败:%s" % str(e)[:200]
|
||||||
if "html" in ctype.lower() or raw.lstrip()[:15].lower().startswith(("<!doctype", "<html")):
|
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)")
|
skipped.append("(缺 vendor_model_id)")
|
||||||
continue
|
continue
|
||||||
cap = m.get("capability") or "t2t"
|
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_in = _f(m.get("cost_input"), price_in)
|
||||||
cost_out = _f(m.get("cost_output"), price_out)
|
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(
|
recs = await sor.sqlExe(
|
||||||
"SELECT id FROM llm_model WHERE vendor_id=${v}$ AND vendor_model_id=${m}$",
|
"SELECT id FROM llm_model WHERE vendor_id=${v}$ AND vendor_model_id=${m}$",
|
||||||
{"v": vendor_id, "m": vmid})
|
{"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() "
|
"cost_input=${ci}$, cost_output=${co}$, description=${d}$, updated_at=NOW() "
|
||||||
"WHERE id=${i}$",
|
"WHERE id=${i}$",
|
||||||
{"pi": price_in, "po": price_out, "ci": cost_in, "co": cost_out,
|
{"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", {})
|
await sor.sqlExe("COMMIT", {})
|
||||||
updated.append(vmid)
|
updated.append(vmid)
|
||||||
continue
|
continue
|
||||||
@ -358,7 +409,7 @@ async def _h_apply_llm_config(sor, params, ctx):
|
|||||||
"ppid": "", "price_input": price_in, "price_output": price_out,
|
"ppid": "", "price_input": price_in, "price_output": price_out,
|
||||||
"cost_input": cost_in, "cost_output": cost_out,
|
"cost_input": cost_in, "cost_output": cost_out,
|
||||||
"default_params": "{}", "status": "active",
|
"default_params": "{}", "status": "active",
|
||||||
"description": m.get("description", "") or "", "org_id": "0"})
|
"description": desc, "org_id": "0"})
|
||||||
created.append(vmid)
|
created.append(vmid)
|
||||||
|
|
||||||
rt_note = ""
|
rt_note = ""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user