fix(wasm): run leak scan before credential injection in tools wrapper (#791)

* fix(wasm): run leak scan before credential injection in tools wrapper

The tools WASM wrapper runs the LeakDetector on HTTP request headers
AFTER inject_host_credentials() has already substituted real secrets
(e.g., xoxb- Slack bot tokens). This causes the leak detector to
flag the tool's own legitimate outbound API calls as secret exfiltration.

Move the scan to run on raw_headers before any credential injection,
matching the fix already applied to the channels wrapper in #421.

Fixes the same class of bug as #421 (which only fixed channels/wasm/wrapper.rs).

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

* perf: inline leak scan to avoid Vec allocation on every HTTP request

Address review feedback: instead of cloning all header keys/values into
a Vec to pass to scan_http_request(), iterate over raw_headers directly
using scan_and_clean(). This also provides more specific error messages
(URL vs header vs body).

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

* style: fix cargo fmt formatting in leak scan loop

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Nick Stebbings
2026-03-10 11:06:53 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent c148dd2b5b
commit 66e834d9d7
+74 -10
View File
@@ -279,6 +279,27 @@ impl near::agent::host::Host for StoreData {
let raw_headers: HashMap<String, String> =
serde_json::from_str(&headers_json).unwrap_or_default();
// 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.
// Inline the scan to avoid allocating a Vec of cloned headers.
let leak_detector = LeakDetector::new();
leak_detector
.scan_and_clean(&injected_url)
.map_err(|e| format!("Potential secret leak in URL blocked: {}", e))?;
for (name, value) in &raw_headers {
leak_detector.scan_and_clean(value).map_err(|e| {
format!("Potential secret leak in header '{}' blocked: {}", name, e)
})?;
}
if let Some(body_bytes) = body.as_deref() {
let body_str = String::from_utf8_lossy(body_bytes);
leak_detector
.scan_and_clean(&body_str)
.map_err(|e| format!("Potential secret leak in body blocked: {}", e))?;
}
let mut headers: HashMap<String, String> = raw_headers
.into_iter()
.map(|(k, v)| {
@@ -297,16 +318,6 @@ impl near::agent::host::Host for StoreData {
self.inject_host_credentials(&host, &mut headers, &mut url);
}
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(&url, &header_vec, body.as_deref())
.map_err(|e| format!("Potential secret leak blocked: {}", e))?;
// Get the max response size from capabilities (default 10MB).
let max_response_bytes = self
.host_state
@@ -1792,4 +1803,57 @@ mod tests {
// Should remain as string since it can't be parsed
assert_eq!(result["count"], serde_json::json!("not-a-number"));
}
/// Regression test: leak scan must run on raw headers (before credential
/// injection), not after. If it ran post-injection, the host-injected
/// Slack bot token (`xoxb-...`) would trigger a Block and reject the
/// tool's own legitimate outbound request.
#[test]
fn test_leak_scan_runs_before_credential_injection() {
use crate::safety::LeakDetector;
// Simulate pre-injection headers: WASM only sees the placeholder, not the real token.
let raw_headers: Vec<(String, String)> = vec![
(
"Authorization".to_string(),
"Bearer {SLACK_BOT_TOKEN}".to_string(),
),
("Content-Type".to_string(), "application/json".to_string()),
];
let detector = LeakDetector::new();
// Pre-injection scan should pass — placeholders are not secrets.
let pre_result = detector.scan_http_request(
"https://slack.com/api/chat.postMessage",
&raw_headers,
None,
);
assert!(
pre_result.is_ok(),
"Leak scan on pre-injection headers should pass, but got: {:?}",
pre_result
);
// Post-injection headers would contain a real Slack token.
let post_injection_headers: Vec<(String, String)> = vec![
(
"Authorization".to_string(),
"Bearer xoxb-1234567890-abcdefghij".to_string(),
),
("Content-Type".to_string(), "application/json".to_string()),
];
// Post-injection scan WOULD block — this is the false positive
// that the pre-injection ordering prevents.
let post_result = detector.scan_http_request(
"https://slack.com/api/chat.postMessage",
&post_injection_headers,
None,
);
assert!(
post_result.is_err(),
"Leak scan on post-injection headers should block the Slack token"
);
}
}