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 <[email protected]>
This commit is contained in:
Zaki Manian
2026-03-01 08:33:58 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent f62937d482
commit ec31e83a7d
3 changed files with 42 additions and 24 deletions
+32 -19
View File
@@ -129,6 +129,7 @@ impl SecretsStore for PostgresSecretsStore {
}
async fn get(&self, user_id: &str, name: &str) -> Result<Secret, SecretError> {
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<bool, SecretError> {
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<bool, SecretError> {
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<bool, SecretError> {
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<Secret, SecretError> {
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<bool, SecretError> {
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<bool, SecretError> {
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<bool, SecretError> {
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<Secret, SecretError> {
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<Vec<SecretRef>, 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<bool, SecretError> {
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);
}
+4 -1
View File
@@ -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<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
name: name.into().to_lowercase(),
value: SecretString::from(value.into()),
provider: None,
expires_at: None,
+6 -4
View File
@@ -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;
}