diff --git a/crates/api/src/routes/anthropic.rs b/crates/api/src/routes/anthropic.rs index c792c10cc..cabdc1c77 100644 --- a/crates/api/src/routes/anthropic.rs +++ b/crates/api/src/routes/anthropic.rs @@ -3,6 +3,12 @@ //! The request body stays schema-free on purpose. Cloud API validates only the //! fields needed for routing, billing, and explicit feature gates, then the raw //! transport rewrites `model` and forwards the rest to Anthropic. +//! +//! `anthropic-beta` is transport, not policy: tokens are syntax-checked and +//! relayed verbatim, leaving Anthropic (which rejects names it does not know) +//! as the authority on validity. Billing and product policy are enforced on the +//! body instead — see [`reject_unsupported_features`] — so a beta token can +//! never by itself unlock a premium or server-side feature. use crate::middleware::auth::AuthenticatedApiKey; use crate::models::AnthropicErrorResponse; @@ -46,26 +52,13 @@ const MAX_STREAM_ID_PEEK_BYTES: usize = 64 * 1024; /// otherwise delay response start indefinitely. Generous because the peek /// ends at the first stream event, well before first content. const STREAM_ID_PEEK_TIMEOUT: Duration = Duration::from_secs(30); -const ALLOWED_ANTHROPIC_BETAS: &[&str] = &[ - // Current Claude Code transport marker and token-only request controls. - "claude-code-20250219", - "interleaved-thinking-2025-05-14", - "thinking-token-count-2026-05-13", - "context-management-2025-06-27", - "prompt-caching-scope-2026-01-05", - "effort-2025-11-24", - "structured-outputs-2025-12-15", - "fine-grained-tool-streaming-2025-05-14", - "token-efficient-tools-2025-02-19", - "prompt-caching-2024-07-31", - // Sent by Claude Code for [1m]-suffixed models. Upstream treats it as a - // no-op: every current 1M-window model serves the full window by default - // at standard per-token pricing, so relaying it has no billing effect. - "context-1m-2025-08-07", - // Claude Code currently sends this marker on ordinary requests. The - // separate typed-tool body gate still rejects invocation of the advisor. - "advisor-tool-2026-03-01", -]; +/// Upper bound on a single `anthropic-beta` token. Roughly triple the longest +/// name Anthropic has published, so the endpoint is never the reason a +/// plausible future token is refused. +const MAX_BETA_TOKEN_LEN: usize = 128; +/// Upper bound on distinct `anthropic-beta` tokens in one request. Current +/// Claude Code sends thirteen. +const MAX_BETA_TOKENS: usize = 64; struct PreparedRequest { body: serde_json::Value, @@ -456,7 +449,7 @@ async fn handle_request( query.as_deref(), &body, endpoint, - &app_state.config.external_providers.anthropic_allowed_betas, + &app_state.config.external_providers.anthropic_denied_betas, ) { Ok(prepared) => prepared, Err(error) => return error.into_response(), @@ -602,7 +595,7 @@ fn prepare_request( query: Option<&str>, body: &[u8], endpoint: AnthropicRawEndpoint, - additional_allowed_betas: &[String], + denied_betas: &[String], ) -> RouteResult { reject_unsupported_anthropic_headers(headers)?; reject_e2ee(headers)?; @@ -660,7 +653,7 @@ fn prepare_request( beta_query: normalize_query(query)?, headers: AnthropicRawHeaders { version: single_header(headers, "anthropic-version")?, - beta: normalized_beta_header(headers, additional_allowed_betas)?, + beta: normalized_beta_header(headers, denied_betas)?, }, }) } @@ -824,13 +817,45 @@ fn single_header(headers: &HeaderMap, name: &'static str) -> RouteResult bool { + !token.is_empty() + && token.len() <= MAX_BETA_TOKEN_LEN + && token + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn malformed_beta_token(token: &str) -> AnthropicRouteError { + // Echoing is safe: the value already passed `to_str`, and the error body is + // JSON-escaped. Never log it — beta tokens travel with customer requests. + let shown: String = token.chars().take(64).collect(); + AnthropicRouteError::new( + StatusCode::BAD_REQUEST, + "invalid_request_error", + format!( + "anthropic-beta token '{shown}' is malformed: use ASCII letters, digits, \ + '-', '_' or '.' (at most {MAX_BETA_TOKEN_LEN} characters)" + ), + ) +} + +/// Collect the client's `anthropic-beta` tokens for relay. +/// +/// Unknown tokens are forwarded on purpose. Anthropic answers a name it does +/// not recognise with its own 400, so an allowlist here adds no protection and +/// only goes stale on every client release (nearai/cloud-api#970, #988, #1068). +/// `ANTHROPIC_DENIED_BETAS` remains as an operator kill-switch, and the body +/// gates in [`reject_unsupported_features`] still enforce billing policy. fn normalized_beta_header( headers: &HeaderMap, - additional_allowed_betas: &[String], + denied_betas: &[String], ) -> RouteResult> { - // Keep the default surface narrow, while allowing operators to admit a - // newly released token through ANTHROPIC_ALLOWED_BETAS without a code - // deployment. Body policy still blocks unsupported server-side products. let mut betas = Vec::::new(); for value in headers.get_all("anthropic-beta") { let value = value.to_str().map_err(|_| { @@ -845,18 +870,33 @@ fn normalized_beta_header( .map(str::trim) .filter(|token| !token.is_empty()) { - if !ALLOWED_ANTHROPIC_BETAS.contains(&token) - && !additional_allowed_betas - .iter() - .any(|allowed| allowed == token) + if !beta_token_is_well_formed(token) { + return Err(malformed_beta_token(token)); + } + if denied_betas + .iter() + .any(|denied| denied.eq_ignore_ascii_case(token)) { - return Err(unsupported_feature(&format!( - "anthropic-beta token '{token}'" - ))); + return Err(AnthropicRouteError::new( + StatusCode::BAD_REQUEST, + "invalid_request_error", + format!( + "anthropic-beta token '{token}' is disabled on this endpoint by \ + operator policy" + ), + )); } - if !betas.iter().any(|existing| existing == token) { - betas.push(token.to_string()); + if betas.iter().any(|existing| existing == token) { + continue; } + if betas.len() == MAX_BETA_TOKENS { + return Err(AnthropicRouteError::new( + StatusCode::BAD_REQUEST, + "invalid_request_error", + format!("anthropic-beta lists more than {MAX_BETA_TOKENS} distinct tokens"), + )); + } + betas.push(token.to_string()); } } Ok((!betas.is_empty()).then(|| betas.join(","))) @@ -1222,8 +1262,17 @@ mod tests { assert!(!prepared.stream); } + /// The default `anthropic-beta` header sent by Claude Code 2.1.272, plus + /// `redact-thinking-2026-02-12`, which it adds conditionally. + const CLAUDE_CODE_BETAS: &str = "claude-code-20250219,interleaved-thinking-2025-05-14,\ +thinking-token-count-2026-05-13,context-management-2025-06-27,\ +prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,\ +advisor-tool-2026-03-01,effort-2025-11-24,fallback-credit-2026-06-01,afk-mode-2026-01-31,\ +per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,\ +redact-thinking-2026-02-12"; + #[test] - fn beta_tokens_are_preserved_and_deduplicated() { + fn beta_tokens_are_relayed_verbatim_and_deduplicated() { let mut headers = request_headers(); headers.append( "anthropic-beta", @@ -1231,26 +1280,27 @@ mod tests { ); headers.append( "anthropic-beta", - HeaderValue::from_static("claude-code-20250219"), + HeaderValue::from_static("claude-code-20250219, future-beta-2099-12-31"), ); assert_eq!( normalized_beta_header(&headers, &[]).unwrap().as_deref(), - Some("claude-code-20250219,interleaved-thinking-2025-05-14") + Some("claude-code-20250219,interleaved-thinking-2025-05-14,future-beta-2099-12-31") ); + // A token this endpoint has never heard of is Anthropic's call, not ours. headers.insert( "anthropic-beta", HeaderValue::from_static("fast-mode-2026-02-01"), ); - assert!(normalized_beta_header(&headers, &[]).is_err()); - - let additional = vec!["fast-mode-2026-02-01".to_string()]; assert_eq!( - normalized_beta_header(&headers, &additional) - .unwrap() - .as_deref(), + normalized_beta_header(&headers, &[]).unwrap().as_deref(), Some("fast-mode-2026-02-01") ); + + assert_eq!( + normalized_beta_header(&request_headers(), &[]).unwrap(), + None + ); } #[test] @@ -1258,9 +1308,7 @@ mod tests { let mut headers = request_headers(); headers.insert( "anthropic-beta", - HeaderValue::from_static( - "claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,effort-2025-11-24,structured-outputs-2025-12-15,context-1m-2025-08-07", - ), + HeaderValue::from_str(CLAUDE_CODE_BETAS).unwrap(), ); let prepared = prepare_request( &headers, @@ -1272,13 +1320,104 @@ mod tests { .unwrap(); assert!(prepared.beta_query); + assert_eq!(prepared.headers.beta.as_deref(), Some(CLAUDE_CODE_BETAS)); + assert!(normalize_query(Some("future=true")).is_err()); + } + + #[test] + fn denied_beta_tokens_are_rejected_case_insensitively() { + let denied = vec!["fast-mode-2026-02-01".to_string()]; + let mut headers = request_headers(); + headers.insert( + "anthropic-beta", + HeaderValue::from_static("Fast-Mode-2026-02-01,claude-code-20250219"), + ); + let error = normalized_beta_header(&headers, &denied).unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); + assert!(error.message.contains("disabled")); + + headers.insert( + "anthropic-beta", + HeaderValue::from_static("claude-code-20250219"), + ); + assert_eq!( + normalized_beta_header(&headers, &denied) + .unwrap() + .as_deref(), + Some("claude-code-20250219") + ); + } + + #[test] + fn malformed_beta_tokens_are_rejected() { + let over_long = "a".repeat(MAX_BETA_TOKEN_LEN + 1); + let too_many = (0..=MAX_BETA_TOKENS) + .map(|index| format!("beta-{index}")) + .collect::>() + .join(","); + for value in [ + "claude code", + "foo;bar", + "\"quoted\"", + &over_long, + &too_many, + ] { + let mut headers = request_headers(); + headers.insert("anthropic-beta", HeaderValue::from_str(value).unwrap()); + let error = normalized_beta_header(&headers, &[]).unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); + // Must not read like the retired allowlist: an operator or syntax + // refusal has to be distinguishable from a stale gate in triage. + assert!(!error.message.contains("is not supported"), "{value}"); + } + + let mut headers = request_headers(); + headers.insert("anthropic-beta", HeaderValue::from_bytes(b"\xff").unwrap()); + assert!(normalized_beta_header(&headers, &[]).is_err()); + + // One below each bound still passes. + let mut headers = request_headers(); + headers.insert( + "anthropic-beta", + HeaderValue::from_str(&"a".repeat(MAX_BETA_TOKEN_LEN)).unwrap(), + ); + assert!(normalized_beta_header(&headers, &[]).is_ok()); + } + + #[test] + fn beta_header_alone_never_unlocks_a_body_gated_feature() { + let mut headers = request_headers(); + headers.insert( + "anthropic-beta", + HeaderValue::from_static("fast-mode-2026-02-01"), + ); + let prepared = prepare_request( + &headers, + None, + &base_body(), + AnthropicRawEndpoint::Messages, + &[], + ) + .unwrap(); assert_eq!( prepared.headers.beta.as_deref(), - headers - .get("anthropic-beta") - .and_then(|value| value.to_str().ok()) + Some("fast-mode-2026-02-01") ); - assert!(normalize_query(Some("future=true")).is_err()); + + // `PreparedRequest` is deliberately not `Debug` (it holds the customer + // body), so inspect the error arm directly rather than `unwrap_err`. + let mut body: serde_json::Value = serde_json::from_slice(&base_body()).unwrap(); + body["speed"] = serde_json::json!("fast"); + let Err(error) = prepare_request( + &headers, + None, + &serde_json::to_vec(&body).unwrap(), + AnthropicRawEndpoint::Messages, + &[], + ) else { + panic!("speed=fast must stay rejected regardless of the beta header"); + }; + assert!(error.message.contains("speed=fast")); } #[test] diff --git a/crates/config/src/types.rs b/crates/config/src/types.rs index 8f4268701..469e181d8 100644 --- a/crates/config/src/types.rs +++ b/crates/config/src/types.rs @@ -2301,22 +2301,44 @@ mod tests { #[test] #[serial] - fn native_anthropic_beta_allowlist_is_trimmed_and_deduplicated() { - let previous = std::env::var_os("ANTHROPIC_ALLOWED_BETAS"); + fn native_anthropic_beta_denylist_is_trimmed_and_deduplicated() { + let previous = std::env::var_os("ANTHROPIC_DENIED_BETAS"); std::env::set_var( - "ANTHROPIC_ALLOWED_BETAS", - "future-beta-1, future-beta-2, future-beta-1, ", + "ANTHROPIC_DENIED_BETAS", + "premium-beta-1, premium-beta-2, premium-beta-1, ", ); assert_eq!( - ExternalProvidersConfig::from_env().anthropic_allowed_betas, - vec!["future-beta-1".to_string(), "future-beta-2".to_string()] + ExternalProvidersConfig::from_env().anthropic_denied_betas, + vec!["premium-beta-1".to_string(), "premium-beta-2".to_string()] ); match previous { + Some(value) => std::env::set_var("ANTHROPIC_DENIED_BETAS", value), + None => std::env::remove_var("ANTHROPIC_DENIED_BETAS"), + } + } + + #[test] + #[serial] + fn retired_anthropic_beta_allowlist_no_longer_gates_tokens() { + let previous_allowed = std::env::var_os("ANTHROPIC_ALLOWED_BETAS"); + let previous_denied = std::env::var_os("ANTHROPIC_DENIED_BETAS"); + std::env::set_var("ANTHROPIC_ALLOWED_BETAS", "legacy-beta-2026-01-01"); + std::env::remove_var("ANTHROPIC_DENIED_BETAS"); + + assert!(ExternalProvidersConfig::from_env() + .anthropic_denied_betas + .is_empty()); + + match previous_allowed { Some(value) => std::env::set_var("ANTHROPIC_ALLOWED_BETAS", value), None => std::env::remove_var("ANTHROPIC_ALLOWED_BETAS"), } + match previous_denied { + Some(value) => std::env::set_var("ANTHROPIC_DENIED_BETAS", value), + None => std::env::remove_var("ANTHROPIC_DENIED_BETAS"), + } } } @@ -2355,9 +2377,10 @@ pub struct ExternalProvidersConfig { /// Expose the native Anthropic Messages routes. Hard-off by default so the /// first rollout can be enabled on staging without changing production. pub enable_anthropic_messages: bool, - /// Additional native Anthropic beta tokens admitted by operations without - /// waiting for a Cloud API release. - pub anthropic_allowed_betas: Vec, + /// Native Anthropic beta tokens refused at the router. Unknown tokens are + /// otherwise relayed to Anthropic, so this is the operator kill-switch for a + /// future header-only premium beta — settable without a Cloud API release. + pub anthropic_denied_betas: Vec, /// Google Gemini API key pub gemini_api_key: Option, /// Default timeout for external provider requests (seconds) @@ -2409,7 +2432,13 @@ impl ExternalProvidersConfig { .ok() .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) .unwrap_or(false); - let mut anthropic_allowed_betas = env::var("ANTHROPIC_ALLOWED_BETAS") + if env::var_os("ANTHROPIC_ALLOWED_BETAS").is_some() { + eprintln!( + "WARN: ANTHROPIC_ALLOWED_BETAS is ignored; native Anthropic beta tokens are \ + relayed upstream (use ANTHROPIC_DENIED_BETAS to refuse one)" + ); + } + let mut anthropic_denied_betas = env::var("ANTHROPIC_DENIED_BETAS") .ok() .map(|value| { value @@ -2420,8 +2449,8 @@ impl ExternalProvidersConfig { .collect::>() }) .unwrap_or_default(); - anthropic_allowed_betas.sort_unstable(); - anthropic_allowed_betas.dedup(); + anthropic_denied_betas.sort_unstable(); + anthropic_denied_betas.dedup(); // Gemini API key let gemini_api_key = if let Ok(path) = env::var("GEMINI_API_KEY_FILE") { @@ -2533,7 +2562,7 @@ impl ExternalProvidersConfig { openai_api_key, anthropic_api_key, enable_anthropic_messages, - anthropic_allowed_betas, + anthropic_denied_betas, gemini_api_key, timeout_seconds, refresh_interval_secs, diff --git a/env.example b/env.example index eaeecd886..16df7c499 100644 --- a/env.example +++ b/env.example @@ -331,7 +331,8 @@ BRAVE_SEARCH_PRO_API_KEY=MY_KEY # until the native compatibility and billing gates have completed their soak. # The upstream credential can be supplied inline or through a mounted file. # ENABLE_ANTHROPIC_MESSAGES=false # "true"/"1" to enable -# ANTHROPIC_ALLOWED_BETAS= # optional comma-separated additions +# ANTHROPIC_DENIED_BETAS= # optional comma-separated tokens to refuse +# # (unknown tokens are relayed to Anthropic) # ANTHROPIC_API_KEY=sk-ant-... # secret # ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic_api_key diff --git a/scripts/test_anthropic_cache_walk.py b/scripts/test_anthropic_cache_walk.py index 93f48a185..3748d3010 100755 --- a/scripts/test_anthropic_cache_walk.py +++ b/scripts/test_anthropic_cache_walk.py @@ -31,14 +31,21 @@ "ANTHROPIC_BETA", ",".join( [ + # The default header sent by Claude Code 2.1.272, plus + # redact-thinking-2026-02-12, which it adds conditionally. "claude-code-20250219", "interleaved-thinking-2025-05-14", "thinking-token-count-2026-05-13", "context-management-2025-06-27", "prompt-caching-scope-2026-01-05", + "mid-conversation-system-2026-04-07", "advisor-tool-2026-03-01", "effort-2025-11-24", - "structured-outputs-2025-12-15", + "fallback-credit-2026-06-01", + "afk-mode-2026-01-31", + "per-turn-control-2026-07-01", + "mid-conversation-tool-changes-2026-07-01", + "redact-thinking-2026-02-12", ] ), )