Allow public HTTPS model provider origins
This commit is contained in:
@@ -176,6 +176,8 @@ CADDYFILE_PATH=./Caddyfile.staging
|
||||
SITE_ADDRESS=https://staging.jyotisha.chat
|
||||
```
|
||||
|
||||
模型供应商的 `base_url` 不再依赖域名白名单,任意公网 HTTPS origin 均可由管理员配置;部署环境不需要 `MODEL_PROVIDER_BASE_URL_ALLOWLIST`。服务端仍强制 HTTPS、禁止凭据、localhost/内网/保留地址,并在 DNS 解析、请求地址 pinning 和重定向处理上执行 SSRF 防护。
|
||||
|
||||
Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the four role-specific server-only database URLs, the exact `AUTH_USER_ORIGIN=https://staging.jyotisha.chat` and `ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat`, the single `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Both hosts run the same application and Better Auth service, but cookies remain host-only; the admin host `/` redirects to `/admin`, and unauthenticated admin requests continue to `/login` on that host. Better Auth trusts only those two origins, while unknown identity hosts fail closed. Persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production remains on Supabase and is not changed by the staging workflow. See `docs/operations/self-hosted-identity.md` for validation and rollback commands.
|
||||
|
||||
After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful gate run for the exact SHA.
|
||||
|
||||
+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