From 68fa7fdc4ad68c7d350f37afa932888d8d4a610f Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 2 Jun 2026 14:24:19 +0800 Subject: [PATCH] fix(passthrough): enforce caller model ACL before lending provider creds Generic /passthrough/{provider}/* picked the first Model matching the provider and injected its credentials for ANY valid API key, ignoring the key's allowed_models. A low-privilege key could thus reach a provider's upstream credentials (e.g. Jina/Cohere) it was never granted. Require the authenticated key to be allowed to access a model of the target provider: select the first provider model the key can access, and reject with 403 when the provider has models but none are in the key's ACL. Mirrors LiteLLM, which enforces the key's model access on passthrough when a target is identifiable. Routing target authorization (#29) is left consistent with LiteLLM's model-group semantics: the requested virtual-router name is authorized (already enforced); its operator-configured targets are not separately re-authorized. Fixes #449 --- crates/aisix-proxy/src/passthrough.rs | 30 +++++-- .../cases/passthrough-model-acl-e2e.test.ts | 80 +++++++++++++++++++ 2 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/src/cases/passthrough-model-acl-e2e.test.ts diff --git a/crates/aisix-proxy/src/passthrough.rs b/crates/aisix-proxy/src/passthrough.rs index dc2c3489..11d545ff 100644 --- a/crates/aisix-proxy/src/passthrough.rs +++ b/crates/aisix-proxy/src/passthrough.rs @@ -204,17 +204,31 @@ async fn dispatch( // Find a model for this provider so we can borrow its provider_key. let provider_lower = provider.to_lowercase(); let all_models = snapshot.models.entries(); + let matches_provider = |e: &aisix_core::resource::ResourceEntry| { + e.value + .provider + .as_deref() + .map(|p| p.eq_ignore_ascii_case(&provider_lower)) + .unwrap_or(false) + }; + let provider_has_model = all_models.iter().any(|e| matches_provider(e)); + // Enforce the authenticated key's model ACL before lending this + // provider's credentials through generic passthrough: pick the first + // model of the provider the key is actually allowed to access. + // Without this any valid key could reach any configured provider's + // upstream credentials (#449). Mirrors LiteLLM, which enforces the + // key's model access on passthrough when a target is identifiable. let model_entry = all_models .into_iter() - .find(|e| { - e.value - .provider - .as_deref() - .map(|p| p.eq_ignore_ascii_case(&provider_lower)) - .unwrap_or(false) - }) + .find(|e| matches_provider(e) && auth.key().can_access(&e.value.display_name)) .ok_or_else(|| { - ProxyError::ModelNotFound(format!("no model found for provider `{provider}`")) + if provider_has_model { + ProxyError::ModelForbidden(format!( + "api key is not authorized for any model of provider `{provider}`" + )) + } else { + ProxyError::ModelNotFound(format!("no model found for provider `{provider}`")) + } })?; let model = &model_entry.value; diff --git a/tests/e2e/src/cases/passthrough-model-acl-e2e.test.ts b/tests/e2e/src/cases/passthrough-model-acl-e2e.test.ts new file mode 100644 index 00000000..9c23b3f0 --- /dev/null +++ b/tests/e2e/src/cases/passthrough-model-acl-e2e.test.ts @@ -0,0 +1,80 @@ +import { createHash } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + AdminClient, + EtcdClient, + spawnApp, + startOpenAiUpstream, + waitConfigPropagation, + type OpenAiUpstream, + type SpawnedApp, +} from "../harness/index.js"; + +// E2E: generic passthrough enforces the caller's model ACL (#449). +// Pre-fix, /passthrough/{provider}/* picked the first Model matching the +// provider and lent its credentials to ANY valid API key, regardless of +// the key's allowed_models — so a low-privilege key could reach a +// provider's upstream credentials it was never granted. The gateway now +// requires the key to be allowed to access a model of that provider +// before injecting the provider credential. + +const sha = (s: string) => createHash("sha256").update(s).digest("hex"); +const ALLOWED = "sk-pt-acl-allowed"; +const DENIED = "sk-pt-acl-denied"; + +describe("passthrough model ACL (#449)", () => { + let app: SpawnedApp | undefined; + let upstream: OpenAiUpstream | undefined; + let etcdReachable = false; + + beforeAll(async () => { + etcdReachable = await new EtcdClient().ping(); + if (!etcdReachable) return; + upstream = await startOpenAiUpstream({}); + app = await spawnApp(); + const admin = new AdminClient(app.adminUrl, app.adminKey); + const pk = await admin.createProviderKey({ + display_name: "pt-acl-pk", + secret: "sk-openai-mock", + api_base: upstream.baseUrl, + }); + await admin.createModel({ + display_name: "pt-acl-model", + provider: "openai", + model_name: "gpt-x", + provider_key_id: pk.id, + }); + // ALLOWED key may use the openai model; DENIED key may only use an + // unrelated model name (no openai model in its ACL). + await admin.createApiKey({ key_hash: sha(ALLOWED), allowed_models: ["pt-acl-model"] }); + await admin.createApiKey({ key_hash: sha(DENIED), allowed_models: ["unrelated-model"] }); + }); + + afterAll(async () => { + await app?.exit(); + await upstream?.close(); + }); + + const callPassthrough = (key: string) => + fetch(`${app!.proxyUrl}/passthrough/openai/v1/files`, { + method: "GET", + headers: { authorization: `Bearer ${key}` }, + }); + + test("key without access to a provider model is rejected (#449)", async (ctx) => { + if (!etcdReachable || !app || !upstream) { + ctx.skip(); + return; + } + await waitConfigPropagation(async () => (await callPassthrough(ALLOWED)).ok); + + const denied = await callPassthrough(DENIED); + expect( + denied.status, + "key with no openai model in its ACL must not reach openai passthrough creds", + ).toBe(403); + + const allowed = await callPassthrough(ALLOWED); + expect(allowed.status, "key allowed for an openai model may use openai passthrough").toBe(200); + }); +});