From ec31e83a7d9fdc1d3c0bb1b863118283c87b6d04 Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Sun, 1 Mar 2026 00:33:58 -0800 Subject: [PATCH] fix: normalize secret names to lowercase for case-insensitive matching (#413) (#431) The Slack channel capabilities.json declares secret names in lowercase (slack_bot_token) but the web UI stored them in UPPERCASE (SLACK_BOT_TOKEN), causing credential injection to fail with "not_authed". Changes: - CreateSecretParams::new() normalizes name to lowercase on creation - All SecretsStore lookups (get, exists, delete, is_accessible) now lowercase the name parameter before querying - Applied to all three backends: PostgreSQL, libSQL, InMemory - CredentialInjector::is_secret_allowed() uses case-insensitive comparison Co-authored-by: Claude Opus 4.6 --- src/secrets/store.rs | 51 +++++++++++++++++---------- src/secrets/types.rs | 5 ++- src/tools/wasm/credential_injector.rs | 10 +++--- 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/secrets/store.rs b/src/secrets/store.rs index 4fbeddd4..0bc180a7 100644 --- a/src/secrets/store.rs +++ b/src/secrets/store.rs @@ -129,6 +129,7 @@ impl SecretsStore for PostgresSecretsStore { } async fn get(&self, user_id: &str, name: &str) -> Result { + let name = name.to_lowercase(); let client = self .pool .get() @@ -176,6 +177,7 @@ impl SecretsStore for PostgresSecretsStore { } async fn exists(&self, user_id: &str, name: &str) -> Result { + let name = name.to_lowercase(); let client = self .pool .get() @@ -218,6 +220,7 @@ impl SecretsStore for PostgresSecretsStore { } async fn delete(&self, user_id: &str, name: &str) -> Result { + let name = name.to_lowercase(); let client = self .pool .get() @@ -263,21 +266,23 @@ impl SecretsStore for PostgresSecretsStore { secret_name: &str, allowed_secrets: &[String], ) -> Result { + let secret_name_lower = secret_name.to_lowercase(); // First check if the secret exists - if !self.exists(user_id, secret_name).await? { + if !self.exists(user_id, &secret_name_lower).await? { return Ok(false); } // Check if secret is in the allowed list // Supports glob patterns: "openai_*" matches "openai_api_key" for pattern in allowed_secrets { - if pattern == secret_name { + let pattern_lower = pattern.to_lowercase(); + if pattern_lower == secret_name_lower { return Ok(true); } // Simple glob: * matches any suffix - if let Some(prefix) = pattern.strip_suffix('*') - && secret_name.starts_with(prefix) + if let Some(prefix) = pattern_lower.strip_suffix('*') + && secret_name_lower.starts_with(prefix) { return Ok(true); } @@ -415,6 +420,7 @@ impl SecretsStore for LibSqlSecretsStore { } async fn get(&self, user_id: &str, name: &str) -> Result { + let name = name.to_lowercase(); let conn = self.connect().await?; let mut rows = conn .query( @@ -424,7 +430,7 @@ impl SecretsStore for LibSqlSecretsStore { FROM secrets WHERE user_id = ?1 AND name = ?2 "#, - libsql::params![user_id, name], + libsql::params![user_id, name.as_str()], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; @@ -460,11 +466,12 @@ impl SecretsStore for LibSqlSecretsStore { } async fn exists(&self, user_id: &str, name: &str) -> Result { + let name = name.to_lowercase(); let conn = self.connect().await?; let mut rows = conn .query( "SELECT 1 FROM secrets WHERE user_id = ?1 AND name = ?2", - libsql::params![user_id, name], + libsql::params![user_id, name.as_str()], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; @@ -501,11 +508,12 @@ impl SecretsStore for LibSqlSecretsStore { } async fn delete(&self, user_id: &str, name: &str) -> Result { + let name = name.to_lowercase(); let conn = self.connect().await?; let affected = conn .execute( "DELETE FROM secrets WHERE user_id = ?1 AND name = ?2", - libsql::params![user_id, name], + libsql::params![user_id, name.as_str()], ) .await .map_err(|e| SecretError::Database(e.to_string()))?; @@ -537,17 +545,19 @@ impl SecretsStore for LibSqlSecretsStore { secret_name: &str, allowed_secrets: &[String], ) -> Result { - if !self.exists(user_id, secret_name).await? { + let secret_name_lower = secret_name.to_lowercase(); + if !self.exists(user_id, &secret_name_lower).await? { return Ok(false); } for pattern in allowed_secrets { - if pattern == secret_name { + let pattern_lower = pattern.to_lowercase(); + if pattern_lower == secret_name_lower { return Ok(true); } - if let Some(prefix) = pattern.strip_suffix('*') - && secret_name.starts_with(prefix) + if let Some(prefix) = pattern_lower.strip_suffix('*') + && secret_name_lower.starts_with(prefix) { return Ok(true); } @@ -701,13 +711,14 @@ pub mod in_memory { } async fn get(&self, user_id: &str, name: &str) -> Result { + let name = name.to_lowercase(); let secret = self .secrets .read() .await - .get(&(user_id.to_string(), name.to_string())) + .get(&(user_id.to_string(), name.clone())) .cloned() - .ok_or_else(|| SecretError::NotFound(name.to_string()))?; + .ok_or_else(|| SecretError::NotFound(name.clone()))?; if let Some(expires_at) = secret.expires_at && expires_at < Utc::now() @@ -733,7 +744,7 @@ pub mod in_memory { .secrets .read() .await - .contains_key(&(user_id.to_string(), name.to_string()))) + .contains_key(&(user_id.to_string(), name.to_lowercase()))) } async fn list(&self, user_id: &str) -> Result, SecretError> { @@ -755,7 +766,7 @@ pub mod in_memory { .secrets .write() .await - .remove(&(user_id.to_string(), name.to_string())) + .remove(&(user_id.to_string(), name.to_lowercase())) .is_some()) } @@ -769,15 +780,17 @@ pub mod in_memory { secret_name: &str, allowed_secrets: &[String], ) -> Result { - if !self.exists(user_id, secret_name).await? { + let secret_name_lower = secret_name.to_lowercase(); + if !self.exists(user_id, &secret_name_lower).await? { return Ok(false); } for pattern in allowed_secrets { - if pattern == secret_name { + let pattern_lower = pattern.to_lowercase(); + if pattern_lower == secret_name_lower { return Ok(true); } - if let Some(prefix) = pattern.strip_suffix('*') - && secret_name.starts_with(prefix) + if let Some(prefix) = pattern_lower.strip_suffix('*') + && secret_name_lower.starts_with(prefix) { return Ok(true); } diff --git a/src/secrets/types.rs b/src/secrets/types.rs index 3d82a334..ae3edb60 100644 --- a/src/secrets/types.rs +++ b/src/secrets/types.rs @@ -171,9 +171,12 @@ pub struct CreateSecretParams { } impl CreateSecretParams { + /// Create new secret params. The name is normalized to lowercase for + /// case-insensitive matching (capabilities.json uses lowercase names + /// like `slack_bot_token`, but UIs may store `SLACK_BOT_TOKEN`). pub fn new(name: impl Into, value: impl Into) -> Self { Self { - name: name.into(), + name: name.into().to_lowercase(), value: SecretString::from(value.into()), provider: None, expires_at: None, diff --git a/src/tools/wasm/credential_injector.rs b/src/tools/wasm/credential_injector.rs index fd25bfcd..7f735a6d 100644 --- a/src/tools/wasm/credential_injector.rs +++ b/src/tools/wasm/credential_injector.rs @@ -235,14 +235,16 @@ impl CredentialInjector { Ok(result) } - /// Check if a secret name is in the allowed list. + /// Check if a secret name is in the allowed list (case-insensitive). fn is_secret_allowed(&self, name: &str) -> bool { + let name_lower = name.to_lowercase(); for pattern in &self.allowed_secrets { - if pattern == name { + let pattern_lower = pattern.to_lowercase(); + if pattern_lower == name_lower { return true; } - if let Some(prefix) = pattern.strip_suffix('*') - && name.starts_with(prefix) + if let Some(prefix) = pattern_lower.strip_suffix('*') + && name_lower.starts_with(prefix) { return true; }