diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c8cb8ec3..9484e8fd 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2492,7 +2492,7 @@ ## BUG-146 | staging 后台写请求把 Caddy 上游协议误当公开来源 -- 状态:resolved(local candidate,未 push / deploy) +- 状态:resolved - 首次发现:2026-08-07 - 最近更新:2026-08-07 - 影响面:所有经 `requireAdminMutation` 的后台写接口、`admin.staging.jyotisha.chat` 模型管理写操作;普通 staging 用户域名、其他后台功能的通用 `ReasonActionModal` 与 production 未改动。 @@ -2520,3 +2520,19 @@ - 相关记录:BUG-124、BUG-135、BUG-145、BUG-146 - 复发自:无 - 修复版本:待本次 staging SHA + +## BUG-148 | Node 22/24 all-address lookup 使模型发现 DNS pinning 失效 + +- 状态:resolved +- 首次发现:2026-08-08 +- 最近更新:2026-08-08 +- 影响面:共享 `requestAllowedModelProvider` 的固定 DNS HTTPS 请求;OpenAI-compatible 供应商模型发现无法到达已通过公网 SSRF 校验的上游。支付网关、SSRF 边界、重定向拒绝、超时与响应大小限制未放宽。 +- 用户现象:staging Web 容器内使用同一 DNS pinning/request 链路请求模型列表时失败,脱敏错误为 `ERR_INVALID_IP_ADDRESS`,已固定的地址族为 IPv4。 +- 触发条件:Node 22/24 的 `https.request` / `net.connect` 以 `lookup` 选项 `all=true` 调用自定义 DNS callback;旧实现无条件调用 `callback(null, address, family)`,而 all-address 契约要求第二参数为 `[{ address, family }]`。 +- 根因:共享 HTTPS helper 只实现了旧的单地址 lookup callback 形态,没有根据 Node 传入的 `options.all` 切换返回值;因此安全校验和 DNS 解析均成功后,Node 在建连前把字符串结果按地址数组读取并抛出 `ERR_INVALID_IP_ADDRESS`。discover route 的 provider 读取、密钥解密、`/models` URL、响应解析和错误翻译均不是本次失败根因。 +- 修复:抽出 `pinnedAddressLookup` 供共享 HTTPS 请求使用;`options.all=true` 时返回单元素已验证地址数组,其他模式继续返回 `address, family`。仍只使用已通过完整公网校验的固定地址,不重新解析、不跟随重定向,也不删除任何 SSRF/信任边界校验。 +- 验证:`npx tsx --test tests/model-provider-encrypted-credentials.test.ts` 的真实本地 TCP 回归在修复前稳定失败为 `ERR_INVALID_IP_ADDRESS`(4 passed / 1 failed),修复后对 `autoSelectFamily=true` 的 all-address 模式和 `autoSelectFamily=false` 的单地址模式均实际建连成功(5 passed / 0 failed)。 +- 防复发:DNS-pinned 请求测试必须通过 Node 网络栈实际调用自定义 lookup,并同时覆盖地址数组与单地址 callback 契约;不得用只匹配源码字符串的断言替代该回归。 +- 相关记录:BUG-146、BUG-147 +- 复发自:无 +- 修复版本:2026-08-08 staging 变更 diff --git a/frontend/src/components/admin/model-id.ts b/frontend/src/components/admin/model-id.ts new file mode 100644 index 00000000..5a4f2919 --- /dev/null +++ b/frontend/src/components/admin/model-id.ts @@ -0,0 +1,8 @@ +export async function createDiscoveredModelId(providerId: string, providerModel: string) { + const digest = await globalThis.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`${providerId}\0${providerModel}`), + ); + const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `m-${hex.slice(0, 62)}`; +} diff --git a/frontend/src/components/admin/model-management.tsx b/frontend/src/components/admin/model-management.tsx index dee61d59..23ac356b 100644 --- a/frontend/src/components/admin/model-management.tsx +++ b/frontend/src/components/admin/model-management.tsx @@ -24,6 +24,7 @@ import { import { useCallback, useEffect, useState } from "react"; import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; +import { createDiscoveredModelId } from "./model-id"; import { formatAdminDate } from "./resource-table"; const { Text } = Typography; @@ -72,10 +73,7 @@ type ProviderForm = { apiKey?: string; enabled: boolean; }; -type ModelForm = Omit & { - versionId?: string; - settingsJson: string; -}; +type ModelForm = Pick; type ModelsPayload = { data: ModelVersion[]; total: number; providers: Provider[] }; type DiscoveredModel = { id: string; label?: string }; @@ -106,7 +104,6 @@ export default function ModelManagement() { const [modelForm] = Form.useForm(); const [filterForm] = Form.useForm(); const selectedProviderId = Form.useWatch("providerId", modelForm); - const selectedProviderModel = Form.useWatch("providerModel", modelForm); const [models, setModels] = useState([]); const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(true); @@ -167,38 +164,19 @@ export default function ModelManagement() { function openModel(model?: ModelVersion, providerId?: string) { setEditingModel(model ?? null); - setDiscoveredModels([]); + setDiscoveredModels(model ? [{ id: model.providerModel, label: model.label }] : []); modelForm.setFieldsValue(model ? { - modelId: model.modelId, - versionId: model.id, providerId: model.providerId, - label: model.label, - description: model.description, providerModel: model.providerModel, - modelTier: model.modelTier, creditCost: model.creditCost, - contextWindow: model.contextWindow, - inputCostMicrousdPerMillion: model.inputCostMicrousdPerMillion, - outputCostMicrousdPerMillion: model.outputCostMicrousdPerMillion, enabled: model.enabled, isDefault: model.isDefault, - fallbackModelId: model.fallbackModelId, - settingsJson: JSON.stringify(model.settings, null, 2), } : { - modelId: "", providerId: providerId ?? providers[0]?.id, - label: "", - description: "", providerModel: "", - modelTier: "standard", creditCost: 1, - contextWindow: null, - inputCostMicrousdPerMillion: 0, - outputCostMicrousdPerMillion: 0, enabled: false, isDefault: false, - fallbackModelId: null, - settingsJson: "{}", }); setModelOpen(true); } @@ -239,7 +217,7 @@ export default function ModelManagement() { body: JSON.stringify({ providerId }), }); setDiscoveredModels(payload.data); - if (!payload.data.length) message.info("未发现可用模型,可继续手工输入"); + if (!payload.data.length) message.info("未发现可用模型,请检查供应商配置后重试"); } catch (error) { message.error(error instanceof Error ? error.message : "获取模型列表失败"); } finally { @@ -247,46 +225,30 @@ export default function ModelManagement() { } } - function selectDiscoveredModel(providerModel: string) { - const discovered = discoveredModels.find((item) => item.id === providerModel); - const values = modelForm.getFieldsValue(["modelId", "label"]); - modelForm.setFieldsValue({ - providerModel, - ...(!values.modelId ? { - modelId: providerModel.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "model", - } : {}), - ...(!values.label ? { label: discovered?.label?.trim() || providerModel } : {}), - }); - } - async function saveModel(values: ModelForm) { setSaving(true); try { - let settings: unknown; - try { - settings = JSON.parse(values.settingsJson); - } catch { - throw new Error("设置 JSON 格式不正确"); - } + const discovered = discoveredModels.find((item) => item.id === values.providerModel); + const modelId = editingModel?.modelId ?? await createDiscoveredModelId(values.providerId, values.providerModel); await adminRequestJson("/api/admin/models", { method: "POST", body: JSON.stringify({ action: "saveDraft", - modelId: values.modelId.trim(), + modelId, versionId: editingModel?.id ?? null, providerId: values.providerId, - label: values.label.trim(), - description: values.description?.trim() ?? "", + label: editingModel?.label ?? (discovered?.label?.trim() || values.providerModel).slice(0, 60), providerModel: values.providerModel.trim(), - modelTier: values.modelTier, creditCost: values.creditCost, - contextWindow: values.contextWindow ?? null, - inputCostMicrousdPerMillion: values.inputCostMicrousdPerMillion, - outputCostMicrousdPerMillion: values.outputCostMicrousdPerMillion, enabled: values.enabled, + description: editingModel?.description ?? "", + modelTier: editingModel?.modelTier ?? "standard", + contextWindow: editingModel?.contextWindow ?? null, + inputCostMicrousdPerMillion: editingModel?.inputCostMicrousdPerMillion ?? 0, + outputCostMicrousdPerMillion: editingModel?.outputCostMicrousdPerMillion ?? 0, isDefault: values.isDefault, - fallbackModelId: values.fallbackModelId?.trim() || null, - settings, + fallbackModelId: editingModel?.fallbackModelId ?? null, + settings: editingModel?.settings ?? {}, }), }); message.success("模型草稿已保存"); @@ -415,39 +377,40 @@ export default function ModelManagement() { - modelForm.submit()} onCancel={() => setModelOpen(false)} destroyOnHidden> + modelForm.submit()} onCancel={() => setModelOpen(false)} destroyOnHidden> form={modelForm} layout="vertical" onFinish={saveModel}> - - - - { + setDiscoveredModels([]); + modelForm.setFieldValue("providerModel", ""); + }} + options={providers.map((item) => ({ value: item.id, label: `${item.name} (${item.code})` }))} + /> + + + + + - - - ({ validator(_, value) { return value && !getFieldValue("enabled") ? Promise.reject(new Error("默认模型必须启用")) : Promise.resolve(); } })]}> Promise; type ModelProviderEnvironment = Readonly>; +export function pinnedAddressLookup(pinned: ResolvedAddress): LookupFunction { + return (_hostname, options, callback) => { + if (options.all) callback(null, [pinned]); + else callback(null, pinned.address, pinned.family); + }; +} + function blockedIpv4(address: string) { const parts = address.split(".").map(Number); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true; @@ -166,7 +173,7 @@ async function requestPinnedHttps( agent: false, headers, servername: isIP(hostname) ? undefined : hostname, - lookup: (_hostname, _options, callback) => callback(null, pinned.address, pinned.family), + lookup: pinnedAddressLookup(pinned), }, (response) => { const status = response.statusCode ?? 0; if (status >= 300 && status < 400) { diff --git a/frontend/tests/admin-model-id.test.ts b/frontend/tests/admin-model-id.test.ts new file mode 100644 index 00000000..64f6baaf --- /dev/null +++ b/frontend/tests/admin-model-id.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createDiscoveredModelId } from "../src/components/admin/model-id.ts"; + +const providerA = "11111111-1111-4111-8111-111111111111"; +const providerB = "22222222-2222-4222-8222-222222222222"; + +test("discovered model IDs are deterministic, distinct, and API-compatible", async () => { + const inputs = [ + [providerA, "gpt-4o"], + [providerB, "gpt-4o"], + [providerA, "GPT-4o"], + [providerA, " gpt-4o"], + [providerA, "gpt-4o "], + [providerA, "模型-α"], + ] as const; + const ids = await Promise.all(inputs.map(([providerId, providerModel]) => createDiscoveredModelId(providerId, providerModel))); + + assert.equal(new Set(ids).size, inputs.length); + assert.equal(ids[0], "m-f1966f65ba46e2c192e274f77c120dbc90f32e6caa2bcd75bb52e4f985b8cb"); + assert.equal(await createDiscoveredModelId(providerA, "gpt-4o"), ids[0]); + for (const id of ids) { + assert.equal(id.length, 64); + assert.match(id, /^m-[0-9a-f]{62}$/); + } +}); diff --git a/frontend/tests/admin-model-management-ui-contract.test.ts b/frontend/tests/admin-model-management-ui-contract.test.ts index d03d2c73..aeb1a610 100644 --- a/frontend/tests/admin-model-management-ui-contract.test.ts +++ b/frontend/tests/admin-model-management-ui-contract.test.ts @@ -23,17 +23,47 @@ test("provider form keeps codes server-owned and API keys write-only", () => { assert.match(component, /anthropic: "Anthropic 官方"/); }); -test("model discovery stays searchable with a manual provider model fallback", () => { +test("model discovery is the only model picker and generates system fields", () => { assert.match(component, /onClick=\{\(\) => void discoverModels\(selectedProviderId\)\}>获取模型列表<\/Button>/); assert.doesNotMatch(component, /title="获取供应商模型列表"[\s\S]*ReasonActionModal/); assert.match(component, /adminRequestJson\("\/api\/admin\/models\/discover"/); assert.match(component, /body: JSON\.stringify\(\{ providerId \}\)/); - assert.match(component, /获取模型列表/); - assert.match(component, / { + assert.match(component, /name="creditCost" label="单次点数"/); + assert.match(component, /name="enabled" label="启用"/); + assert.match(component, /name="isDefault" label="默认模型"/); + for (const field of [ + "description", + "modelTier", + "contextWindow", + "inputCostMicrousdPerMillion", + "outputCostMicrousdPerMillion", + "fallbackModelId", + "settingsJson", + ]) { + assert.doesNotMatch(component, new RegExp(`name=["']${field}["']`)); + } + assert.doesNotMatch(component, /模型档位|上下文窗口|输入成本(微美元\/百万 Token)|输出成本(微美元\/百万 Token)|回退模型 ID|设置 JSON/); +}); + +test("hidden model fields preserve draft values or use database-compatible defaults", () => { + assert.match(component, /description: editingModel\?\.description \?\? ""/); + assert.match(component, /modelTier: editingModel\?\.modelTier \?\? "standard"/); + assert.match(component, /contextWindow: editingModel\?\.contextWindow \?\? null/); + assert.match(component, /inputCostMicrousdPerMillion: editingModel\?\.inputCostMicrousdPerMillion \?\? 0/); + assert.match(component, /outputCostMicrousdPerMillion: editingModel\?\.outputCostMicrousdPerMillion \?\? 0/); + assert.match(component, /fallbackModelId: editingModel\?\.fallbackModelId \?\? null/); + assert.match(component, /settings: editingModel\?\.settings \?\? \{\}/); }); test("model mutations omit client reasons while the server keeps fixed audit reasons", () => { diff --git a/frontend/tests/epay-settings.test.ts b/frontend/tests/epay-settings.test.ts index bf0d2613..5df397fb 100644 --- a/frontend/tests/epay-settings.test.ts +++ b/frontend/tests/epay-settings.test.ts @@ -215,7 +215,7 @@ test("网关探测固定已验证公网 IP、保留 TLS hostname 且限制重定 assert.match(gatewayPolicy, /const resolved = await withinTimeout\([\s\S]*resolvePublicUrl\(value, options\.lookup \?\? defaultLookup\)/); assert.match(gatewayPolicy, /const pinned = resolved\.addresses\[0\]!/); assert.match(gatewayPolicy, /servername: isIP\(hostname\) \? undefined : hostname/); - assert.match(gatewayPolicy, /lookup: \(_hostname, _options, callback\) => callback\(null, pinned\.address, pinned\.family\)/); + assert.match(gatewayPolicy, /lookup: pinnedAddressLookup\(pinned\)/); assert.match(gatewayPolicy, /requestPinnedHttps\([\s\S]*resolved,[\s\S]*"HEAD"/); assert.match(gatewayPolicy, /headStatus === 405 \|\| headStatus === 501[\s\S]*requestPinnedHttps\([\s\S]*resolved,[\s\S]*"GET"/); assert.match(gatewayPolicy, /status >= 300 && status < 400/); diff --git a/frontend/tests/model-configuration-security.test.ts b/frontend/tests/model-configuration-security.test.ts index 235eec20..2c29bf7b 100644 --- a/frontend/tests/model-configuration-security.test.ts +++ b/frontend/tests/model-configuration-security.test.ts @@ -95,7 +95,7 @@ test("model provider URLs allow arbitrary public HTTPS origins but retain SSRF b ); const gatewayPolicy = readFileSync(new URL("../src/lib/epay/gateway-policy.ts", import.meta.url), "utf8"); - assert.match(gatewayPolicy, /lookup: \(_hostname, _options, callback\) => callback\(null, pinned\.address, pinned\.family\)/); + assert.match(gatewayPolicy, /lookup: pinnedAddressLookup\(pinned\)/); assert.match(gatewayPolicy, /status >= 300 && status < 400/); assert.match(gatewayPolicy, /不允许重定向/); }); diff --git a/frontend/tests/model-provider-encrypted-credentials.test.ts b/frontend/tests/model-provider-encrypted-credentials.test.ts index bda64ed7..4983cd1c 100644 --- a/frontend/tests/model-provider-encrypted-credentials.test.ts +++ b/frontend/tests/model-provider-encrypted-credentials.test.ts @@ -1,10 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import { readFileSync } from "node:fs"; +import { connect, createServer, type AddressInfo } from "node:net"; +import { pinnedAddressLookup } from "../src/lib/epay/gateway-policy.ts"; import { decryptModelProviderApiKey, encryptModelProviderApiKey, modelProviderModelsUrl, modelProviderRequestHeaders } from "../src/lib/model-provider-policy.ts"; const key=Buffer.alloc(32,7).toString("base64"); test("model provider credentials use the dedicated AES-GCM master key",()=>{const encrypted=encryptModelProviderApiKey("provider-secret",{MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY:key});assert.notEqual(encrypted,"provider-secret");assert.equal(decryptModelProviderApiKey({encryptedApiKey:encrypted},{MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY:key}),"provider-secret");assert.throws(()=>decryptModelProviderApiKey({encryptedApiKey:encrypted},{MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY:Buffer.alloc(32,8).toString("base64")}));}); test("Anthropic discovery uses its official endpoint and headers",()=>{assert.equal(modelProviderModelsUrl({providerType:"anthropic",baseUrl:null}),"https://api.anthropic.com/v1/models");assert.deepEqual(modelProviderRequestHeaders("anthropic","secret"),{"x-api-key":"secret","anthropic-version":"2023-06-01"});assert.deepEqual(modelProviderRequestHeaders("openai","secret"),{authorization:"Bearer secret"});}); test("runtime and admin APIs do not parse legacy provider credential env vars",()=>{for(const file of ["../src/lib/model-catalog.ts","../src/mastra/model.ts","../src/app/api/admin/models/route.ts"]){const source=readFileSync(new URL(file,import.meta.url),"utf8");assert.doesNotMatch(source,/OPENAI_API_KEY|DEEPSEEK_API_KEY|LLM_API_KEY|secret_ref/);}}); -test("discovery stays on the DNS-pinned bounded HTTPS helper",()=>{const route=readFileSync(new URL("../src/app/api/admin/models/discover/route.ts",import.meta.url),"utf8");const gateway=readFileSync(new URL("../src/lib/epay/gateway-policy.ts",import.meta.url),"utf8");assert.match(route,/requestAllowedModelProvider/);assert.doesNotMatch(route,/\bfetch\s*\(/);assert.match(gateway,/lookup: \(_hostname, _options, callback\)/);assert.match(gateway,/不允许重定向/);assert.match(route,/maxResponseBytes:256\*1024/);}); +test("discovery stays on the DNS-pinned bounded HTTPS helper",()=>{const route=readFileSync(new URL("../src/app/api/admin/models/discover/route.ts",import.meta.url),"utf8");const gateway=readFileSync(new URL("../src/lib/epay/gateway-policy.ts",import.meta.url),"utf8");assert.match(route,/requestAllowedModelProvider/);assert.doesNotMatch(route,/\bfetch\s*\(/);assert.match(gateway,/lookup: pinnedAddressLookup\(pinned\)/);assert.match(gateway,/不允许重定向/);assert.match(route,/maxResponseBytes:256\*1024/);}); +test("DNS pinning supports Node all-address and single-address lookups",async(t)=>{const server=createServer((socket)=>socket.end());await new Promise((resolve,reject)=>{server.once("error",reject);server.listen(0,"127.0.0.1",resolve);});t.after(()=>new Promise((resolve,reject)=>server.close((error)=>error?reject(error):resolve())));const {port}=server.address() as AddressInfo;for(const autoSelectFamily of [true,false])await new Promise((resolve,reject)=>{const socket=connect({host:"provider.example",port,autoSelectFamily,lookup:pinnedAddressLookup({address:"127.0.0.1",family:4})});socket.once("connect",()=>{socket.end();resolve();});socket.once("error",reject);});});