diff --git a/crates/headroom-proxy/src/bedrock/invoke.rs b/crates/headroom-proxy/src/bedrock/invoke.rs index a30e0027a..2d2126984 100644 --- a/crates/headroom-proxy/src/bedrock/invoke.rs +++ b/crates/headroom-proxy/src/bedrock/invoke.rs @@ -65,7 +65,7 @@ use crate::proxy::AppState; // would risk drift from the middleware's resolution + WARN log. use headroom_core::auth_mode::AuthMode; -use crate::bedrock::vendor::is_anthropic_model_id; +use crate::bedrock::vendor::{is_anthropic_model_id, is_safe_model_id}; /// RAII guard that observes the `bedrock_invoke_latency_seconds` /// histogram on drop. Created at handler entry; observed when the @@ -173,6 +173,25 @@ pub async fn handle_invoke( "bedrock invoke route received request" ); + // Security: validate model_id to prevent path traversal attacks. + // Axum's Path extractor URL-decodes the path segment, so + // %2F becomes / in model_id. Without this check, an attacker could + // craft a model_id like ../../admin to redirect the upstream request + // to an arbitrary path on the AWS Bedrock endpoint. + if !is_safe_model_id(&model_id) { + tracing::warn!( + event = "bedrock_model_id_invalid", + request_id = %request_id, + model_id = %model_id, + "bedrock invoke: model_id contains path traversal characters; rejecting" + ); + return error_response( + StatusCode::BAD_REQUEST, + "bedrock_model_id_invalid", + "model_id contains invalid path traversal characters", + ); + } + let is_anthropic = is_anthropic_model_id(&model_id); let outbound_body: Bytes = if is_anthropic { run_anthropic_compression(&body, &state, auth_mode, &request_id) diff --git a/crates/headroom-proxy/src/bedrock/invoke_streaming.rs b/crates/headroom-proxy/src/bedrock/invoke_streaming.rs index 62501003c..1f779f8c0 100644 --- a/crates/headroom-proxy/src/bedrock/invoke_streaming.rs +++ b/crates/headroom-proxy/src/bedrock/invoke_streaming.rs @@ -74,7 +74,7 @@ use crate::proxy::AppState; // `Extension` extractor. use headroom_core::auth_mode::AuthMode; -use crate::bedrock::vendor::is_anthropic_model_id; +use crate::bedrock::vendor::{is_anthropic_model_id, is_safe_model_id}; /// AWS Bedrock Runtime DNS template. const BEDROCK_RUNTIME_HOST_TEMPLATE: &str = "bedrock-runtime.{region}.amazonaws.com"; @@ -151,6 +151,29 @@ pub async fn handle_invoke_streaming( "bedrock invoke-with-response-stream route received request" ); + // Security: validate model_id to prevent path traversal attacks. + // Same check as the non-streaming handler — see invoke.rs for rationale. + if !is_safe_model_id(&model_id) { + tracing::warn!( + event = "bedrock_model_id_invalid", + request_id = %request_id, + model_id = %model_id, + "bedrock invoke-streaming: model_id contains path traversal characters; rejecting" + ); + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from( + serde_json::json!({ + "error": { + "type": "bedrock_model_id_invalid", + "message": "model_id contains invalid path traversal characters", + } + }) + .to_string(), + )) + .expect("static"); + } + // 1. Live-zone compression for Anthropic-shape bodies (same as D1). let is_anthropic = is_anthropic_model_id(&model_id); let outbound_body: Bytes = if is_anthropic { diff --git a/crates/headroom-proxy/src/bedrock/vendor.rs b/crates/headroom-proxy/src/bedrock/vendor.rs index bfa378be1..73e7feb71 100644 --- a/crates/headroom-proxy/src/bedrock/vendor.rs +++ b/crates/headroom-proxy/src/bedrock/vendor.rs @@ -36,6 +36,39 @@ pub fn is_anthropic_model_id(model_id: &str) -> bool { canonical_vendor(model_id) == "anthropic" } +/// Whether a Bedrock model id is safe to interpolate into an upstream +/// URL path. +/// +/// Bedrock model IDs are dot-separated `.--` +/// (optionally geo-prefixed). They never contain `/`, `\`, or `..` +/// segments. Axum's `Path` extractor URL-decodes path +/// parameters, so `%2F` becomes `/` in the extracted `model_id`. Without +/// validation, an attacker could craft a `model_id` like `../../admin` +/// to redirect the upstream request to an arbitrary path on the AWS +/// Bedrock endpoint (SSRF / access control bypass). The SigV4 signature +/// is computed AFTER the URL is constructed, so it signs the manipulated +/// path — AWS will accept the request. +/// +/// This check rejects any `model_id` containing path-traversal characters. +/// It is called by both the non-streaming and streaming Bedrock handlers +/// before constructing the upstream URL. +pub fn is_safe_model_id(model_id: &str) -> bool { + if model_id.is_empty() { + return false; + } + // Reject any path separator — Bedrock model IDs use dots, not slashes. + if model_id.contains('/') || model_id.contains('\\') { + return false; + } + // Reject `..` traversal segments. A literal `..` can appear as a + // dot-segment in a model ID only through a malicious path parameter; + // real AWS model IDs never contain `..`. + if model_id.contains("..") { + return false; + } + true +} + #[cfg(test)] mod tests { use super::*; @@ -79,4 +112,28 @@ mod tests { assert!(!is_anthropic_model_id("eu.amazon.nova-lite-v1:0")); assert!(!is_anthropic_model_id("mistral.voxtral-mini-3b-2507")); } + + #[test] + fn is_safe_model_id_accepts_valid_ids() { + assert!(is_safe_model_id("anthropic.claude-3-haiku-20240307-v1:0")); + assert!(is_safe_model_id("eu.anthropic.claude-haiku-4-5-20251001-v1:0")); + assert!(is_safe_model_id("amazon.titan-text-express-v1")); + assert!(is_safe_model_id("meta.llama3-70b-instruct-v1:0")); + assert!(is_safe_model_id("global.anthropic.claude-haiku-4-5-20251001-v1:0")); + } + + #[test] + fn is_safe_model_id_rejects_path_traversal() { + // Forward slash — the primary SSRF vector (decoded from %2F by axum) + assert!(!is_safe_model_id("../../admin")); + assert!(!is_safe_model_id("foo/bar")); + // Backslash + assert!(!is_safe_model_id("..\\..\\admin")); + assert!(!is_safe_model_id("foo\\bar")); + // Double-dot segment without separators + assert!(!is_safe_model_id("..")); + assert!(!is_safe_model_id("foo..bar")); + // Empty + assert!(!is_safe_model_id("")); + } }