fix(security): remediate unassigned CodeQL findings
- harden URL/request handling for composio and whatsapp integrations - reduce cleartext logging exposure across providers/tools/gateway - hash and constant-time compare gateway webhook secrets - expand nested secret encryption coverage in config - align feature aliases and add regression tests for security paths - fix bubblewrap all-features test invocation surfaced during deep validation
This commit is contained in:
parent
f9d681063d
commit
1711f140be
14 changed files with 481 additions and 146 deletions
|
|
@ -106,17 +106,17 @@ struct NativeContentIn {
|
|||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
pub fn new(api_key: Option<&str>) -> Self {
|
||||
Self::with_base_url(api_key, None)
|
||||
pub fn new(credential: Option<&str>) -> Self {
|
||||
Self::with_base_url(credential, None)
|
||||
}
|
||||
|
||||
pub fn with_base_url(api_key: Option<&str>, base_url: Option<&str>) -> Self {
|
||||
pub fn with_base_url(credential: Option<&str>, base_url: Option<&str>) -> Self {
|
||||
let base_url = base_url
|
||||
.map(|u| u.trim_end_matches('/'))
|
||||
.unwrap_or("https://api.anthropic.com")
|
||||
.to_string();
|
||||
Self {
|
||||
credential: api_key
|
||||
credential: credential
|
||||
.map(str::trim)
|
||||
.filter(|k| !k.is_empty())
|
||||
.map(ToString::to_string),
|
||||
|
|
@ -410,9 +410,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn creates_with_key() {
|
||||
let p = AnthropicProvider::new(Some("sk-ant-test123"));
|
||||
let p = AnthropicProvider::new(Some("anthropic-test-credential"));
|
||||
assert!(p.credential.is_some());
|
||||
assert_eq!(p.credential.as_deref(), Some("sk-ant-test123"));
|
||||
assert_eq!(p.credential.as_deref(), Some("anthropic-test-credential"));
|
||||
assert_eq!(p.base_url, "https://api.anthropic.com");
|
||||
}
|
||||
|
||||
|
|
@ -431,17 +431,19 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn creates_with_whitespace_key() {
|
||||
let p = AnthropicProvider::new(Some(" sk-ant-test123 "));
|
||||
let p = AnthropicProvider::new(Some(" anthropic-test-credential "));
|
||||
assert!(p.credential.is_some());
|
||||
assert_eq!(p.credential.as_deref(), Some("sk-ant-test123"));
|
||||
assert_eq!(p.credential.as_deref(), Some("anthropic-test-credential"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_with_custom_base_url() {
|
||||
let p =
|
||||
AnthropicProvider::with_base_url(Some("sk-ant-test"), Some("https://api.example.com"));
|
||||
let p = AnthropicProvider::with_base_url(
|
||||
Some("anthropic-credential"),
|
||||
Some("https://api.example.com"),
|
||||
);
|
||||
assert_eq!(p.base_url, "https://api.example.com");
|
||||
assert_eq!(p.credential.as_deref(), Some("sk-ant-test"));
|
||||
assert_eq!(p.credential.as_deref(), Some("anthropic-credential"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize};
|
|||
pub struct OpenAiCompatibleProvider {
|
||||
pub(crate) name: String,
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) api_key: Option<String>,
|
||||
pub(crate) credential: Option<String>,
|
||||
pub(crate) auth_header: AuthStyle,
|
||||
/// When false, do not fall back to /v1/responses on chat completions 404.
|
||||
/// GLM/Zhipu does not support the responses API.
|
||||
|
|
@ -37,11 +37,16 @@ pub enum AuthStyle {
|
|||
}
|
||||
|
||||
impl OpenAiCompatibleProvider {
|
||||
pub fn new(name: &str, base_url: &str, api_key: Option<&str>, auth_style: AuthStyle) -> Self {
|
||||
pub fn new(
|
||||
name: &str,
|
||||
base_url: &str,
|
||||
credential: Option<&str>,
|
||||
auth_style: AuthStyle,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
api_key: api_key.map(ToString::to_string),
|
||||
credential: credential.map(ToString::to_string),
|
||||
auth_header: auth_style,
|
||||
supports_responses_fallback: true,
|
||||
client: Client::builder()
|
||||
|
|
@ -57,13 +62,13 @@ impl OpenAiCompatibleProvider {
|
|||
pub fn new_no_responses_fallback(
|
||||
name: &str,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
credential: Option<&str>,
|
||||
auth_style: AuthStyle,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
api_key: api_key.map(ToString::to_string),
|
||||
credential: credential.map(ToString::to_string),
|
||||
auth_header: auth_style,
|
||||
supports_responses_fallback: false,
|
||||
client: Client::builder()
|
||||
|
|
@ -409,18 +414,18 @@ impl OpenAiCompatibleProvider {
|
|||
fn apply_auth_header(
|
||||
&self,
|
||||
req: reqwest::RequestBuilder,
|
||||
api_key: &str,
|
||||
credential: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
match &self.auth_header {
|
||||
AuthStyle::Bearer => req.header("Authorization", format!("Bearer {api_key}")),
|
||||
AuthStyle::XApiKey => req.header("x-api-key", api_key),
|
||||
AuthStyle::Custom(header) => req.header(header, api_key),
|
||||
AuthStyle::Bearer => req.header("Authorization", format!("Bearer {credential}")),
|
||||
AuthStyle::XApiKey => req.header("x-api-key", credential),
|
||||
AuthStyle::Custom(header) => req.header(header, credential),
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_via_responses(
|
||||
&self,
|
||||
api_key: &str,
|
||||
credential: &str,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
model: &str,
|
||||
|
|
@ -438,7 +443,7 @@ impl OpenAiCompatibleProvider {
|
|||
let url = self.responses_url();
|
||||
|
||||
let response = self
|
||||
.apply_auth_header(self.client.post(&url).json(&request), api_key)
|
||||
.apply_auth_header(self.client.post(&url).json(&request), credential)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
|
|
@ -463,7 +468,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"{} API key not set. Run `zeroclaw onboard` or set the appropriate env var.",
|
||||
self.name
|
||||
|
|
@ -494,7 +499,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
let url = self.chat_completions_url();
|
||||
|
||||
let response = self
|
||||
.apply_auth_header(self.client.post(&url).json(&request), api_key)
|
||||
.apply_auth_header(self.client.post(&url).json(&request), credential)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
|
|
@ -505,7 +510,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
|
||||
if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback {
|
||||
return self
|
||||
.chat_via_responses(api_key, system_prompt, message, model)
|
||||
.chat_via_responses(credential, system_prompt, message, model)
|
||||
.await
|
||||
.map_err(|responses_err| {
|
||||
anyhow::anyhow!(
|
||||
|
|
@ -549,7 +554,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"{} API key not set. Run `zeroclaw onboard` or set the appropriate env var.",
|
||||
self.name
|
||||
|
|
@ -573,7 +578,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
|
||||
let url = self.chat_completions_url();
|
||||
let response = self
|
||||
.apply_auth_header(self.client.post(&url).json(&request), api_key)
|
||||
.apply_auth_header(self.client.post(&url).json(&request), credential)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
|
|
@ -588,7 +593,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
if let Some(user_msg) = last_user {
|
||||
return self
|
||||
.chat_via_responses(
|
||||
api_key,
|
||||
credential,
|
||||
system.map(|m| m.content.as_str()),
|
||||
&user_msg.content,
|
||||
model,
|
||||
|
|
@ -795,16 +800,20 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn creates_with_key() {
|
||||
let p = make_provider("venice", "https://api.venice.ai", Some("vn-key"));
|
||||
let p = make_provider(
|
||||
"venice",
|
||||
"https://api.venice.ai",
|
||||
Some("venice-test-credential"),
|
||||
);
|
||||
assert_eq!(p.name, "venice");
|
||||
assert_eq!(p.base_url, "https://api.venice.ai");
|
||||
assert_eq!(p.api_key.as_deref(), Some("vn-key"));
|
||||
assert_eq!(p.credential.as_deref(), Some("venice-test-credential"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_without_key() {
|
||||
let p = make_provider("test", "https://example.com", None);
|
||||
assert!(p.api_key.is_none());
|
||||
assert!(p.credential.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -104,8 +104,8 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E
|
|||
///
|
||||
/// For Anthropic, the provider-specific env var is `ANTHROPIC_OAUTH_TOKEN` (for setup-tokens)
|
||||
/// followed by `ANTHROPIC_API_KEY` (for regular API keys).
|
||||
fn resolve_api_key(name: &str, api_key: Option<&str>) -> Option<String> {
|
||||
if let Some(key) = api_key.map(str::trim).filter(|k| !k.is_empty()) {
|
||||
fn resolve_provider_credential(name: &str, credential_override: Option<&str>) -> Option<String> {
|
||||
if let Some(key) = credential_override.map(str::trim).filter(|k| !k.is_empty()) {
|
||||
return Some(key.to_string());
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +194,7 @@ pub fn create_provider_with_url(
|
|||
api_key: Option<&str>,
|
||||
api_url: Option<&str>,
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
let resolved_key = resolve_api_key(name, api_key);
|
||||
let resolved_key = resolve_provider_credential(name, api_key);
|
||||
let key = resolved_key.as_deref();
|
||||
match name {
|
||||
// ── Primary providers (custom implementations) ───────
|
||||
|
|
@ -454,8 +454,8 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_prefers_explicit_argument() {
|
||||
let resolved = resolve_api_key("openrouter", Some(" explicit-key "));
|
||||
fn resolve_provider_credential_prefers_explicit_argument() {
|
||||
let resolved = resolve_provider_credential("openrouter", Some(" explicit-key "));
|
||||
assert_eq!(resolved.as_deref(), Some("explicit-key"));
|
||||
}
|
||||
|
||||
|
|
@ -463,18 +463,18 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn factory_openrouter() {
|
||||
assert!(create_provider("openrouter", Some("sk-test")).is_ok());
|
||||
assert!(create_provider("openrouter", Some("provider-test-credential")).is_ok());
|
||||
assert!(create_provider("openrouter", None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_anthropic() {
|
||||
assert!(create_provider("anthropic", Some("sk-test")).is_ok());
|
||||
assert!(create_provider("anthropic", Some("provider-test-credential")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_openai() {
|
||||
assert!(create_provider("openai", Some("sk-test")).is_ok());
|
||||
assert!(create_provider("openai", Some("provider-test-credential")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -774,15 +774,24 @@ mod tests {
|
|||
scheduler_retries: 2,
|
||||
};
|
||||
|
||||
let provider = create_resilient_provider("openrouter", Some("sk-test"), None, &reliability);
|
||||
let provider = create_resilient_provider(
|
||||
"openrouter",
|
||||
Some("provider-test-credential"),
|
||||
None,
|
||||
&reliability,
|
||||
);
|
||||
assert!(provider.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resilient_provider_errors_for_invalid_primary() {
|
||||
let reliability = crate::config::ReliabilityConfig::default();
|
||||
let provider =
|
||||
create_resilient_provider("totally-invalid", Some("sk-test"), None, &reliability);
|
||||
let provider = create_resilient_provider(
|
||||
"totally-invalid",
|
||||
Some("provider-test-credential"),
|
||||
None,
|
||||
&reliability,
|
||||
);
|
||||
assert!(provider.is_err());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use reqwest::Client;
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct OpenAiProvider {
|
||||
api_key: Option<String>,
|
||||
credential: Option<String>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
|
|
@ -110,9 +110,9 @@ struct NativeResponseMessage {
|
|||
}
|
||||
|
||||
impl OpenAiProvider {
|
||||
pub fn new(api_key: Option<&str>) -> Self {
|
||||
pub fn new(credential: Option<&str>) -> Self {
|
||||
Self {
|
||||
api_key: api_key.map(ToString::to_string),
|
||||
credential: credential.map(ToString::to_string),
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
|
|
@ -232,7 +232,7 @@ impl Provider for OpenAiProvider {
|
|||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
|
||||
})?;
|
||||
|
||||
|
|
@ -259,7 +259,7 @@ impl Provider for OpenAiProvider {
|
|||
let response = self
|
||||
.client
|
||||
.post("https://api.openai.com/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?;
|
||||
|
|
@ -284,7 +284,7 @@ impl Provider for OpenAiProvider {
|
|||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
|
||||
})?;
|
||||
|
||||
|
|
@ -300,7 +300,7 @@ impl Provider for OpenAiProvider {
|
|||
let response = self
|
||||
.client
|
||||
.post("https://api.openai.com/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.json(&native_request)
|
||||
.send()
|
||||
.await?;
|
||||
|
|
@ -330,20 +330,20 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn creates_with_key() {
|
||||
let p = OpenAiProvider::new(Some("sk-proj-abc123"));
|
||||
assert_eq!(p.api_key.as_deref(), Some("sk-proj-abc123"));
|
||||
let p = OpenAiProvider::new(Some("openai-test-credential"));
|
||||
assert_eq!(p.credential.as_deref(), Some("openai-test-credential"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_without_key() {
|
||||
let p = OpenAiProvider::new(None);
|
||||
assert!(p.api_key.is_none());
|
||||
assert!(p.credential.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_with_empty_key() {
|
||||
let p = OpenAiProvider::new(Some(""));
|
||||
assert_eq!(p.api_key.as_deref(), Some(""));
|
||||
assert_eq!(p.credential.as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use reqwest::Client;
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct OpenRouterProvider {
|
||||
api_key: Option<String>,
|
||||
credential: Option<String>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
|
|
@ -110,9 +110,9 @@ struct NativeResponseMessage {
|
|||
}
|
||||
|
||||
impl OpenRouterProvider {
|
||||
pub fn new(api_key: Option<&str>) -> Self {
|
||||
pub fn new(credential: Option<&str>) -> Self {
|
||||
Self {
|
||||
api_key: api_key.map(ToString::to_string),
|
||||
credential: credential.map(ToString::to_string),
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
|
|
@ -232,10 +232,10 @@ impl Provider for OpenRouterProvider {
|
|||
async fn warmup(&self) -> anyhow::Result<()> {
|
||||
// Hit a lightweight endpoint to establish TLS + HTTP/2 connection pool.
|
||||
// This prevents the first real chat request from timing out on cold start.
|
||||
if let Some(api_key) = self.api_key.as_ref() {
|
||||
if let Some(credential) = self.credential.as_ref() {
|
||||
self.client
|
||||
.get("https://openrouter.ai/api/v1/auth/key")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
|
@ -250,7 +250,7 @@ impl Provider for OpenRouterProvider {
|
|||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref()
|
||||
let credential = self.credential.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."))?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
|
|
@ -276,7 +276,7 @@ impl Provider for OpenRouterProvider {
|
|||
let response = self
|
||||
.client
|
||||
.post("https://openrouter.ai/api/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.header(
|
||||
"HTTP-Referer",
|
||||
"https://github.com/theonlyhennygod/zeroclaw",
|
||||
|
|
@ -306,7 +306,7 @@ impl Provider for OpenRouterProvider {
|
|||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref()
|
||||
let credential = self.credential.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."))?;
|
||||
|
||||
let api_messages: Vec<Message> = messages
|
||||
|
|
@ -326,7 +326,7 @@ impl Provider for OpenRouterProvider {
|
|||
let response = self
|
||||
.client
|
||||
.post("https://openrouter.ai/api/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.header(
|
||||
"HTTP-Referer",
|
||||
"https://github.com/theonlyhennygod/zeroclaw",
|
||||
|
|
@ -356,7 +356,7 @@ impl Provider for OpenRouterProvider {
|
|||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."
|
||||
)
|
||||
|
|
@ -374,7 +374,7 @@ impl Provider for OpenRouterProvider {
|
|||
let response = self
|
||||
.client
|
||||
.post("https://openrouter.ai/api/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.header(
|
||||
"HTTP-Referer",
|
||||
"https://github.com/theonlyhennygod/zeroclaw",
|
||||
|
|
@ -494,14 +494,17 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn creates_with_key() {
|
||||
let provider = OpenRouterProvider::new(Some("sk-or-123"));
|
||||
assert_eq!(provider.api_key.as_deref(), Some("sk-or-123"));
|
||||
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"));
|
||||
assert_eq!(
|
||||
provider.credential.as_deref(),
|
||||
Some("openrouter-test-credential")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_without_key() {
|
||||
let provider = OpenRouterProvider::new(None);
|
||||
assert!(provider.api_key.is_none());
|
||||
assert!(provider.credential.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue