diff --git a/deploy/README.md b/deploy/README.md index fa1a40cf..3726b4ce 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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. diff --git a/frontend/README.md b/frontend/README.md index 7c8c79ad..23e84ab1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -43,11 +43,11 @@ JYOTISH_API_BASE=http://127.0.0.1:5200 # 模型供应商配置加密主密钥:严格 Base64 编码的 32 字节随机值 MODEL_PROVIDER_CONFIG_ENCRYPTION_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 如何触发 diff --git a/frontend/src/lib/epay/gateway-policy.ts b/frontend/src/lib/epay/gateway-policy.ts index d0026bda..4ca2bf0c 100644 --- a/frontend/src/lib/epay/gateway-policy.ts +++ b/frontend/src/lib/epay/gateway-policy.ts @@ -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, 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(operation: Promise, timeoutMs: number, message: string) { diff --git a/frontend/tests/model-configuration-security.test.ts b/frontend/tests/model-configuration-security.test.ts index 65247e4e..ec8604b2 100644 --- a/frontend/tests/model-configuration-security.test.ts +++ b/frontend/tests/model-configuration-security.test.ts @@ -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", () => {