Allow public HTTPS model provider origins
This commit is contained in:
+2
-2
@@ -43,11 +43,11 @@ JYOTISH_API_BASE=http://127.0.0.1:5200
|
||||
# 模型供应商配置加密主密钥:严格 Base64 编码的 32 字节随机值
|
||||
MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=<strict-base64-32-byte-key>
|
||||
|
||||
# OpenAI-compatible 自定义端点还必须加入服务端 origin allowlist
|
||||
# MODEL_PROVIDER_BASE_URL_ALLOWLIST=https://api.deepseek.com
|
||||
# OpenAI-compatible 自定义端点可使用任意公网 HTTPS origin;无需配置域名白名单
|
||||
```
|
||||
|
||||
模型目录、供应商地址及 API key 均通过管理端配置;API key 使用上述主密钥 AES-256-GCM 加密后存入数据库。运行时不读取 `LLM_MODELS_JSON`、`LLM_BASE_URL`、`LLM_MODEL` 或供应商 API-key 环境变量。
|
||||
供应商地址仍受服务端 SSRF 防护:仅允许 HTTPS、禁止用户名/密码、拒绝 localhost、内网和保留地址;域名必须解析到公网地址,请求会固定到已验证的 DNS 地址并拒绝重定向。
|
||||
|
||||
## Skill 如何触发
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ const blockedHostnames = new Set([
|
||||
"metadata.google.internal",
|
||||
]);
|
||||
const blockedHostnameSuffixes = [".localhost", ".local", ".internal", ".lan", ".home", ".arpa"];
|
||||
const defaultModelProviderOrigins = new Set(["https://api.openai.com", "https://api.anthropic.com"]);
|
||||
const defaultLookup: HostLookup = (hostname, options) => dns.lookup(hostname, options);
|
||||
|
||||
type ResolvedAddress = Readonly<{ address: string; family: number }>;
|
||||
@@ -124,32 +123,17 @@ export async function assertPublicGatewayUrl(
|
||||
return (await resolvePublicUrl(configured, lookup)).url;
|
||||
}
|
||||
|
||||
function addAllowedOrigin(origins: Set<string>, value: string | undefined) {
|
||||
if (!value) return;
|
||||
try {
|
||||
const url = assertPublicEpayGateway(value.trim());
|
||||
if (url.protocol === "https:") origins.add(url.origin);
|
||||
} catch {
|
||||
// Invalid server-owned entries do not widen the allowlist.
|
||||
}
|
||||
}
|
||||
|
||||
function modelProviderOrigins(environment: ModelProviderEnvironment) {
|
||||
const origins = new Set(defaultModelProviderOrigins);
|
||||
environment.MODEL_PROVIDER_BASE_URL_ALLOWLIST?.split(",").forEach((value) => addAllowedOrigin(origins, value));
|
||||
return origins;
|
||||
}
|
||||
|
||||
export async function assertAllowedModelProviderUrl(
|
||||
value: URL | string,
|
||||
environment: ModelProviderEnvironment = process.env,
|
||||
_environment: ModelProviderEnvironment = process.env,
|
||||
lookup: HostLookup = defaultLookup,
|
||||
) {
|
||||
const resolved = await resolvePublicUrl(value, lookup);
|
||||
if (resolved.url.protocol !== "https:" || !modelProviderOrigins(environment).has(resolved.url.origin)) {
|
||||
throw new Error("模型供应商地址不在服务器允许列表中");
|
||||
void _environment;
|
||||
const url = value instanceof URL ? value : new URL(value);
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error("网关地址不允许使用 HTTP;模型供应商必须使用 HTTPS");
|
||||
}
|
||||
return resolved;
|
||||
return resolvePublicUrl(url, lookup);
|
||||
}
|
||||
|
||||
async function withinTimeout<T>(operation: Promise<T>, timeoutMs: number, message: string) {
|
||||
|
||||
@@ -53,24 +53,50 @@ async function migrateModelConfigurationFixture(connectionString: string) {
|
||||
}
|
||||
}
|
||||
|
||||
test("model provider URLs require a server-owned public allowlist", async () => {
|
||||
await assertAllowedModelProviderUrl(
|
||||
"https://models.example.com/v1",
|
||||
{ MODEL_PROVIDER_BASE_URL_ALLOWLIST: "https://models.example.com" },
|
||||
publicLookup,
|
||||
);
|
||||
await assert.rejects(
|
||||
assertAllowedModelProviderUrl("https://attacker.example/v1", {}, publicLookup),
|
||||
/允许列表/,
|
||||
test("model provider URLs allow arbitrary public HTTPS origins but retain SSRF boundaries", async () => {
|
||||
let lookupOptions: unknown;
|
||||
const resolved = await assertAllowedModelProviderUrl(
|
||||
"https://attacker.example/v1",
|
||||
{},
|
||||
async (_hostname, options) => {
|
||||
lookupOptions = options;
|
||||
return publicLookup();
|
||||
},
|
||||
);
|
||||
assert.equal(resolved.url.origin, "https://attacker.example");
|
||||
assert.deepEqual(lookupOptions, { all: true, verbatim: true });
|
||||
|
||||
for (const value of [
|
||||
"http://attacker.example/v1",
|
||||
"https://user:password@attacker.example/v1",
|
||||
"https://localhost/v1",
|
||||
"https://service.internal/v1",
|
||||
"https://10.0.0.1/v1",
|
||||
"https://192.168.1.1/v1",
|
||||
"https://169.254.169.254/v1",
|
||||
"https://192.0.2.1/v1",
|
||||
"https://[2001:db8::1]/v1",
|
||||
]) {
|
||||
await assert.rejects(
|
||||
assertAllowedModelProviderUrl(value, {}, publicLookup),
|
||||
/HTTPS|本机|内网|内部域名|保留地址/,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
assertAllowedModelProviderUrl(
|
||||
"https://models.example.com/v1",
|
||||
{ MODEL_PROVIDER_BASE_URL_ALLOWLIST: "https://models.example.com" },
|
||||
"https://public.example/v1",
|
||||
{},
|
||||
async () => [{ address: "169.254.169.254", family: 4 }],
|
||||
),
|
||||
/内网|保留地址/,
|
||||
);
|
||||
|
||||
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, /status >= 300 && status < 400/);
|
||||
assert.match(gatewayPolicy, /不允许重定向/);
|
||||
});
|
||||
|
||||
test("admin and runtime source expose only secretConfigured and contain no secretRef contract", () => {
|
||||
|
||||
Reference in New Issue
Block a user