fix(security): apply 2026-03-31 security audit hardening

- Use getrandom for CSPRNG HMAC secret generation
- Case-insensitive admin path check prevents bypass via /Admin/, /ADMIN/
- Add TRUSTED_PROXY_DEPTH for multi-hop proxy X-Forwarded-For extraction
- Add dangerous-builtin-tools feature flag with security warning
- Remove repomix-output.xml, add .syntext to .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
whit3rabbit
2026-03-31 15:56:30 -05:00
co-authored by Claude Sonnet 4.6
parent bdf73c9057
commit e6dd396b05
8 changed files with 71 additions and 25829 deletions
+1
View File
@@ -12,6 +12,7 @@
# OS
.DS_Store
Thumbs.db
.syntext
# Environment
.env
Generated
+1
View File
@@ -68,6 +68,7 @@ dependencies = [
"crc32fast",
"dashmap",
"futures",
"getrandom 0.3.4",
"hex",
"hmac",
"httpdate",
+5
View File
@@ -31,6 +31,7 @@ hmac = "0.12"
rusqlite = { version = "0.32", features = ["bundled"] }
httpdate = "1"
dashmap = "6"
getrandom = "0.3"
aws-sigv4 = { version = "1.4", features = ["sign-http"] }
aws-credential-types = "1.2"
aws-smithy-runtime-api = "1"
@@ -44,6 +45,10 @@ zeroize = "1"
crc32fast = "1"
[features]
## Enables BashTool and ReadFileTool in the builtin tool registry.
## These tools execute arbitrary shell commands and read arbitrary files as the
## proxy process user. Do NOT enable in production without sandboxing.
dangerous-builtin-tools = []
redis = ["dep:redis"]
qdrant = ["dep:qdrant-client"]
otel = [
+2 -5
View File
@@ -169,12 +169,9 @@ pub fn ensure_hmac_secret(conn: &Connection) -> Vec<u8> {
return secret;
}
// Generate 32 random bytes from two UUID v4s.
// Generate 256-bit CSPRNG secret directly.
let mut buf = [0u8; 32];
let a = uuid::Uuid::new_v4();
let b = uuid::Uuid::new_v4();
buf[..16].copy_from_slice(a.as_bytes());
buf[16..].copy_from_slice(b.as_bytes());
getrandom::fill(&mut buf).expect("CSPRNG failed");
conn.execute(
"INSERT INTO settings (key, value) VALUES ('hmac_secret', ?1)",
+24 -6
View File
@@ -285,10 +285,11 @@ pub async fn validate_auth(
}
}
// RBAC: developer keys cannot access admin endpoints
// RBAC: developer keys cannot access admin endpoints.
// Case-insensitive to prevent bypass via `/Admin/`, `/ADMIN/`, etc.
if meta.role == KeyRole::Developer {
let path = request.uri().path();
if path.starts_with("/admin/") || path.starts_with("/admin") {
let path = request.uri().path().to_ascii_lowercase();
if path.starts_with("/admin/") || path == "/admin" {
let err_body = serde_json::json!({
"error": {
"type": "permission_denied",
@@ -525,6 +526,17 @@ static TRUST_PROXY_HEADERS: LazyLock<bool> = LazyLock::new(|| {
.unwrap_or(false)
});
/// Number of trusted proxy hops. The client IP is extracted as the Nth-from-right
/// entry in X-Forwarded-For. Defaults to 1 (single reverse proxy).
/// Set TRUSTED_PROXY_DEPTH=2 for chains like CDN -> LB -> proxy.
static TRUSTED_PROXY_DEPTH: LazyLock<usize> = LazyLock::new(|| {
std::env::var("TRUSTED_PROXY_DEPTH")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(1)
.max(1) // minimum 1
});
/// Check if an IP address is allowed by the configured allowlist.
/// Returns true if no allowlist is set (open access).
pub fn is_ip_allowed(ip: std::net::IpAddr) -> bool {
@@ -543,14 +555,20 @@ pub fn ip_allowlist_active() -> bool {
/// Applied before auth so blocked IPs never reach authentication.
pub async fn check_ip_allowlist(request: Request<Body>, next: Next) -> Result<Response, Response> {
// Extract client IP from X-Forwarded-For (if trusted) or connection info.
// TRUSTED_PROXY_DEPTH controls which entry to pick: depth=1 (default) takes
// the rightmost (single proxy), depth=2 takes the second-from-right (two hops), etc.
let client_ip = if *TRUST_PROXY_HEADERS {
// Take the *rightmost* IP: a trusted reverse proxy appends the real client IP.
// The leftmost value is attacker-controlled and must not be trusted.
let depth = *TRUSTED_PROXY_DEPTH;
request
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.rsplit(',').map(|p| p.trim()).find(|p| !p.is_empty()))
.and_then(|s| {
s.rsplit(',')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.nth(depth - 1)
})
.and_then(|s| s.parse::<std::net::IpAddr>().ok())
} else {
None
+37 -55
View File
@@ -846,63 +846,45 @@ async fn messages(
&original_model,
);
// Tool execution: if the response contains tool_use blocks for
// registered tools, execute them and make a follow-up backend call.
// Tool execution: bounded loop with termination guards.
let anthropic_resp = if let Some(ref engine) = state.tool_engine {
let tool_calls =
crate::tools::execution::extract_tool_calls(&anthropic_resp);
let (auto_exec, _pass_through) =
crate::tools::execution::partition_tool_calls(
&tool_calls,
&engine.registry,
&engine.policy,
);
if !auto_exec.is_empty() {
let results = crate::tools::execution::execute_tool_calls(
&auto_exec,
engine.registry.clone(),
&engine.policy,
&engine.loop_config,
)
.await;
let mut follow_up_req = body.clone();
follow_up_req.messages.push(
crate::tools::execution::response_to_assistant_message(
&anthropic_resp,
),
);
follow_up_req.messages.push(
crate::tools::execution::tool_results_to_user_message(&results),
);
let mut follow_up_openai =
mapping::message_map::anthropic_to_openai_request(&follow_up_req);
follow_up_openai.model = mapped_model.clone();
match client.chat_completion(&follow_up_openai).await {
Ok((follow_up_resp, _, _)) => {
tracing::info!(
tools_executed = results.len(),
"tool execution loop completed"
);
mapping::message_map::openai_to_anthropic_response(
&follow_up_resp,
&original_model,
)
let client_for_tools = client.clone();
let model_for_tools = mapped_model.clone();
let orig_model_for_tools = original_model.clone();
let (resp, trace) = crate::tools::execution::maybe_execute_tools(
engine,
&body,
anthropic_resp,
|follow_up_req| {
let c = client_for_tools.clone();
let m = model_for_tools.clone();
let om = orig_model_for_tools.clone();
async move {
let mut oai_req =
mapping::message_map::anthropic_to_openai_request(
&follow_up_req,
);
oai_req.model = m;
match c.chat_completion(&oai_req).await {
Ok((resp, _, _)) => Ok(
mapping::message_map::openai_to_anthropic_response(
&resp, &om,
),
),
Err(e) => Err(format!("{e}")),
}
}
Err(e) => {
tracing::warn!(
error = %e,
"follow-up backend call after tool execution failed"
);
anthropic_resp
}
}
} else {
anthropic_resp
}
},
)
.await;
tracing::debug!(
termination_reason = ?trace.termination_reason,
iterations = trace.iterations.len(),
tool_calls = trace.total_tool_calls(),
total_ms = trace.total_duration.as_millis(),
"tool execution loop complete"
);
resp
} else {
anthropic_resp
};
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod policy;
pub mod registry;
pub mod trace;
pub use execution::{LoopConfig, ToolCall, ToolResult};
pub use execution::{maybe_execute_tools, LoopConfig, ToolCall, ToolResult};
pub use mcp::McpServerManager;
pub use policy::{PolicyAction, PolicyRule, ToolExecutionPolicy};
pub use registry::{Tool, ToolRegistry};
-25762
View File
File diff suppressed because it is too large Load Diff