mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Sprite
Claude Sonnet 4.6
parent
a21dba0ac1
commit
dc7d9cce34
@@ -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()),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user