This commit is contained in:
FailSafe 2026-08-27 20:35:01 +03:00 committed by GitHub
commit 7411875c16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 101 additions and 2 deletions

View file

@ -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<String> 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)

View file

@ -74,7 +74,7 @@ use crate::proxy::AppState;
// `Extension<AuthMode>` 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 {

View file

@ -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 `<vendor>.<model>-<date>-<rev>`
/// (optionally geo-prefixed). They never contain `/`, `\`, or `..`
/// segments. Axum's `Path<String>` 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(""));
}
}