fix(wasm): run leak scan on pre-injection headers in channel callbacks (#1377)

* fix(wasm): run leak scan on pre-injection headers in channel callbacks

The WASM channel host's http_request handler was scanning request headers
AFTER inject_credentials() replaced placeholder values (e.g. {SLACK_BOT_TOKEN})
with real secrets. This caused the leak detector to flag host-injected
credentials as potential leaks, blocking legitimate WASM channel callbacks.

Run the leak scan on the original WASM-provided headers (before any
credential injection) so host-injected tokens never appear in the scan.
WASM never sees the real values, so scanning the pre-injection state is
correct. Matches the existing pattern in src/tools/wasm/wrapper.rs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: regression test for pre-injection leak scan ordering

Proves that scanning post-injection headers triggers a false positive
on host-injected xoxb- tokens, confirming the fix must scan WASM-provided
headers before credential injection.

* fix: address review feedback — eliminate double-parse, fix comment, migrate import

- Eliminate double-parse of headers_json: parse once, scan raw headers,
  then inject credentials (matches tools wrapper pattern)
- Fix misleading comment: URL has template substitution but not yet
  host credential injection (was "before ANY credential injection")
- Migrate import to ironclaw_safety::LeakDetector per CLAUDE.md
- Remove unnecessary block scope around leak scan
- Remove raw_url_for_scan alias (just use &url directly)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: serrrfirat <f@nuff.tech>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
This commit is contained in:
Nick Stebbings
2026-04-20 02:17:35 +12:00
committed by GitHub
parent a524bf8aee
commit 14333e4a0a

View File

@@ -339,11 +339,31 @@ impl near::agent::channel_host::Host for ChannelStoreData {
format!("Rate limit exceeded: {}", e)
})?;
// Parse headers and inject credentials into header values
// This allows patterns like "Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
let raw_headers: std::collections::HashMap<String, String> =
serde_json::from_str(&headers_json).unwrap_or_default();
// Parse headers from WASM and scan for leaks before credential injection.
// Host-injected tokens (e.g., xoxb- Slack bot token) would otherwise
// trigger the leak detector. The URL has template substitution applied
// (`injected_url`) but not yet host credential injection.
let raw_headers: std::collections::HashMap<String, String> = serde_json::from_str(
&headers_json,
)
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "Malformed headers JSON from WASM; scanning empty headers");
std::collections::HashMap::new()
});
let mut logical_url = injected_url;
let leak_detector = LeakDetector::new();
let raw_header_vec: Vec<(String, String)> = raw_headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
leak_detector
.scan_http_request(&logical_url, &raw_header_vec, body.as_deref())
.map_err(|e| format!("Potential secret leak blocked: {}", e))?;
// Now inject credentials into header values
// This allows patterns like "Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
let mut headers: std::collections::HashMap<String, String> = raw_headers
.into_iter()
.map(|(k, v)| {
@@ -363,22 +383,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
"Parsed and injected request headers"
);
let mut logical_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()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
leak_detector
.scan_http_request(&logical_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(&logical_url) {
@@ -6834,18 +6838,87 @@ mod tests {
}
#[test]
fn test_rewrite_http_url_for_testing_uses_host_map() {
use std::sync::{Mutex, OnceLock};
static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
let _lock = ENV_MUTEX
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env mutex poisoned");
fn test_http_request_scans_headers_before_placeholder_substitution() {
use super::ChannelStoreData;
use std::collections::HashMap;
let _guard = crate::config::helpers::lock_env();
let original = std::env::var(TEST_HTTP_REWRITE_MAP_ENV).ok();
// SAFETY: guarded by ENV_MUTEX no concurrent env access.
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var(
TEST_HTTP_REWRITE_MAP_ENV,
r#"{"slack.com":"http://127.0.0.1:1"}"#,
);
}
let capabilities =
ChannelCapabilities::for_channel("test").with_tool_capabilities(ToolCapabilities {
http: Some(HttpCapability::new(vec![EndpointPattern::host(
"slack.com",
)])),
..Default::default()
});
// Placeholder whose substituted value matches the openai_api_key leak
// pattern (`sk-(?:proj-)?[a-zA-Z0-9]{20,}`). If the scan ran AFTER
// `inject_credentials` replaced `{FAKE_TOKEN}` in the Authorization
// header, the Bearer value would trip the detector.
let mut credentials = HashMap::new();
credentials.insert(
"FAKE_TOKEN".to_string(),
"sk-proj-TESTFAKEKEY01234567890abcdef".to_string(),
);
let mut store = ChannelStoreData::new(
1024 * 1024,
"test",
capabilities,
credentials,
Vec::new(),
Arc::new(PairingStore::new_noop()),
);
let result = super::near::agent::channel_host::Host::http_request(
&mut store,
"BREW".to_string(),
"https://slack.com/api/chat.postMessage".to_string(),
r#"{"Authorization":"Bearer {FAKE_TOKEN}"}"#.to_string(),
None,
Some(1_000),
);
// Reaching the unsupported-method branch proves the leak scan accepted
// the raw `Bearer {FAKE_TOKEN}` header value. A regression to
// post-injection scanning would surface as "Potential secret leak
// blocked" before method dispatch.
let error = result.expect_err("unsupported method should fail after leak scan");
assert!(
!error.contains("Potential secret leak blocked"),
"placeholder-substituted credential must not trigger request leak scan: {error}"
);
assert!(
error.contains("Unsupported HTTP method: BREW"),
"expected unsupported method after leak scan, got: {error}"
);
// SAFETY: Under ENV_MUTEX, restore original state.
unsafe {
if let Some(ref val) = original {
std::env::set_var(TEST_HTTP_REWRITE_MAP_ENV, val);
} else {
std::env::remove_var(TEST_HTTP_REWRITE_MAP_ENV);
}
}
}
#[test]
fn test_rewrite_http_url_for_testing_uses_host_map() {
let _guard = crate::config::helpers::lock_env();
let original = std::env::var(TEST_HTTP_REWRITE_MAP_ENV).ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var(
TEST_HTTP_REWRITE_MAP_ENV,