Allow public HTTPS model provider origins
Staging Backend Quality Gate / validate (push) Successful in 12m28s
Staging Backend Quality Gate / publish (push) Successful in 22m47s

This commit is contained in:
Jesse_Chen
2026-08-08 11:18:50 +08:00
parent 0676b6c402
commit 5fa1b8304e
4 changed files with 47 additions and 35 deletions
+2
View File
@@ -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
View File
@@ -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 如何触发
+6 -22
View File
@@ -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", () => {