mirror of
https://github.com/whit3rabbit/anyllm-proxy.git
synced 2026-09-21 16:00:49 +00:00
fix: enforce model allowlist on Bedrock native routes; fix model discovery
Bedrock native routes (converse/converse-stream/invoke/invoke-with-response- stream) now enforce the virtual key's model allowlist, closing a model-scope bypass where a model-scoped key could invoke any Bedrock modelId. Admin UI "Query models" discovery: stop doubling /v1 into /v1/v1/models for local providers whose catalog base URL already ends in /v1, trim trailing slashes off api_base, support Anthropic-native providers (x-api-key + anthropic-version auth, display_name model field), and show/warn about the discovery target URL in the Add-Backend form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
062f8eaf5a
commit
7fc44582c6
@@ -16,6 +16,14 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follo
|
||||
packages. The binaries were already built in CI but never packaged/attached.
|
||||
|
||||
### Fixed
|
||||
- Admin UI model discovery ("Query models"): fixed the discovery URL builder doubling
|
||||
`/v1` into `/v1/v1/models` for local providers whose catalog default base URL already
|
||||
ends in `/v1` (LM Studio, vLLM, etc.), and now trims trailing slashes off `api_base`
|
||||
before saving/discovering. Anthropic-native providers are now discoverable too:
|
||||
the request authenticates with `x-api-key` + `anthropic-version` (not a Bearer token)
|
||||
and reads the model name from `display_name` (Anthropic's field) as well as `name`.
|
||||
The Add-Backend form shows the exact URL "Query models" will hit and warns when
|
||||
discovery is unsupported for the provider protocol (Vertex/Gemini/Bedrock native).
|
||||
- Security: Bedrock native routes (`POST /model/{modelId}/converse`,
|
||||
`/converse-stream`, `/invoke`, `/invoke-with-response-stream`) now enforce the
|
||||
virtual key's model allowlist. Previously these handlers skipped the
|
||||
|
||||
+8
-8
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ import {
|
||||
useDiscoverModels,
|
||||
} from '../../api/queries'
|
||||
import type { CatalogProvider, ManagedBackend } from '../../api/types'
|
||||
import { getProviderFields } from '../../utils/providerFields'
|
||||
import { getProviderFields, resolveDiscoveryUrl } from '../../utils/providerFields'
|
||||
import { groupSections } from '../../utils/providerTiers'
|
||||
|
||||
// A provider is "local" when its default endpoint is a loopback address.
|
||||
@@ -153,7 +153,7 @@ function AddBackendForm({
|
||||
name: form.name,
|
||||
provider_id: provider.id,
|
||||
api_key: form.api_key || undefined,
|
||||
api_base: form.api_base || undefined,
|
||||
api_base: form.api_base ? form.api_base.trim().replace(/\/+$/, '') : undefined,
|
||||
deployment: form.deployment || undefined,
|
||||
api_version: form.api_version || undefined,
|
||||
project: form.project || undefined,
|
||||
@@ -210,6 +210,20 @@ function AddBackendForm({
|
||||
onChange={(e) => setForm((p) => ({ ...p, [f.name]: e.target.value }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{f.name === 'api_base' && (() => {
|
||||
const target = resolveDiscoveryUrl(form.api_base || provider.default_base_url || '')
|
||||
if (!target) return null
|
||||
// /v1/models discovery only works for OpenAI-shaped and Anthropic-native providers.
|
||||
const unsupported = ['vertex_ai', 'gemini_native', 'bedrock_native'].includes(
|
||||
provider.protocol,
|
||||
)
|
||||
return (
|
||||
<div className="form-hint">
|
||||
Query models will request: <span className="mono">{target}</span>
|
||||
{unsupported && ' — model discovery may not work for this provider.'}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
{discover.isError && (
|
||||
|
||||
@@ -12,6 +12,16 @@ export interface FieldDef {
|
||||
group: FieldGroup
|
||||
}
|
||||
|
||||
// Mirror of resolve_discover_target's /v1/models logic in models.rs — keep in sync.
|
||||
// Handles trailing slashes and avoids doubling /v1 (catalog defaults end in /v1).
|
||||
export function resolveDiscoveryUrl(base: string): string {
|
||||
const trimmed = base.trim().replace(/\/+$/, '')
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.endsWith('/models')) return trimmed
|
||||
if (trimmed.endsWith('/v1')) return `${trimmed}/models`
|
||||
return `${trimmed}/v1/models`
|
||||
}
|
||||
|
||||
export function getProviderFields(provider: CatalogProvider): FieldDef[] {
|
||||
const fields: FieldDef[] = []
|
||||
const firstEnvVar = provider.env_vars.length > 0 ? provider.env_vars[0] : null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::admin::state::SharedState;
|
||||
use anyllm_client::http::{build_http_client, HttpClientConfig};
|
||||
use anyllm_providers::provider::ProviderProtocol;
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::StatusCode,
|
||||
@@ -280,12 +281,17 @@ pub(super) async fn discover_models(
|
||||
State(shared): State<SharedState>,
|
||||
Json(body): Json<DiscoverRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// A provider counts as local only if the catalog says so — never trust a client flag.
|
||||
let provider_is_local = body
|
||||
// Look the provider up once; derive local-ness and protocol from the catalog,
|
||||
// never from a client flag.
|
||||
let provider_def = body
|
||||
.provider_id
|
||||
.as_deref()
|
||||
.and_then(|id| shared.provider_catalog.get_provider(id))
|
||||
.is_some_and(|p| p.is_local());
|
||||
.and_then(|id| shared.provider_catalog.get_provider(id));
|
||||
let provider_is_local = provider_def.is_some_and(|p| p.is_local());
|
||||
// Anthropic-native providers list models at /v1/models too, but authenticate with
|
||||
// x-api-key + anthropic-version instead of a Bearer token.
|
||||
let is_anthropic =
|
||||
provider_def.is_some_and(|p| p.protocol == ProviderProtocol::AnthropicNative);
|
||||
|
||||
let (url, api_key) = match resolve_discover_target(&body, provider_is_local) {
|
||||
Ok(v) => v,
|
||||
@@ -306,7 +312,13 @@ pub(super) async fn discover_models(
|
||||
};
|
||||
let mut req = client.get(&url);
|
||||
if let Some(ref key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {key}"));
|
||||
if is_anthropic {
|
||||
req = req
|
||||
.header("x-api-key", key)
|
||||
.header("anthropic-version", "2023-06-01");
|
||||
} else {
|
||||
req = req.header("Authorization", format!("Bearer {key}"));
|
||||
}
|
||||
}
|
||||
|
||||
let resp = match req.send().await {
|
||||
@@ -366,7 +378,12 @@ pub(super) async fn discover_models(
|
||||
arr.iter()
|
||||
.filter_map(|m| {
|
||||
let id = m.get("id")?.as_str()?.to_string();
|
||||
let name = m.get("name").and_then(|n| n.as_str()).map(String::from);
|
||||
// OpenAI uses "name"; Anthropic list-models uses "display_name".
|
||||
let name = m
|
||||
.get("name")
|
||||
.or_else(|| m.get("display_name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(String::from);
|
||||
Some(DiscoveredModel { id, name })
|
||||
})
|
||||
.collect()
|
||||
@@ -438,9 +455,13 @@ fn resolve_discover_target(
|
||||
} else {
|
||||
crate::config::validate_base_url(url)?;
|
||||
}
|
||||
// If the URL already ends with /models, use as-is; otherwise append.
|
||||
// Append the models path, but don't double up: catalog default base URLs
|
||||
// for local providers already end in /v1 (e.g. http://host:4444/v1), so a
|
||||
// blind /v1/models append produced /v1/v1/models.
|
||||
let url = if url.ends_with("/models") {
|
||||
url.to_string()
|
||||
} else if url.ends_with("/v1") {
|
||||
format!("{url}/models")
|
||||
} else {
|
||||
format!("{url}/v1/models")
|
||||
};
|
||||
@@ -521,6 +542,21 @@ mod tests {
|
||||
assert_eq!(api_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_discover_does_not_double_v1_suffix() {
|
||||
// Local-provider catalog defaults already end in /v1; don't produce /v1/v1/models.
|
||||
let (url, _) =
|
||||
resolve_discover_target(&custom_request("http://192.168.1.72:4444/v1"), true)
|
||||
.expect("local LAN /v1 discovery URL should be accepted when allow_local");
|
||||
assert_eq!(url, "http://192.168.1.72:4444/v1/models");
|
||||
|
||||
// Trailing slash on a /v1 base collapses the same way.
|
||||
let (url, _) =
|
||||
resolve_discover_target(&custom_request("http://192.168.1.72:4444/v1/"), true)
|
||||
.expect("trailing-slash /v1 discovery URL should be accepted when allow_local");
|
||||
assert_eq!(url, "http://192.168.1.72:4444/v1/models");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_discover_local_allows_loopback_but_keeps_scheme_check() {
|
||||
// allow_local=true: loopback is accepted (LM Studio/Ollama on localhost)...
|
||||
|
||||
@@ -17,14 +17,17 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
type VkCtx = Option<axum::Extension<crate::server::middleware::VirtualKeyContext>>;
|
||||
|
||||
/// POST /model/{modelId}/converse — Bedrock Converse API (non-streaming).
|
||||
pub(crate) async fn bedrock_converse(
|
||||
State(state): State<AppState>,
|
||||
Path(model_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
vk_ctx: VkCtx,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
forward_native(&state, &model_id, &headers, body, "converse", false).await
|
||||
forward_native(&state, &model_id, &headers, vk_ctx, body, "converse", false).await
|
||||
}
|
||||
|
||||
/// POST /model/{modelId}/converse-stream — Bedrock Converse API (streaming).
|
||||
@@ -32,9 +35,19 @@ pub(crate) async fn bedrock_converse_stream(
|
||||
State(state): State<AppState>,
|
||||
Path(model_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
vk_ctx: VkCtx,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
forward_native(&state, &model_id, &headers, body, "converse-stream", true).await
|
||||
forward_native(
|
||||
&state,
|
||||
&model_id,
|
||||
&headers,
|
||||
vk_ctx,
|
||||
body,
|
||||
"converse-stream",
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// POST /model/{modelId}/invoke — Bedrock InvokeModel (non-streaming, model-native format).
|
||||
@@ -42,9 +55,10 @@ pub(crate) async fn bedrock_invoke(
|
||||
State(state): State<AppState>,
|
||||
Path(model_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
vk_ctx: VkCtx,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
forward_native(&state, &model_id, &headers, body, "invoke", false).await
|
||||
forward_native(&state, &model_id, &headers, vk_ctx, body, "invoke", false).await
|
||||
}
|
||||
|
||||
/// POST /model/{modelId}/invoke-with-response-stream — Bedrock InvokeModel (streaming).
|
||||
@@ -52,12 +66,14 @@ pub(crate) async fn bedrock_invoke_stream(
|
||||
State(state): State<AppState>,
|
||||
Path(model_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
vk_ctx: VkCtx,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
forward_native(
|
||||
&state,
|
||||
&model_id,
|
||||
&headers,
|
||||
vk_ctx,
|
||||
body,
|
||||
"invoke-with-response-stream",
|
||||
true,
|
||||
@@ -69,6 +85,7 @@ async fn forward_native(
|
||||
state: &AppState,
|
||||
model_id: &str,
|
||||
headers: &HeaderMap,
|
||||
vk_ctx: VkCtx,
|
||||
body: Bytes,
|
||||
suffix: &str,
|
||||
streaming: bool,
|
||||
@@ -85,6 +102,20 @@ async fn forward_native(
|
||||
}
|
||||
};
|
||||
|
||||
// Enforce the virtual key's model allowlist. The model comes from the URL path
|
||||
// here (Bedrock native puts modelId in the URL), so check it directly — the
|
||||
// same enforcement bedrock_passthrough applies to the body's `model` field.
|
||||
if let Some(axum::Extension(ref ctx)) = vk_ctx {
|
||||
if !crate::server::policy::is_model_allowed(model_id, &ctx.allowed_models) {
|
||||
let err = anyllm_translate::mapping::errors_map::create_anthropic_error(
|
||||
anyllm_translate::anthropic::ErrorType::PermissionError,
|
||||
format!("Model '{model_id}' is not allowed for this API key."),
|
||||
None,
|
||||
);
|
||||
return (StatusCode::FORBIDDEN, axum::Json(err)).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let body =
|
||||
match super::secret_redaction::redact_body(state.redact_secrets(), headers, body).await {
|
||||
Ok(body) => body,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use anyllm_proxy::admin;
|
||||
use anyllm_proxy::config::{
|
||||
BackendAuth, BackendConfig, BackendKind, Config, ModelMapping, MultiConfig, OpenAIApiFormat,
|
||||
TlsConfig,
|
||||
};
|
||||
use anyllm_proxy::server::routes;
|
||||
use axum::body::Body;
|
||||
@@ -1674,6 +1675,92 @@ async fn translate_model_passthrough_routes_enforce_virtual_key_model_allowlist(
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bedrock native routes (POST /model/{modelId}/...) must enforce the virtual
|
||||
// key's model allowlist, same as bedrock_passthrough. The modelId comes from
|
||||
// the URL path here, so the check runs before any AWS call — a denied model is
|
||||
// rejected 403 without dummy credentials ever being signed/sent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn bedrock_backend_config() -> BackendConfig {
|
||||
BackendConfig {
|
||||
kind: BackendKind::Bedrock,
|
||||
provider_id: Some("bedrock".to_string()),
|
||||
api_key: String::new(),
|
||||
base_url: "us-east-1".to_string(), // region is stored in base_url for Bedrock
|
||||
api_format: OpenAIApiFormat::Chat,
|
||||
model_mapping: ModelMapping {
|
||||
big_model: "anthropic.claude-3-5-sonnet-20241022-v2:0".into(),
|
||||
small_model: "anthropic.claude-3-5-haiku-20241022-v1:0".into(),
|
||||
},
|
||||
tls: TlsConfig::default(),
|
||||
backend_auth: BackendAuth::BearerToken(String::new()),
|
||||
log_bodies: false,
|
||||
omit_stream_options: false,
|
||||
stream_timeout_secs: 900,
|
||||
// Dummy static credentials — never actually used because the denied
|
||||
// model is rejected before signing.
|
||||
bedrock_credentials: Some(aws_credential_types::Credentials::new(
|
||||
"AKIDTEST", "secret", None, None, "test",
|
||||
)),
|
||||
allow_local_ssrf: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_bedrock_proxy_with_shared_vk() -> String {
|
||||
let state = shared_state(); // fires set_virtual_keys before app build
|
||||
let mut backends = IndexMap::new();
|
||||
backends.insert("bedrock".to_string(), bedrock_backend_config());
|
||||
let multi = MultiConfig {
|
||||
listen_port: 0,
|
||||
log_bodies: false,
|
||||
redact_secrets: false,
|
||||
anthropic_thinking_repair: false,
|
||||
pxpipe_compress: false,
|
||||
forward_client_auth: false,
|
||||
default_backend: "bedrock".to_string(),
|
||||
backends,
|
||||
expose_degradation_warnings: false,
|
||||
};
|
||||
let app = routes::app_multi_with_shared(multi, Some(state), None, None, None, None);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bedrock_native_routes_enforce_virtual_key_model_allowlist() {
|
||||
let proxy_url = spawn_bedrock_proxy_with_shared_vk().await;
|
||||
let raw_key = "sk-vkbedrocknativedenied";
|
||||
insert_test_virtual_key(
|
||||
raw_key,
|
||||
90_010,
|
||||
Some(vec!["anthropic.claude-3-5-sonnet-20241022-v2:0".to_string()]),
|
||||
);
|
||||
|
||||
let client = Client::new();
|
||||
// Denied model in the URL path across all four native endpoints.
|
||||
let denied = "anthropic.claude-3-opus-20240229-v1:0";
|
||||
for suffix in [
|
||||
"converse",
|
||||
"converse-stream",
|
||||
"invoke",
|
||||
"invoke-with-response-stream",
|
||||
] {
|
||||
let resp = client
|
||||
.post(format!("{proxy_url}/model/{denied}/{suffix}"))
|
||||
.header("x-api-key", raw_key)
|
||||
.json(&json!({"messages": [{"role": "user", "content": "hi"}]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 403, "native {suffix} must deny scoped model");
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["error"]["type"], "permission_error");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RPM rate limiting (T051): create key with rpm_limit:2, 3rd request → 429
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user