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); + }); +});