hardening: tighten gateway auth and secret lifecycle handling

This commit is contained in:
Chummy 2026-02-25 18:11:17 +08:00 committed by Chum Yin
parent 2ecfa0d269
commit d5cd65bc4f
6 changed files with 960 additions and 53 deletions

View File

@ -86,7 +86,7 @@ impl ProviderApiMode {
/// Top-level ZeroClaw configuration, loaded from `config.toml`.
///
/// Resolution order: `ZEROCLAW_WORKSPACE` env → `active_workspace.toml` marker → `~/.zeroclaw/config.toml`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
pub struct Config {
/// Workspace directory - computed from home, not serialized
#[serde(skip)]
@ -293,7 +293,7 @@ pub struct ProviderConfig {
// ── Delegate Agents ──────────────────────────────────────────────
/// Configuration for a delegate sub-agent used by the `delegate` tool.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
pub struct DelegateAgentConfig {
/// Provider name (e.g. "ollama", "openrouter", "anthropic")
pub provider: String,
@ -330,6 +330,79 @@ fn default_max_tool_iterations() -> usize {
10
}
impl std::fmt::Debug for DelegateAgentConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DelegateAgentConfig")
.field("provider", &self.provider)
.field("model", &self.model)
.field("system_prompt", &self.system_prompt)
.field(
"api_key",
&self.api_key.as_ref().map(|_| "***REDACTED***".to_string()),
)
.field("temperature", &self.temperature)
.field("max_depth", &self.max_depth)
.field("agentic", &self.agentic)
.field("allowed_tools", &self.allowed_tools)
.field("max_iterations", &self.max_iterations)
.finish()
}
}
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let model_provider_ids: Vec<&str> =
self.model_providers.keys().map(String::as_str).collect();
let delegate_agent_ids: Vec<&str> = self.agents.keys().map(String::as_str).collect();
let enabled_channel_count = [
self.channels_config.telegram.is_some(),
self.channels_config.discord.is_some(),
self.channels_config.slack.is_some(),
self.channels_config.mattermost.is_some(),
self.channels_config.webhook.is_some(),
self.channels_config.imessage.is_some(),
self.channels_config.matrix.is_some(),
self.channels_config.signal.is_some(),
self.channels_config.whatsapp.is_some(),
self.channels_config.linq.is_some(),
self.channels_config.wati.is_some(),
self.channels_config.nextcloud_talk.is_some(),
self.channels_config.email.is_some(),
self.channels_config.irc.is_some(),
self.channels_config.lark.is_some(),
self.channels_config.feishu.is_some(),
self.channels_config.dingtalk.is_some(),
self.channels_config.qq.is_some(),
self.channels_config.nostr.is_some(),
self.channels_config.clawdtalk.is_some(),
]
.into_iter()
.filter(|enabled| *enabled)
.count();
f.debug_struct("Config")
.field("workspace_dir", &self.workspace_dir)
.field("config_path", &self.config_path)
.field(
"api_key",
&self.api_key.as_ref().map(|_| "***REDACTED***".to_string()),
)
.field("api_url_configured", &self.api_url.is_some())
.field("default_provider", &self.default_provider)
.field("provider_api", &self.provider_api)
.field("default_model", &self.default_model)
.field("model_providers", &model_provider_ids)
.field("default_temperature", &self.default_temperature)
.field("model_routes_count", &self.model_routes.len())
.field("embedding_routes_count", &self.embedding_routes.len())
.field("delegate_agents", &delegate_agent_ids)
.field("cli_channel_enabled", &self.channels_config.cli)
.field("enabled_channels_count", &enabled_channel_count)
.field("sensitive_sections", &"***REDACTED***")
.finish_non_exhaustive()
}
}
// ── Hardware Config (wizard-driven) ─────────────────────────────
/// Hardware transport mode.
@ -2139,7 +2212,7 @@ pub struct AutonomyConfig {
///
/// When a tool is listed here, non-CLI channels will not expose it to the
/// model in tool specs.
#[serde(default)]
#[serde(default = "default_non_cli_excluded_tools")]
pub non_cli_excluded_tools: Vec<String>,
}
@ -2151,6 +2224,35 @@ fn default_always_ask() -> Vec<String> {
vec![]
}
fn default_non_cli_excluded_tools() -> Vec<String> {
[
"shell",
"file_write",
"file_edit",
"git_operations",
"browser",
"browser_open",
"http_request",
"schedule",
"cron_add",
"cron_remove",
"cron_update",
"cron_run",
"memory_store",
"memory_forget",
"proxy_config",
"model_routing_config",
"pushover",
"composio",
"delegate",
"screenshot",
"image_info",
]
.into_iter()
.map(std::string::ToString::to_string)
.collect()
}
fn is_valid_env_var_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
@ -2208,7 +2310,7 @@ impl Default for AutonomyConfig {
auto_approve: default_auto_approve(),
always_ask: default_always_ask(),
allowed_roots: Vec::new(),
non_cli_excluded_tools: Vec::new(),
non_cli_excluded_tools: default_non_cli_excluded_tools(),
}
}
}
@ -4177,6 +4279,21 @@ fn decrypt_secret(
Ok(())
}
fn decrypt_vec_secrets(
store: &crate::security::SecretStore,
values: &mut [String],
field_name: &str,
) -> Result<()> {
for (idx, value) in values.iter_mut().enumerate() {
if crate::security::SecretStore::is_encrypted(value) {
*value = store
.decrypt(value)
.with_context(|| format!("Failed to decrypt {field_name}[{idx}]"))?;
}
}
Ok(())
}
fn encrypt_optional_secret(
store: &crate::security::SecretStore,
value: &mut Option<String>,
@ -4207,6 +4324,345 @@ fn encrypt_secret(
Ok(())
}
fn encrypt_vec_secrets(
store: &crate::security::SecretStore,
values: &mut [String],
field_name: &str,
) -> Result<()> {
for (idx, value) in values.iter_mut().enumerate() {
if !crate::security::SecretStore::is_encrypted(value) {
*value = store
.encrypt(value)
.with_context(|| format!("Failed to encrypt {field_name}[{idx}]"))?;
}
}
Ok(())
}
fn decrypt_channel_secrets(
store: &crate::security::SecretStore,
channels: &mut ChannelsConfig,
) -> Result<()> {
if let Some(ref mut telegram) = channels.telegram {
decrypt_secret(
store,
&mut telegram.bot_token,
"config.channels_config.telegram.bot_token",
)?;
}
if let Some(ref mut discord) = channels.discord {
decrypt_secret(
store,
&mut discord.bot_token,
"config.channels_config.discord.bot_token",
)?;
}
if let Some(ref mut slack) = channels.slack {
decrypt_secret(
store,
&mut slack.bot_token,
"config.channels_config.slack.bot_token",
)?;
decrypt_optional_secret(
store,
&mut slack.app_token,
"config.channels_config.slack.app_token",
)?;
}
if let Some(ref mut mattermost) = channels.mattermost {
decrypt_secret(
store,
&mut mattermost.bot_token,
"config.channels_config.mattermost.bot_token",
)?;
}
if let Some(ref mut webhook) = channels.webhook {
decrypt_optional_secret(
store,
&mut webhook.secret,
"config.channels_config.webhook.secret",
)?;
}
if let Some(ref mut matrix) = channels.matrix {
decrypt_secret(
store,
&mut matrix.access_token,
"config.channels_config.matrix.access_token",
)?;
}
if let Some(ref mut whatsapp) = channels.whatsapp {
decrypt_optional_secret(
store,
&mut whatsapp.access_token,
"config.channels_config.whatsapp.access_token",
)?;
decrypt_optional_secret(
store,
&mut whatsapp.app_secret,
"config.channels_config.whatsapp.app_secret",
)?;
decrypt_optional_secret(
store,
&mut whatsapp.verify_token,
"config.channels_config.whatsapp.verify_token",
)?;
}
if let Some(ref mut linq) = channels.linq {
decrypt_secret(
store,
&mut linq.api_token,
"config.channels_config.linq.api_token",
)?;
decrypt_optional_secret(
store,
&mut linq.signing_secret,
"config.channels_config.linq.signing_secret",
)?;
}
if let Some(ref mut nextcloud) = channels.nextcloud_talk {
decrypt_secret(
store,
&mut nextcloud.app_token,
"config.channels_config.nextcloud_talk.app_token",
)?;
decrypt_optional_secret(
store,
&mut nextcloud.webhook_secret,
"config.channels_config.nextcloud_talk.webhook_secret",
)?;
}
if let Some(ref mut irc) = channels.irc {
decrypt_optional_secret(
store,
&mut irc.server_password,
"config.channels_config.irc.server_password",
)?;
decrypt_optional_secret(
store,
&mut irc.nickserv_password,
"config.channels_config.irc.nickserv_password",
)?;
decrypt_optional_secret(
store,
&mut irc.sasl_password,
"config.channels_config.irc.sasl_password",
)?;
}
if let Some(ref mut lark) = channels.lark {
decrypt_secret(
store,
&mut lark.app_secret,
"config.channels_config.lark.app_secret",
)?;
decrypt_optional_secret(
store,
&mut lark.encrypt_key,
"config.channels_config.lark.encrypt_key",
)?;
decrypt_optional_secret(
store,
&mut lark.verification_token,
"config.channels_config.lark.verification_token",
)?;
}
if let Some(ref mut dingtalk) = channels.dingtalk {
decrypt_secret(
store,
&mut dingtalk.client_secret,
"config.channels_config.dingtalk.client_secret",
)?;
}
if let Some(ref mut qq) = channels.qq {
decrypt_secret(
store,
&mut qq.app_secret,
"config.channels_config.qq.app_secret",
)?;
}
if let Some(ref mut nostr) = channels.nostr {
decrypt_secret(
store,
&mut nostr.private_key,
"config.channels_config.nostr.private_key",
)?;
}
if let Some(ref mut clawdtalk) = channels.clawdtalk {
decrypt_secret(
store,
&mut clawdtalk.api_key,
"config.channels_config.clawdtalk.api_key",
)?;
decrypt_optional_secret(
store,
&mut clawdtalk.webhook_secret,
"config.channels_config.clawdtalk.webhook_secret",
)?;
}
Ok(())
}
fn encrypt_channel_secrets(
store: &crate::security::SecretStore,
channels: &mut ChannelsConfig,
) -> Result<()> {
if let Some(ref mut telegram) = channels.telegram {
encrypt_secret(
store,
&mut telegram.bot_token,
"config.channels_config.telegram.bot_token",
)?;
}
if let Some(ref mut discord) = channels.discord {
encrypt_secret(
store,
&mut discord.bot_token,
"config.channels_config.discord.bot_token",
)?;
}
if let Some(ref mut slack) = channels.slack {
encrypt_secret(
store,
&mut slack.bot_token,
"config.channels_config.slack.bot_token",
)?;
encrypt_optional_secret(
store,
&mut slack.app_token,
"config.channels_config.slack.app_token",
)?;
}
if let Some(ref mut mattermost) = channels.mattermost {
encrypt_secret(
store,
&mut mattermost.bot_token,
"config.channels_config.mattermost.bot_token",
)?;
}
if let Some(ref mut webhook) = channels.webhook {
encrypt_optional_secret(
store,
&mut webhook.secret,
"config.channels_config.webhook.secret",
)?;
}
if let Some(ref mut matrix) = channels.matrix {
encrypt_secret(
store,
&mut matrix.access_token,
"config.channels_config.matrix.access_token",
)?;
}
if let Some(ref mut whatsapp) = channels.whatsapp {
encrypt_optional_secret(
store,
&mut whatsapp.access_token,
"config.channels_config.whatsapp.access_token",
)?;
encrypt_optional_secret(
store,
&mut whatsapp.app_secret,
"config.channels_config.whatsapp.app_secret",
)?;
encrypt_optional_secret(
store,
&mut whatsapp.verify_token,
"config.channels_config.whatsapp.verify_token",
)?;
}
if let Some(ref mut linq) = channels.linq {
encrypt_secret(
store,
&mut linq.api_token,
"config.channels_config.linq.api_token",
)?;
encrypt_optional_secret(
store,
&mut linq.signing_secret,
"config.channels_config.linq.signing_secret",
)?;
}
if let Some(ref mut nextcloud) = channels.nextcloud_talk {
encrypt_secret(
store,
&mut nextcloud.app_token,
"config.channels_config.nextcloud_talk.app_token",
)?;
encrypt_optional_secret(
store,
&mut nextcloud.webhook_secret,
"config.channels_config.nextcloud_talk.webhook_secret",
)?;
}
if let Some(ref mut irc) = channels.irc {
encrypt_optional_secret(
store,
&mut irc.server_password,
"config.channels_config.irc.server_password",
)?;
encrypt_optional_secret(
store,
&mut irc.nickserv_password,
"config.channels_config.irc.nickserv_password",
)?;
encrypt_optional_secret(
store,
&mut irc.sasl_password,
"config.channels_config.irc.sasl_password",
)?;
}
if let Some(ref mut lark) = channels.lark {
encrypt_secret(
store,
&mut lark.app_secret,
"config.channels_config.lark.app_secret",
)?;
encrypt_optional_secret(
store,
&mut lark.encrypt_key,
"config.channels_config.lark.encrypt_key",
)?;
encrypt_optional_secret(
store,
&mut lark.verification_token,
"config.channels_config.lark.verification_token",
)?;
}
if let Some(ref mut dingtalk) = channels.dingtalk {
encrypt_secret(
store,
&mut dingtalk.client_secret,
"config.channels_config.dingtalk.client_secret",
)?;
}
if let Some(ref mut qq) = channels.qq {
encrypt_secret(
store,
&mut qq.app_secret,
"config.channels_config.qq.app_secret",
)?;
}
if let Some(ref mut nostr) = channels.nostr {
encrypt_secret(
store,
&mut nostr.private_key,
"config.channels_config.nostr.private_key",
)?;
}
if let Some(ref mut clawdtalk) = channels.clawdtalk {
encrypt_secret(
store,
&mut clawdtalk.api_key,
"config.channels_config.clawdtalk.api_key",
)?;
encrypt_optional_secret(
store,
&mut clawdtalk.webhook_secret,
"config.channels_config.clawdtalk.webhook_secret",
)?;
}
Ok(())
}
fn config_dir_creation_error(path: &Path) -> String {
format!(
"Failed to create config directory: {}. If running as an OpenRC service, \
@ -4351,18 +4807,22 @@ impl Config {
&mut config.storage.provider.config.db_url,
"config.storage.provider.config.db_url",
)?;
decrypt_vec_secrets(
&store,
&mut config.reliability.api_keys,
"config.reliability.api_keys",
)?;
decrypt_vec_secrets(
&store,
&mut config.gateway.paired_tokens,
"config.gateway.paired_tokens",
)?;
for agent in config.agents.values_mut() {
decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
}
if let Some(ref mut ns) = config.channels_config.nostr {
decrypt_secret(
&store,
&mut ns.private_key,
"config.channels_config.nostr.private_key",
)?;
}
decrypt_channel_secrets(&store, &mut config.channels_config)?;
config.apply_env_overrides();
config.validate()?;
@ -4505,6 +4965,26 @@ impl Config {
);
}
}
let mut seen_non_cli_excluded = std::collections::HashSet::new();
for (i, tool_name) in self.autonomy.non_cli_excluded_tools.iter().enumerate() {
let normalized = tool_name.trim();
if normalized.is_empty() {
anyhow::bail!("autonomy.non_cli_excluded_tools[{i}] must not be empty");
}
if !normalized
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
anyhow::bail!(
"autonomy.non_cli_excluded_tools[{i}] contains invalid characters: {normalized}"
);
}
if !seen_non_cli_excluded.insert(normalized.to_string()) {
anyhow::bail!(
"autonomy.non_cli_excluded_tools contains duplicate entry: {normalized}"
);
}
}
// Security OTP / estop
if self.security.otp.token_ttl_secs == 0 {
@ -4999,18 +5479,22 @@ impl Config {
&mut config_to_save.storage.provider.config.db_url,
"config.storage.provider.config.db_url",
)?;
encrypt_vec_secrets(
&store,
&mut config_to_save.reliability.api_keys,
"config.reliability.api_keys",
)?;
encrypt_vec_secrets(
&store,
&mut config_to_save.gateway.paired_tokens,
"config.gateway.paired_tokens",
)?;
for agent in config_to_save.agents.values_mut() {
encrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
}
if let Some(ref mut ns) = config_to_save.channels_config.nostr {
encrypt_secret(
&store,
&mut ns.private_key,
"config.channels_config.nostr.private_key",
)?;
}
encrypt_channel_secrets(&store, &mut config_to_save.channels_config)?;
let toml_str =
toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
@ -5046,6 +5530,18 @@ impl Config {
temp_path.display()
)
})?;
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
fs::set_permissions(&temp_path, Permissions::from_mode(0o600))
.await
.with_context(|| {
format!(
"Failed to set secure permissions on temporary config file: {}",
temp_path.display()
)
})?;
}
temp_file
.write_all(toml_str.as_bytes())
.await
@ -5081,15 +5577,14 @@ impl Config {
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
if let Err(err) =
fs::set_permissions(&self.config_path, Permissions::from_mode(0o600)).await
{
tracing::warn!(
"Failed to harden config permissions to 0600 at {}: {}",
self.config_path.display(),
err
);
}
fs::set_permissions(&self.config_path, Permissions::from_mode(0o600))
.await
.with_context(|| {
format!(
"Failed to enforce secure permissions on config file: {}",
self.config_path.display()
)
})?;
}
sync_directory(parent_dir).await?;
@ -5160,6 +5655,60 @@ mod tests {
assert!(c.config_path.to_string_lossy().contains("config.toml"));
}
#[test]
async fn config_debug_redacts_sensitive_values() {
let mut config = Config::default();
config.workspace_dir = PathBuf::from("/tmp/workspace");
config.config_path = PathBuf::from("/tmp/config.toml");
config.api_key = Some("root-credential".into());
config.storage.provider.config.db_url = Some("postgres://user:pw@host/db".into());
config.browser.computer_use.api_key = Some("browser-credential".into());
config.gateway.paired_tokens = vec!["zc_0123456789abcdef".into()];
config.channels_config.telegram = Some(TelegramConfig {
bot_token: "telegram-credential".into(),
allowed_users: Vec::new(),
stream_mode: StreamMode::Off,
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
});
config.agents.insert(
"worker".into(),
DelegateAgentConfig {
provider: "openrouter".into(),
model: "model-test".into(),
system_prompt: None,
api_key: Some("agent-credential".into()),
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
},
);
let debug_output = format!("{config:?}");
assert!(debug_output.contains("***REDACTED***"));
for secret in [
"root-credential",
"postgres://user:pw@host/db",
"browser-credential",
"zc_0123456789abcdef",
"telegram-credential",
"agent-credential",
] {
assert!(
!debug_output.contains(secret),
"debug output leaked secret value: {secret}"
);
}
assert!(!debug_output.contains("paired_tokens"));
assert!(!debug_output.contains("bot_token"));
assert!(!debug_output.contains("db_url"));
}
#[test]
async fn config_dir_creation_error_mentions_openrc_and_path() {
let msg = config_dir_creation_error(Path::new("/etc/zeroclaw"));
@ -5244,6 +5793,41 @@ mod tests {
assert!(a.require_approval_for_medium_risk);
assert!(a.block_high_risk_commands);
assert!(a.shell_env_passthrough.is_empty());
assert!(a.non_cli_excluded_tools.contains(&"shell".to_string()));
assert!(a.non_cli_excluded_tools.contains(&"delegate".to_string()));
}
#[test]
async fn autonomy_config_serde_defaults_non_cli_excluded_tools() {
let raw = r#"
level = "supervised"
workspace_only = true
allowed_commands = ["git"]
forbidden_paths = ["/etc"]
max_actions_per_hour = 20
max_cost_per_day_cents = 500
require_approval_for_medium_risk = true
block_high_risk_commands = true
shell_env_passthrough = []
auto_approve = ["file_read"]
always_ask = []
allowed_roots = []
"#;
let parsed: AutonomyConfig = toml::from_str(raw).unwrap();
assert!(parsed.non_cli_excluded_tools.contains(&"shell".to_string()));
assert!(parsed
.non_cli_excluded_tools
.contains(&"browser".to_string()));
}
#[test]
async fn config_validate_rejects_duplicate_non_cli_excluded_tools() {
let mut cfg = Config::default();
cfg.autonomy.non_cli_excluded_tools = vec!["shell".into(), "shell".into()];
let err = cfg.validate().unwrap_err();
assert!(err
.to_string()
.contains("autonomy.non_cli_excluded_tools contains duplicate entry"));
}
#[test]
@ -5708,6 +6292,16 @@ tool_dispatcher = "xml"
config.browser.computer_use.api_key = Some("browser-credential".into());
config.web_search.brave_api_key = Some("brave-credential".into());
config.storage.provider.config.db_url = Some("postgres://user:pw@host/db".into());
config.reliability.api_keys = vec!["backup-credential".into()];
config.gateway.paired_tokens = vec!["zc_0123456789abcdef".into()];
config.channels_config.telegram = Some(TelegramConfig {
bot_token: "telegram-credential".into(),
allowed_users: Vec::new(),
stream_mode: StreamMode::Off,
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
});
config.agents.insert(
"worker".into(),
@ -5775,6 +6369,27 @@ tool_dispatcher = "xml"
"postgres://user:pw@host/db"
);
let reliability_key = &stored.reliability.api_keys[0];
assert!(crate::security::SecretStore::is_encrypted(reliability_key));
assert_eq!(store.decrypt(reliability_key).unwrap(), "backup-credential");
let paired_token = &stored.gateway.paired_tokens[0];
assert!(crate::security::SecretStore::is_encrypted(paired_token));
assert_eq!(store.decrypt(paired_token).unwrap(), "zc_0123456789abcdef");
let telegram_token = stored
.channels_config
.telegram
.as_ref()
.unwrap()
.bot_token
.clone();
assert!(crate::security::SecretStore::is_encrypted(&telegram_token));
assert_eq!(
store.decrypt(&telegram_token).unwrap(),
"telegram-credential"
);
let _ = fs::remove_dir_all(&dir).await;
}

View File

@ -793,7 +793,36 @@ async fn handle_health(State(state): State<AppState>) -> impl IntoResponse {
const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8";
/// GET /metrics — Prometheus text exposition format
async fn handle_metrics(State(state): State<AppState>) -> impl IntoResponse {
async fn handle_metrics(
State(state): State<AppState>,
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> impl IntoResponse {
if state.pairing.require_pairing() {
let auth = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let token = auth.strip_prefix("Bearer ").unwrap_or("").trim();
if !state.pairing.is_authenticated(token) {
return (
StatusCode::UNAUTHORIZED,
[(header::CONTENT_TYPE, PROMETHEUS_CONTENT_TYPE)],
String::from(
"# unauthorized: provide Authorization: Bearer <token> for /metrics\n",
),
);
}
} else if !peer_addr.ip().is_loopback() {
return (
StatusCode::FORBIDDEN,
[(header::CONTENT_TYPE, PROMETHEUS_CONTENT_TYPE)],
String::from(
"# metrics disabled for non-loopback clients when pairing is not required\n",
),
);
}
let body = if let Some(prom) = state
.observer
.as_ref()
@ -1136,6 +1165,20 @@ async fn handle_webhook(
return (StatusCode::TOO_MANY_REQUESTS, Json(err));
}
// Require at least one auth layer for non-loopback traffic.
if !state.pairing.require_pairing()
&& state.webhook_secret_hash.is_none()
&& !peer_addr.ip().is_loopback()
{
tracing::warn!(
"Webhook: rejected unauthenticated non-loopback request (pairing disabled and no webhook secret configured)"
);
let err = serde_json::json!({
"error": "Unauthorized — configure pairing or X-Webhook-Secret for non-local webhook access"
});
return (StatusCode::UNAUTHORIZED, Json(err));
}
// ── Bearer token auth (pairing) ──
if state.pairing.require_pairing() {
let auth = headers
@ -1961,7 +2004,9 @@ mod tests {
event_tx: tokio::sync::broadcast::channel(16).0,
};
let response = handle_metrics(State(state)).await.into_response();
let response = handle_metrics(State(state), test_connect_info(), HeaderMap::new())
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
@ -2015,7 +2060,9 @@ mod tests {
event_tx: tokio::sync::broadcast::channel(16).0,
};
let response = handle_metrics(State(state)).await.into_response();
let response = handle_metrics(State(state), test_connect_info(), HeaderMap::new())
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
@ -2023,6 +2070,98 @@ mod tests {
assert!(text.contains("zeroclaw_heartbeat_ticks_total 1"));
}
#[tokio::test]
async fn metrics_endpoint_rejects_public_clients_when_pairing_is_disabled() {
let state = AppState {
config: Arc::new(Mutex::new(Config::default())),
provider: Arc::new(MockProvider::default()),
model: "test-model".into(),
temperature: 0.0,
mem: Arc::new(MockMemory),
auto_save: false,
webhook_secret_hash: None,
pairing: Arc::new(PairingGuard::new(false, &[])),
trust_forwarded_headers: false,
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)),
whatsapp: None,
whatsapp_app_secret: None,
linq: None,
linq_signing_secret: None,
nextcloud_talk: None,
nextcloud_talk_webhook_secret: None,
wati: None,
qq: None,
qq_webhook_enabled: false,
observer: Arc::new(crate::observability::NoopObserver),
tools_registry: Arc::new(Vec::new()),
tools_registry_exec: Arc::new(Vec::new()),
multimodal: crate::config::MultimodalConfig::default(),
max_tool_iterations: 10,
cost_tracker: None,
event_tx: tokio::sync::broadcast::channel(16).0,
};
let response = handle_metrics(State(state), test_public_connect_info(), HeaderMap::new())
.await
.into_response();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = response.into_body().collect().await.unwrap().to_bytes();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("non-loopback"));
}
#[tokio::test]
async fn metrics_endpoint_requires_bearer_token_when_pairing_is_enabled() {
let paired_token = "zc_test_token".to_string();
let state = AppState {
config: Arc::new(Mutex::new(Config::default())),
provider: Arc::new(MockProvider::default()),
model: "test-model".into(),
temperature: 0.0,
mem: Arc::new(MockMemory),
auto_save: false,
webhook_secret_hash: None,
pairing: Arc::new(PairingGuard::new(true, std::slice::from_ref(&paired_token))),
trust_forwarded_headers: false,
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)),
whatsapp: None,
whatsapp_app_secret: None,
linq: None,
linq_signing_secret: None,
nextcloud_talk: None,
nextcloud_talk_webhook_secret: None,
wati: None,
qq: None,
qq_webhook_enabled: false,
observer: Arc::new(crate::observability::NoopObserver),
tools_registry: Arc::new(Vec::new()),
tools_registry_exec: Arc::new(Vec::new()),
multimodal: crate::config::MultimodalConfig::default(),
max_tool_iterations: 10,
cost_tracker: None,
event_tx: tokio::sync::broadcast::channel(16).0,
};
let unauthorized =
handle_metrics(State(state.clone()), test_connect_info(), HeaderMap::new())
.await
.into_response();
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {paired_token}")).unwrap(),
);
let authorized = handle_metrics(State(state), test_connect_info(), headers)
.await
.into_response();
assert_eq!(authorized.status(), StatusCode::OK);
}
#[test]
fn gateway_rate_limiter_blocks_after_limit() {
let limiter = GatewayRateLimiter::new(2, 2, 100);
@ -2183,12 +2322,15 @@ mod tests {
let parsed: Config = toml::from_str(&saved).unwrap();
assert_eq!(parsed.gateway.paired_tokens.len(), 1);
let persisted = &parsed.gateway.paired_tokens[0];
assert_eq!(persisted.len(), 64);
assert!(persisted.chars().all(|c| c.is_ascii_hexdigit()));
assert!(crate::security::SecretStore::is_encrypted(persisted));
let store = crate::security::SecretStore::new(temp.path(), true);
let decrypted = store.decrypt(persisted).unwrap();
assert_eq!(decrypted.len(), 64);
assert!(decrypted.chars().all(|c| c.is_ascii_hexdigit()));
let in_memory = shared_config.lock();
assert_eq!(in_memory.gateway.paired_tokens.len(), 1);
assert_eq!(&in_memory.gateway.paired_tokens[0], persisted);
assert_eq!(&in_memory.gateway.paired_tokens[0], &decrypted);
}
#[test]
@ -2366,6 +2508,10 @@ mod tests {
ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 30_300)))
}
fn test_public_connect_info() -> ConnectInfo<SocketAddr> {
ConnectInfo(SocketAddr::from(([203, 0, 113, 10], 30_300)))
}
#[tokio::test]
async fn webhook_idempotency_skips_duplicate_provider_calls() {
let provider_impl = Arc::new(MockProvider::default());
@ -2433,6 +2579,56 @@ mod tests {
assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn webhook_rejects_public_traffic_without_auth_layers() {
let provider_impl = Arc::new(MockProvider::default());
let provider: Arc<dyn Provider> = provider_impl;
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
let state = AppState {
config: Arc::new(Mutex::new(Config::default())),
provider,
model: "test-model".into(),
temperature: 0.0,
mem: memory,
auto_save: false,
webhook_secret_hash: None,
pairing: Arc::new(PairingGuard::new(false, &[])),
trust_forwarded_headers: false,
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)),
whatsapp: None,
whatsapp_app_secret: None,
linq: None,
linq_signing_secret: None,
nextcloud_talk: None,
nextcloud_talk_webhook_secret: None,
wati: None,
qq: None,
qq_webhook_enabled: false,
observer: Arc::new(crate::observability::NoopObserver),
tools_registry: Arc::new(Vec::new()),
tools_registry_exec: Arc::new(Vec::new()),
multimodal: crate::config::MultimodalConfig::default(),
max_tool_iterations: 10,
cost_tracker: None,
event_tx: tokio::sync::broadcast::channel(16).0,
};
let response = handle_webhook(
State(state),
test_public_connect_info(),
HeaderMap::new(),
Ok(Json(WebhookBody {
message: "hello".into(),
})),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn node_control_returns_not_found_when_disabled() {
let provider: Arc<dyn Provider> = Arc::new(MockProvider::default());

View File

@ -719,9 +719,9 @@ fn token_end(input: &str, from: usize) -> usize {
/// Scrub known secret-like token prefixes from provider error strings.
///
/// Redacts tokens with prefixes like `sk-`, `xoxb-`, `xoxp-`, `ghp_`, `gho_`,
/// `ghu_`, and `github_pat_`.
/// `ghu_`, `github_pat_`, `AIza`, and `AKIA`.
pub fn scrub_secret_patterns(input: &str) -> String {
const PREFIXES: [&str; 7] = [
const PREFIXES: [&str; 9] = [
"sk-",
"xoxb-",
"xoxp-",
@ -729,6 +729,8 @@ pub fn scrub_secret_patterns(input: &str) -> String {
"gho_",
"ghu_",
"github_pat_",
"AIza",
"AKIA",
];
let mut scrubbed = input.to_string();
@ -2865,6 +2867,20 @@ mod tests {
assert_eq!(result, "failed: [REDACTED]");
}
#[test]
fn scrub_google_api_key_prefix() {
let input = "upstream returned key AIzaSyA8exampleToken123456";
let result = scrub_secret_patterns(input);
assert_eq!(result, "upstream returned key [REDACTED]");
}
#[test]
fn scrub_aws_access_key_prefix() {
let input = "credential leak AKIAIOSFODNN7EXAMPLE";
let result = scrub_secret_patterns(input);
assert_eq!(result, "credential leak [REDACTED]");
}
#[test]
fn routed_provider_accepts_per_route_max_tokens() {
let reliability = crate::config::ReliabilityConfig::default();

View File

@ -5,6 +5,7 @@ use parking_lot::Mutex;
use ring::hmac;
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
@ -76,7 +77,7 @@ impl OtpValidator {
.get(normalized)
.is_some_and(|expiry| *expiry >= now_secs)
{
return Ok(true);
return Ok(false);
}
}
@ -132,18 +133,43 @@ fn write_secret_file(path: &Path, value: &str) -> Result<()> {
}
let temp_path = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
fs::write(&temp_path, value).with_context(|| {
let mut temp_file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp_path)
.with_context(|| {
format!(
"Failed to create temporary OTP secret {}",
temp_path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
temp_file
.set_permissions(fs::Permissions::from_mode(0o600))
.with_context(|| {
format!(
"Failed to set permissions on temporary OTP secret {}",
temp_path.display()
)
})?;
}
temp_file.write_all(value.as_bytes()).with_context(|| {
format!(
"Failed to write temporary OTP secret {}",
temp_path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600));
}
temp_file.sync_all().with_context(|| {
format!(
"Failed to fsync temporary OTP secret {}",
temp_path.display()
)
})?;
drop(temp_file);
fs::rename(&temp_path, path).with_context(|| {
format!(
@ -151,6 +177,17 @@ fn write_secret_file(path: &Path, value: &str) -> Result<()> {
path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).with_context(|| {
format!(
"Failed to enforce permissions on OTP secret file {}",
path.display()
)
})?;
}
Ok(())
}
@ -275,6 +312,18 @@ mod tests {
assert!(validator.validate_at(&code, now).unwrap());
}
#[test]
fn replayed_totp_code_is_rejected() {
let dir = tempdir().unwrap();
let store = SecretStore::new(dir.path(), true);
let (validator, _) = OtpValidator::from_config(&test_config(), dir.path(), &store).unwrap();
let now = 1_700_000_000u64;
let code = validator.code_for_timestamp(now);
assert!(validator.validate_at(&code, now).unwrap());
assert!(!validator.validate_at(&code, now).unwrap());
}
#[test]
fn expired_totp_code_is_rejected() {
let dir = tempdir().unwrap();

View File

@ -190,9 +190,13 @@ impl PairingGuard {
// TODO: make this function the main one without spawning a task
let handle = tokio::task::spawn_blocking(move || this.try_pair_blocking(&code, &client_id));
handle
.await
.expect("failed to spawn blocking task this should not happen")
match handle.await {
Ok(result) => result,
Err(err) => {
tracing::error!("pairing worker task failed: {err}");
Ok(None)
}
}
}
/// Check if a bearer token is valid (compares against stored hashes).

View File

@ -24,6 +24,7 @@ use anyhow::{Context, Result};
use chacha20poly1305::aead::{Aead, KeyInit, OsRng};
use chacha20poly1305::{AeadCore, ChaCha20Poly1305, Key, Nonce};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
/// Length of the random encryption key in bytes (256-bit, matches `ChaCha20`).
@ -178,16 +179,42 @@ impl SecretStore {
if let Some(parent) = self.key_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&self.key_path, hex_encode(&key))
.context("Failed to write secret key file")?;
// Set restrictive permissions
#[cfg(unix)]
let key_hex = hex_encode(&key);
match fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&self.key_path)
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&self.key_path, fs::Permissions::from_mode(0o600))
.context("Failed to set key file permissions")?;
Ok(mut key_file) => {
// Set restrictive permissions before writing key bytes.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
key_file
.set_permissions(fs::Permissions::from_mode(0o600))
.context("Failed to set key file permissions")?;
}
key_file
.write_all(key_hex.as_bytes())
.context("Failed to write secret key file")?;
key_file
.sync_all()
.context("Failed to fsync secret key file")?;
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
// Concurrent creator won the race; read the existing key.
let hex_key = fs::read_to_string(&self.key_path)
.context("Failed to read concurrently created secret key file")?;
return hex_decode(hex_key.trim())
.context("Secret key file is corrupt after concurrent create");
}
Err(err) => {
return Err(err).context("Failed to create secret key file");
}
}
#[cfg(windows)]
{
// On Windows, use icacls to restrict permissions to current user only