Address PR #2050 review comments

Three follow-up fixes from automated reviewers (Copilot, gemini-code-assist):

- Gate IRONCLAW_TEST_HTTP_REMAP behind cfg(test, debug_assertions) so a
  stray env var on a release deployment cannot silently redirect outbound
  HTTP traffic from production to a test endpoint.
- Bound the OAuth token-refresh response body at 64 KiB. A misbehaving or
  hostile token endpoint could otherwise stream an unbounded body and
  OOM the process via response.json().
- Cache mcp_supports_auth() metadata-discovery results per server URL on
  the ExtensionManager. The previous code re-issued a network probe for
  every unauthenticated MCP server on every list() call, slowing the
  extensions list endpoint when multiple MCP servers were configured.
  Cache is invalidated alongside the latent-actions cache on add/update/
  remove of MCP servers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Illia Polosukhin
2026-04-07 15:25:59 +00:00
parent 758f767891
commit cb998bedb9
3 changed files with 55 additions and 5 deletions

View File

@@ -337,7 +337,14 @@ impl AppBuilder {
tools_builder =
tools_builder.with_credentials(Arc::clone(&credential_registry), Arc::clone(ss));
}
let http_interceptor = crate::http_intercept::remap_from_env();
// Test-only HTTP host remapping. Gated to debug/test builds so a stray
// `IRONCLAW_TEST_HTTP_REMAP` env var on a release deployment cannot
// silently redirect outbound HTTP from production to a test endpoint.
let http_interceptor = if cfg!(any(test, debug_assertions)) {
crate::http_intercept::remap_from_env()
} else {
None
};
if let Some(ref interceptor) = http_interceptor {
tools_builder = tools_builder.with_http_interceptor(Arc::clone(interceptor));
}

View File

@@ -517,9 +517,19 @@ pub async fn refresh_oauth_access_token(
}
};
// Cap the response body at 64 KiB. Legitimate OAuth token responses are
// a few hundred bytes; a misbehaving or hostile token endpoint must not
// be able to OOM the process by streaming an unbounded body.
const MAX_TOKEN_BODY_BYTES: usize = 64 * 1024;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let body_bytes = response
.bytes()
.await
.map(|b| b.slice(..b.len().min(MAX_TOKEN_BODY_BYTES)))
.unwrap_or_default();
let body = String::from_utf8_lossy(&body_bytes);
tracing::warn!(
status = %status,
body = %body,
@@ -528,7 +538,22 @@ pub async fn refresh_oauth_access_token(
return false;
}
let token_data: serde_json::Value = match response.json().await {
let body_bytes = match response.bytes().await {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, "Failed to read token refresh response body");
return false;
}
};
if body_bytes.len() > MAX_TOKEN_BODY_BYTES {
tracing::warn!(
len = body_bytes.len(),
limit = MAX_TOKEN_BODY_BYTES,
"OAuth token refresh response exceeds size limit"
);
return false;
}
let token_data: serde_json::Value = match serde_json::from_slice(&body_bytes) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "Failed to parse token refresh response");

View File

@@ -468,6 +468,9 @@ pub struct ExtensionManager {
wasm_tools_dir: PathBuf,
wasm_channels_dir: PathBuf,
latent_wasm_provider_actions: RwLock<HashMap<String, Vec<LatentProviderAction>>>,
/// Per-server URL cache for `mcp_supports_auth` metadata discovery.
/// Avoids re-issuing a network probe on every `list()` call.
mcp_auth_support_cache: RwLock<HashMap<String, bool>>,
// WASM channel hot-activation infrastructure (set post-construction)
channel_runtime: RwLock<Option<ChannelRuntimeState>>,
@@ -642,6 +645,7 @@ impl ExtensionManager {
wasm_tools_dir,
wasm_channels_dir,
latent_wasm_provider_actions: RwLock::new(HashMap::new()),
mcp_auth_support_cache: RwLock::new(HashMap::new()),
channel_runtime: RwLock::new(None),
relay_channel_manager: RwLock::new(None),
secrets,
@@ -2721,6 +2725,7 @@ impl ExtensionManager {
// action. Drop the cache so the next listing reflects its
// installed/active status.
self.invalidate_latent_wasm_provider_actions_cache().await;
self.mcp_auth_support_cache.write().await.clear();
}
result
}
@@ -2767,6 +2772,7 @@ impl ExtensionManager {
};
if result.is_ok() {
self.invalidate_latent_wasm_provider_actions_cache().await;
self.mcp_auth_support_cache.write().await.clear();
}
result
}
@@ -2786,6 +2792,7 @@ impl ExtensionManager {
// state; drop the cache so the registry-discovery path can
// resurface it as a latent provider action.
self.invalidate_latent_wasm_provider_actions_cache().await;
self.mcp_auth_support_cache.write().await.clear();
}
result
}
@@ -3948,10 +3955,16 @@ impl ExtensionManager {
return true;
}
// Cache hit: avoid the network probe on every list() call. Cache is
// keyed by server URL and invalidated when MCP server config changes.
if let Some(&cached) = self.mcp_auth_support_cache.read().await.get(&server.url) {
return cached;
}
// Metadata discovery uses the bounded MCP OAuth client timeouts in
// `discover_full_oauth_metadata()`, so this list-path probe cannot hang
// indefinitely on a hostile or slow server URL.
match discover_full_oauth_metadata(&server.url).await {
let supports = match discover_full_oauth_metadata(&server.url).await {
Ok(_) => true,
Err(crate::tools::mcp::auth::AuthError::NotSupported) => false,
Err(error) => {
@@ -3963,7 +3976,12 @@ impl ExtensionManager {
);
false
}
}
};
self.mcp_auth_support_cache
.write()
.await
.insert(server.url.clone(), supports);
supports
}
async fn start_secret_oauth_flow(