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 <[email protected]>

* 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 <[email protected]>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Sprite <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Zaki Manian
2026-03-01 19:04:37 -08:00
committed by GitHub
co-authored by Sprite Claude Sonnet 4.6
parent a21dba0ac1
commit dc7d9cce34
2 changed files with 300 additions and 5 deletions
+299 -4
View File
@@ -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<String>,
/// Headers to add to matching requests (e.g., "Authorization: Bearer ...").
headers: HashMap<String, String>,
/// Query parameters to add to matching requests.
query_params: HashMap<String, String>,
/// 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<String, String>,
/// Pre-resolved credentials for automatic host-based injection.
/// Applied per-request by matching the URL host against host_patterns.
host_credentials: Vec<ResolvedHostCredential>,
/// Pairing store for DM pairing (guest access control).
pairing_store: Arc<PairingStore>,
/// Dedicated tokio runtime for HTTP requests, lazily initialized.
@@ -89,6 +113,7 @@ impl ChannelStoreData {
channel_name: &str,
capabilities: ChannelCapabilities,
credentials: HashMap<String, String>,
host_credentials: Vec<ResolvedHostCredential>,
pairing_store: Arc<PairingStore>,
) -> 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<String, String>,
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<String, String> =
serde_json::from_str(&headers_json).unwrap_or_default();
let headers: std::collections::HashMap<String, String> = raw_headers
let mut headers: std::collections::HashMap<String, String> = 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<Arc<dyn crate::db::SettingsStore>>,
/// Secrets store for host-based credential injection.
/// Used to pre-resolve credentials before each WASM callback.
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
}
/// 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<dyn SecretsStore + Send + Sync>) -> 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<String, String>,
host_credentials: Vec<ResolvedHostCredential>,
pairing_store: Arc<PairingStore>,
) -> Result<Store<ChannelStoreData>, 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<PreparedChannelModule>,
capabilities: &ChannelCapabilities,
credentials: &RwLock<HashMap<String, String>>,
host_credentials: Vec<ResolvedHostCredential>,
pairing_store: Arc<PairingStore>,
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<PreparedChannelModule>,
capabilities: &ChannelCapabilities,
credentials: &RwLock<HashMap<String, String>>,
host_credentials: Vec<ResolvedHostCredential>,
pairing_store: Arc<PairingStore>,
timeout: Duration,
workspace_store: &Arc<ChannelWorkspaceStore>,
@@ -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<String> {
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<ResolvedHostCredential> {
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()),
);
+1 -1
View File
@@ -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;