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:
whit3rabbit
2026-07-12 09:14:20 -05:00
co-authored by Claude Opus 4.8
parent 062f8eaf5a
commit 7fc44582c6
7 changed files with 206 additions and 20 deletions
+8
View File
@@ -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
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
+43 -7
View File
@@ -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)...
+34 -3
View File
@@ -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,
+87
View File
@@ -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
// ---------------------------------------------------------------------------