mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge 58168685bd into 27b4e2d147
This commit is contained in:
commit
7411875c16
3 changed files with 101 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue