From dc7d9cce34868f5f1083dbf8c0acf78640fc9ab1 Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Sun, 1 Mar 2026 19:04:37 -0800 Subject: [PATCH] fix(channels): add host-based credential injection to WASM channel wrapper (#421) * fix(channels): add host-based credential injection to WASM channel wrapper The channel WASM wrapper was missing the host-based credential injection that the tools wrapper implements. The `credentials` block in channel capabilities files was dead code: Slack's `on_respond` sends requests with no Authorization header, expecting the host to inject the bot token based on `host_patterns`, but the host never did. This caused Slack (and any channel relying on capabilities-declared credentials) to fail all outbound API calls with `not_authed`. Changes: - Add `ResolvedHostCredential` struct mirroring the tools wrapper - Add `host_credentials` field to `ChannelStoreData` - Add `inject_host_credentials()` method on `ChannelStoreData` - Update `redact_credentials()` to also scrub host-injected secret values - Add `secrets_store` field to `WasmChannel` + `with_secrets_store()` builder - Add `resolve_channel_host_credentials()` async helper that decrypts capabilities-declared credentials before each WASM callback - Update `create_store()` and all `call_on_*` / `execute_status` / `execute_poll` call sites to pre-resolve and pass host credentials - Fix leak scan ordering: scan runs on WASM-provided values BEFORE host credential injection, preventing false-positive blocks on injected Bearer tokens (e.g. xoxb- Slack tokens) - Make `credential_injector` module pub(crate) so channels can reuse `inject_credential` and `host_matches_pattern` Fixes #389, root cause of #413 Co-Authored-By: Claude Sonnet 4.6 * fix(wasm): redact URL-encoded credentials, use url::Url, derive Clone Address review feedback on PR #421: 1. Security: redact_credentials now scrubs URL-encoded forms of secrets in addition to raw values, preventing exfiltration via encoded representations in error strings from reqwest 2. Use url::Url::query_pairs_mut() for query parameter injection instead of manual string manipulation, improving robustness with malformed URLs 3. Derive Clone on ResolvedHostCredential and simplify the per-tick clone in the status repeater loop Co-Authored-By: Claude Opus 4.6 * style: cargo fmt Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Sprite Co-authored-by: Claude Sonnet 4.6 --- src/channels/wasm/wrapper.rs | 303 ++++++++++++++++++++++++++++++++++- src/tools/wasm/mod.rs | 2 +- 2 files changed, 300 insertions(+), 5 deletions(-) diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index a29da1e7..9d4d298a 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -52,8 +52,12 @@ use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, use crate::error::ChannelError; use crate::pairing::PairingStore; use crate::safety::LeakDetector; +use crate::secrets::SecretsStore; use crate::tools::wasm::LogLevel; use crate::tools::wasm::WasmResourceLimiter; +use crate::tools::wasm::credential_injector::{ + InjectedCredentials, host_matches_pattern, inject_credential, +}; // Generate component model bindings from the WIT file wasmtime::component::bindgen!({ @@ -65,6 +69,23 @@ wasmtime::component::bindgen!({ }, }); +/// Pre-resolved credential for host-based injection. +/// +/// Built before each WASM execution by decrypting secrets from the store. +/// Applied per-request by matching the URL host against `host_patterns`. +/// WASM channels never see the raw secret values. +#[derive(Clone)] +struct ResolvedHostCredential { + /// Host patterns this credential applies to (e.g., "api.slack.com"). + host_patterns: Vec, + /// Headers to add to matching requests (e.g., "Authorization: Bearer ..."). + headers: HashMap, + /// Query parameters to add to matching requests. + query_params: HashMap, + /// Raw secret value for redaction in error messages. + secret_value: String, +} + /// Store data for WASM channel execution. /// /// Contains the resource limiter, channel-specific host state, and WASI context. @@ -76,6 +97,9 @@ struct ChannelStoreData { /// Injected credentials for URL substitution (e.g., bot tokens). /// Keys are placeholder names like "TELEGRAM_BOT_TOKEN". credentials: HashMap, + /// Pre-resolved credentials for automatic host-based injection. + /// Applied per-request by matching the URL host against host_patterns. + host_credentials: Vec, /// Pairing store for DM pairing (guest access control). pairing_store: Arc, /// Dedicated tokio runtime for HTTP requests, lazily initialized. @@ -89,6 +113,7 @@ impl ChannelStoreData { channel_name: &str, capabilities: ChannelCapabilities, credentials: HashMap, + host_credentials: Vec, pairing_store: Arc, ) -> Self { // Create a minimal WASI context (no filesystem, no env vars for security) @@ -100,6 +125,7 @@ impl ChannelStoreData { wasi, table: ResourceTable::new(), credentials, + host_credentials, pairing_store, http_runtime: None, } @@ -159,15 +185,74 @@ impl ChannelStoreData { /// return values to WASM. reqwest::Error includes the full URL in its /// Display output, so any error from an injected-URL request will /// contain the raw credential unless we scrub it. + /// + /// Scrubs raw, URL-encoded, and Base64-encoded forms of each secret + /// to prevent exfiltration via encoded representations in error strings. fn redact_credentials(&self, text: &str) -> String { let mut result = text.to_string(); for (name, value) in &self.credentials { if !value.is_empty() { - result = result.replace(value, &format!("[REDACTED:{}]", name)); + let tag = format!("[REDACTED:{}]", name); + result = result.replace(value, &tag); + // Also redact URL-encoded form (covers secrets in query strings) + let encoded = urlencoding::encode(value); + if encoded != *value { + result = result.replace(encoded.as_ref(), &tag); + } + } + } + for cred in &self.host_credentials { + if !cred.secret_value.is_empty() { + let tag = "[REDACTED:host_credential]"; + result = result.replace(&cred.secret_value, tag); + // Also redact URL-encoded form (covers secrets injected as query params) + let encoded = urlencoding::encode(&cred.secret_value); + if encoded.as_ref() != cred.secret_value { + result = result.replace(encoded.as_ref(), tag); + } } } result } + + /// Inject pre-resolved host credentials into the request. + /// + /// Matches the URL host against each resolved credential's host_patterns. + /// Matching credentials have their headers merged and query params appended. + fn inject_host_credentials( + &self, + url_host: &str, + headers: &mut HashMap, + url: &mut String, + ) { + for cred in &self.host_credentials { + let matches = cred + .host_patterns + .iter() + .any(|pattern| host_matches_pattern(url_host, pattern)); + + if !matches { + continue; + } + + // Merge injected headers (host credentials take precedence) + for (key, value) in &cred.headers { + headers.insert(key.clone(), value.clone()); + } + + // Append query parameters to URL + if !cred.query_params.is_empty() { + if let Ok(mut parsed_url) = url::Url::parse(url) { + for (name, value) in &cred.query_params { + parsed_url.query_pairs_mut().append_pair(name, value); + } + *url = parsed_url.to_string(); + } else { + tracing::warn!(url = %url, "Could not parse URL to inject query parameters; skipping injection"); + } + } + } + } } // Implement WasiView to provide WASI context and resource table @@ -249,7 +334,7 @@ impl near::agent::channel_host::Host for ChannelStoreData { let raw_headers: std::collections::HashMap = serde_json::from_str(&headers_json).unwrap_or_default(); - let headers: std::collections::HashMap = raw_headers + let mut headers: std::collections::HashMap = raw_headers .into_iter() .map(|(k, v)| { ( @@ -268,7 +353,12 @@ impl near::agent::channel_host::Host for ChannelStoreData { "Parsed and injected request headers" ); - let url = injected_url; + let mut url = injected_url; + + // Leak scan runs on WASM-provided values BEFORE host credential injection. + // This prevents false positives where the host-injected Bearer token + // (e.g., xoxb- Slack token) triggers the leak detector — WASM never saw + // the real value, so scanning the pre-injection state is correct. let leak_detector = LeakDetector::new(); let header_vec: Vec<(String, String)> = headers .iter() @@ -279,6 +369,12 @@ impl near::agent::channel_host::Host for ChannelStoreData { .scan_http_request(&url, &header_vec, body.as_deref()) .map_err(|e| format!("Potential secret leak blocked: {}", e))?; + // Inject pre-resolved host credentials (Bearer tokens, API keys, etc.) + // after the leak scan so host-injected secrets don't trigger false positives. + if let Some(host) = extract_host_from_url(&url) { + self.inject_host_credentials(&host, &mut headers, &mut url); + } + // Get the max response size from capabilities (default 10MB). let max_response_bytes = self .host_state @@ -560,6 +656,10 @@ pub struct WasmChannel { /// Settings store for persisting broadcast metadata across restarts. settings_store: Option>, + + /// Secrets store for host-based credential injection. + /// Used to pre-resolve credentials before each WASM callback. + secrets_store: Option>, } /// Update broadcast metadata in memory and persist to the settings store when @@ -621,9 +721,20 @@ impl WasmChannel { workspace_store: Arc::new(ChannelWorkspaceStore::new()), last_broadcast_metadata: Arc::new(tokio::sync::RwLock::new(None)), settings_store, + secrets_store: None, } } + /// Set the secrets store for host-based credential injection. + /// + /// When set, credentials declared in the channel's capabilities are + /// automatically decrypted and injected into HTTP requests based on + /// the target host (e.g., Bearer token for api.slack.com). + pub fn with_secrets_store(mut self, store: Arc) -> Self { + self.secrets_store = Some(store); + self + } + /// Update the channel config before starting. /// /// Merges the provided values into the existing config JSON. @@ -767,6 +878,7 @@ impl WasmChannel { prepared: &PreparedChannelModule, capabilities: &ChannelCapabilities, credentials: HashMap, + host_credentials: Vec, pairing_store: Arc, ) -> Result, WasmChannelError> { let engine = runtime.engine(); @@ -778,6 +890,7 @@ impl WasmChannel { &prepared.name, capabilities.clone(), credentials, + host_credentials, pairing_store, ); let mut store = Store::new(engine, store_data); @@ -888,6 +1001,9 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let host_credentials = + resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + .await; let pairing_store = self.pairing_store.clone(); let workspace_store = self.workspace_store.clone(); @@ -899,6 +1015,7 @@ impl WasmChannel { &prepared, &capabilities, credentials, + host_credentials, pairing_store, )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; @@ -1024,6 +1141,9 @@ impl WasmChannel { let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store); let timeout = self.runtime.config().callback_timeout; let credentials = self.get_credentials().await; + let host_credentials = + resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + .await; let pairing_store = self.pairing_store.clone(); let workspace_store = self.workspace_store.clone(); @@ -1044,6 +1164,7 @@ impl WasmChannel { &prepared, &capabilities, credentials, + host_credentials, pairing_store, )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; @@ -1123,6 +1244,9 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let host_credentials = + resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + .await; let pairing_store = self.pairing_store.clone(); let workspace_store = self.workspace_store.clone(); @@ -1134,6 +1258,7 @@ impl WasmChannel { &prepared, &capabilities, credentials, + host_credentials, pairing_store, )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; @@ -1224,6 +1349,9 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let host_credentials = + resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + .await; let pairing_store = self.pairing_store.clone(); // Prepare response data @@ -1243,6 +1371,7 @@ impl WasmChannel { &prepared, &capabilities, credentials, + host_credentials, pairing_store, )?; @@ -1337,6 +1466,9 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; + let host_credentials = + resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + .await; let pairing_store = self.pairing_store.clone(); let wit_update = status_to_wit(status, metadata); @@ -1348,6 +1480,7 @@ impl WasmChannel { &prepared, &capabilities, credentials, + host_credentials, pairing_store, )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; @@ -1394,6 +1527,7 @@ impl WasmChannel { prepared: &Arc, capabilities: &ChannelCapabilities, credentials: &RwLock>, + host_credentials: Vec, pairing_store: Arc, timeout: Duration, wit_update: wit_channel::StatusUpdate, @@ -1415,6 +1549,7 @@ impl WasmChannel { &prepared, &capabilities, credentials_snapshot, + host_credentials, pairing_store, )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; @@ -1496,6 +1631,13 @@ impl WasmChannel { let prepared = Arc::clone(&self.prepared); let capabilities = self.capabilities.clone(); let credentials = self.credentials.clone(); + // Pre-resolve host credentials once for the lifetime of the repeater. + // Channels tokens rarely change, so a snapshot per-repeater is correct. + let repeater_host_credentials = resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + ) + .await; let pairing_store = self.pairing_store.clone(); let callback_timeout = self.runtime.config().callback_timeout; let wit_update = status_to_wit(&status, metadata); @@ -1509,6 +1651,7 @@ impl WasmChannel { interval.tick().await; let wit_update_clone = clone_wit_status_update(&wit_update); + let hc = repeater_host_credentials.clone(); if let Err(e) = Self::execute_status( &channel_name, @@ -1516,6 +1659,7 @@ impl WasmChannel { &prepared, &capabilities, &credentials, + hc, pairing_store.clone(), callback_timeout, wit_update_clone, @@ -1733,7 +1877,8 @@ impl WasmChannel { let channel_name = self.name.clone(); let runtime = Arc::clone(&self.runtime); let prepared = Arc::clone(&self.prepared); - let capabilities = self.capabilities.clone(); + let poll_capabilities = self.capabilities.clone(); + let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store); let message_tx = self.message_tx.clone(); let rate_limiter = self.rate_limiter.clone(); let credentials = self.credentials.clone(); @@ -1742,6 +1887,7 @@ impl WasmChannel { let workspace_store = self.workspace_store.clone(); let last_broadcast_metadata = self.last_broadcast_metadata.clone(); let settings_store = self.settings_store.clone(); + let poll_secrets_store = self.secrets_store.clone(); tokio::spawn(async move { let mut interval_timer = tokio::time::interval(interval); @@ -1755,6 +1901,13 @@ impl WasmChannel { "Polling tick - calling on_poll" ); + // Pre-resolve host credentials for this tick + let host_credentials = resolve_channel_host_credentials( + &poll_capabilities, + poll_secrets_store.as_deref(), + ) + .await; + // Execute on_poll with fresh WASM instance let result = Self::execute_poll( &channel_name, @@ -1762,6 +1915,7 @@ impl WasmChannel { &prepared, &capabilities, &credentials, + host_credentials, pairing_store.clone(), callback_timeout, &workspace_store, @@ -1819,6 +1973,7 @@ impl WasmChannel { prepared: &Arc, capabilities: &ChannelCapabilities, credentials: &RwLock>, + host_credentials: Vec, pairing_store: Arc, timeout: Duration, workspace_store: &Arc, @@ -1847,6 +2002,7 @@ impl WasmChannel { &prepared, &capabilities, credentials_snapshot, + host_credentials, pairing_store, )?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; @@ -2491,6 +2647,97 @@ impl HttpResponse { } } +/// Extract the hostname from a URL string. +/// +/// Returns `None` for malformed URLs or non-HTTP(S) schemes. +fn extract_host_from_url(url: &str) -> Option { + let parsed = url::Url::parse(url).ok()?; + if !matches!(parsed.scheme(), "http" | "https") { + return None; + } + parsed.host_str().map(|h| { + h.strip_prefix('[') + .and_then(|v| v.strip_suffix(']')) + .unwrap_or(h) + .to_lowercase() + }) +} + +/// Pre-resolve host credentials for all HTTP capability mappings. +/// +/// Called once per callback (in async context, before spawn_blocking) so the +/// synchronous WASM host function can inject credentials without needing async +/// access to the secrets store. +/// +/// Silently skips credentials that can't be resolved (e.g., missing secrets). +/// The channel will get a 401/403 from the API, which is the expected UX when +/// auth hasn't been configured yet. +async fn resolve_channel_host_credentials( + capabilities: &ChannelCapabilities, + store: Option<&(dyn SecretsStore + Send + Sync)>, +) -> Vec { + let store = match store { + Some(s) => s, + None => return Vec::new(), + }; + + let http_cap = match &capabilities.tool_capabilities.http { + Some(cap) => cap, + None => return Vec::new(), + }; + + if http_cap.credentials.is_empty() { + return Vec::new(); + } + + let mut resolved = Vec::new(); + + for mapping in http_cap.credentials.values() { + // Skip UrlPath credentials; they're handled by placeholder substitution + if matches!( + mapping.location, + crate::secrets::CredentialLocation::UrlPath { .. } + ) { + continue; + } + + let secret = match store.get_decrypted("default", &mapping.secret_name).await { + Ok(s) => s, + Err(e) => { + tracing::debug!( + secret_name = %mapping.secret_name, + error = %e, + "Could not resolve credential for WASM channel (auth may not be configured)" + ); + continue; + } + }; + + let mut injected = InjectedCredentials::empty(); + inject_credential(&mut injected, &mapping.location, &secret); + + if injected.is_empty() { + continue; + } + + resolved.push(ResolvedHostCredential { + host_patterns: mapping.host_patterns.clone(), + headers: injected.headers, + query_params: injected.query_params, + secret_value: secret.expose().to_string(), + }); + } + + if !resolved.is_empty() { + tracing::debug!( + count = resolved.len(), + "Pre-resolved host credentials for WASM channel execution" + ); + } + + resolved +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -2601,6 +2848,7 @@ mod tests { &prepared, &capabilities, &credentials, + Vec::new(), // no host credentials in test Arc::new(PairingStore::new()), timeout, &workspace_store, @@ -3456,6 +3704,7 @@ mod tests { "test", ChannelCapabilities::default(), creds, + Vec::new(), Arc::new(PairingStore::new()), ); @@ -3487,6 +3736,7 @@ mod tests { "test", ChannelCapabilities::default(), std::collections::HashMap::new(), + Vec::new(), Arc::new(PairingStore::new()), ); @@ -3494,6 +3744,50 @@ mod tests { assert_eq!(store.redact_credentials(input), input); } + #[test] + fn test_redact_credentials_url_encoded() { + use super::{ChannelStoreData, ResolvedHostCredential}; + + // Credential with characters that get URL-encoded + let mut creds = std::collections::HashMap::new(); + creds.insert( + "API_KEY".to_string(), + "key with spaces&special=chars".to_string(), + ); + + let host_creds = vec![ResolvedHostCredential { + host_patterns: vec!["api.example.com".to_string()], + headers: std::collections::HashMap::new(), + query_params: std::collections::HashMap::new(), + secret_value: "host secret+value".to_string(), + }]; + + let store = ChannelStoreData::new( + 1024 * 1024, + "test", + ChannelCapabilities::default(), + creds, + host_creds, + Arc::new(PairingStore::new()), + ); + + // Error containing URL-encoded form of the credential + let error = "request failed: https://api.example.com?key=key%20with%20spaces%26special%3Dchars&host=host%20secret%2Bvalue"; + + let redacted = store.redact_credentials(error); + + assert!( + !redacted.contains("key%20with%20spaces"), + "URL-encoded credential should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("host%20secret%2Bvalue"), + "URL-encoded host credential should be redacted, got: {}", + redacted + ); + } + #[test] fn test_redact_credentials_skips_empty_values() { use super::ChannelStoreData; @@ -3506,6 +3800,7 @@ mod tests { "test", ChannelCapabilities::default(), creds, + Vec::new(), Arc::new(PairingStore::new()), ); diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index 42e9f150..a3fe0b24 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -76,7 +76,7 @@ mod allowlist; mod capabilities; mod capabilities_schema; -mod credential_injector; +pub(crate) mod credential_injector; mod error; mod host; mod limits;