From e9765301681c693208e0cd4222393e17e9518fb4 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 7 Oct 2026 23:35:55 +0530 Subject: [PATCH 1/5] Return the response on a failed http_request, not just its status code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `format_response` built the status line, headers and body, then threw all of it away on a non-2xx and returned the bare string `HTTP `. Observed as `HTTP 403`, twenty characters, repeated eight times. The discarded body was the only part that said what was wrong — GitHub's 403 names the missing `User-Agent` header outright, with a documentation link — so the caller had nothing to act on and the failure was misread as a credentials problem. Also fixes the header block, which printed each name twice (`{}: {:?}` over `(k.as_str(), k.as_str())`), so every line read `x-ratelimit-remaining: "x-ratelimit-remaining"` and carried no information. Values are shown now, with the existing `set-cookie` redaction widened to anything cookie- or authorization-shaped. Co-Authored-By: Claude Opus 5 --- .../tinytools-std/src/network/http_request.rs | 36 +++++++++++++++---- .../src/network/http_request_tests.rs | 30 ++++++++++++---- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/crates/tinytools-std/src/network/http_request.rs b/crates/tinytools-std/src/network/http_request.rs index 39aac65..296f333 100644 --- a/crates/tinytools-std/src/network/http_request.rs +++ b/crates/tinytools-std/src/network/http_request.rs @@ -242,14 +242,30 @@ impl HttpRequestTool { let status = response.status(); let status_code = status.as_u16(); - let response_headers = response.headers().iter(); - let headers_text = response_headers - .map(|(k, _)| { - let is_sensitive = k.as_str().to_lowercase().contains("set-cookie"); + // Name *and* value. This printed the name twice — `{}: {:?}` over + // `(k.as_str(), k.as_str())` — so every line read + // `x-ratelimit-remaining: "x-ratelimit-remaining"` and the header block + // carried no information at all. The redaction beside it only makes + // sense if values were meant to be shown, which is the reading taken + // here: rate-limit and retry-after headers are exactly what a caller + // needs on the failures this block renders. + let headers_text = response + .headers() + .iter() + .map(|(name, value)| { + let lower = name.as_str().to_ascii_lowercase(); + // A response can hand back credentials: `set-cookie` was + // already redacted, and anything cookie- or authorization- + // shaped belongs with it rather than in a model's context. + let is_sensitive = lower.contains("cookie") || lower.contains("authorization"); if is_sensitive { - format!("{}: ***REDACTED***", k.as_str()) + format!("{}: ***REDACTED***", name.as_str()) } else { - format!("{}: {:?}", k.as_str(), k.as_str()) + format!( + "{}: {}", + name.as_str(), + value.to_str().unwrap_or("") + ) } }) .collect::>() @@ -271,7 +287,13 @@ impl HttpRequestTool { if status.is_success() { Ok(ToolResult::success(output)) } else { - Ok(ToolResult::error(format!("HTTP {status_code}"))) + // The same `output` on both arms. This returned the bare string + // `HTTP 403` — twenty characters — having already built the status + // line, headers and body and then dropped them, so the one part of + // the response that said *why* never reached the caller. A real + // case: GitHub's 403 names the missing `User-Agent` header outright, + // with a documentation link, and all of it was discarded. + Ok(ToolResult::error(output)) } } diff --git a/crates/tinytools-std/src/network/http_request_tests.rs b/crates/tinytools-std/src/network/http_request_tests.rs index 520cc33..dce577f 100644 --- a/crates/tinytools-std/src/network/http_request_tests.rs +++ b/crates/tinytools-std/src/network/http_request_tests.rs @@ -475,10 +475,19 @@ async fn a_body_is_sent_and_a_success_response_is_formatted_with_redacted_cookie #[tokio::test] async fn a_non_success_response_is_reported_as_an_error() { - let (addr, _) = serve(vec![ - "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - .to_string(), - ]) + // The failure now carries the whole response rather than just its code. + // + // It used to answer `HTTP 500` —, and in a measured case `HTTP 403`, + // twenty characters, after building the status line, headers and body and + // then dropping them. The discarded body was the only thing that said what + // was wrong: GitHub's 403 names the missing `User-Agent` header outright. + let body = "Request forbidden by administrative rules. \ + Please make sure your request has a User-Agent header"; + let (addr, _) = serve(vec![format!( + "HTTP/1.1 403 Forbidden\r\nContent-Length: {}\r\nX-RateLimit-Remaining: 59\r\nConnection: close\r\n\r\n{}", + body.len(), + body + )]) .await; let tool = test_tool(vec![]); let response = tool @@ -491,8 +500,17 @@ async fn a_non_success_response_is_reported_as_an_error() { .await .unwrap(); let result = tool.format_response(response).await.unwrap(); - assert!(result.is_error); - assert!(result.text().contains("HTTP 500")); + assert!(result.is_error, "a 403 is still a failure"); + let text = result.text(); + // Anchored: a caller classifying the failure reads the status from the head + // of the text, and an unreadable one gets guessed at. + assert!(text.starts_with("Status: 403"), "{text}"); + // The reason the server gave. + assert!(text.contains("User-Agent header"), "{text}"); + // Header *values*, not the name printed twice. `x-ratelimit-remaining` is + // exactly what a caller wants on the failures this block renders. + let lower = text.to_ascii_lowercase(); + assert!(lower.contains("x-ratelimit-remaining: 59"), "{text}"); } #[tokio::test] From 16ffc3224e0e4a11e725afebfe700c09cfd5ca4b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 8 Oct 2026 14:15:53 +0300 Subject: [PATCH 2/5] fix(network): redact all response headers except a safe allowlist Response headers were previously redacted only when their name contained "cookie" or "authorization", which let credentials leak through service-specific headers with arbitrary names. Header values are now shown only for a small allowlist of diagnostic headers, and everything else is redacted. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinytools-std/src/network/http_request.rs | 22 ++++++++++++++----- .../src/network/http_request_tests.rs | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/tinytools-std/src/network/http_request.rs b/crates/tinytools-std/src/network/http_request.rs index 296f333..b1282bb 100644 --- a/crates/tinytools-std/src/network/http_request.rs +++ b/crates/tinytools-std/src/network/http_request.rs @@ -174,6 +174,18 @@ impl HttpRequestTool { .collect() } + fn is_safe_response_header(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "content-type" + | "content-length" + | "retry-after" + | "x-ratelimit-limit" + | "x-ratelimit-remaining" + | "x-ratelimit-reset" + ) + } + async fn execute_request( &self, url: &str, @@ -253,12 +265,10 @@ impl HttpRequestTool { .headers() .iter() .map(|(name, value)| { - let lower = name.as_str().to_ascii_lowercase(); - // A response can hand back credentials: `set-cookie` was - // already redacted, and anything cookie- or authorization- - // shaped belongs with it rather than in a model's context. - let is_sensitive = lower.contains("cookie") || lower.contains("authorization"); - if is_sensitive { + // Response headers are untrusted and service-specific headers + // can carry credentials under arbitrary names. Only expose + // values from this small set of useful diagnostic headers. + if !Self::is_safe_response_header(name.as_str()) { format!("{}: ***REDACTED***", name.as_str()) } else { format!( diff --git a/crates/tinytools-std/src/network/http_request_tests.rs b/crates/tinytools-std/src/network/http_request_tests.rs index dce577f..6bbd383 100644 --- a/crates/tinytools-std/src/network/http_request_tests.rs +++ b/crates/tinytools-std/src/network/http_request_tests.rs @@ -228,6 +228,28 @@ fn redact_headers_does_not_alter_original() { assert_eq!(headers[0].1, "Bearer real-token"); } +#[test] +fn only_explicitly_safe_response_header_values_are_displayed() { + for safe in ["content-type", "content-length", "retry-after", "x-ratelimit-remaining"] { + assert!(HttpRequestTool::is_safe_response_header(safe), "{safe}"); + assert!(HttpRequestTool::is_safe_response_header(&safe.to_ascii_uppercase())); + } + + for sensitive in [ + "set-cookie", + "www-authenticate", + "proxy-authenticate", + "authentication-info", + "x-api-key", + "x-service-token", + ] { + assert!( + !HttpRequestTool::is_safe_response_header(sensitive), + "{sensitive} must be redacted" + ); + } +} + #[test] fn redirect_policy_is_none() { let tool = test_tool(vec!["example.com"]); From fffca7eebe059db36ebe2f664943ce5ba8fec823 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 8 Oct 2026 14:16:04 +0300 Subject: [PATCH 3/5] test(network): reformat safe response header assertions Reformatted the safe response header test to satisfy rustfmt line length limits. No behaviour change. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinytools-std/src/network/http_request_tests.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/tinytools-std/src/network/http_request_tests.rs b/crates/tinytools-std/src/network/http_request_tests.rs index 6bbd383..4b47020 100644 --- a/crates/tinytools-std/src/network/http_request_tests.rs +++ b/crates/tinytools-std/src/network/http_request_tests.rs @@ -230,9 +230,16 @@ fn redact_headers_does_not_alter_original() { #[test] fn only_explicitly_safe_response_header_values_are_displayed() { - for safe in ["content-type", "content-length", "retry-after", "x-ratelimit-remaining"] { + for safe in [ + "content-type", + "content-length", + "retry-after", + "x-ratelimit-remaining", + ] { assert!(HttpRequestTool::is_safe_response_header(safe), "{safe}"); - assert!(HttpRequestTool::is_safe_response_header(&safe.to_ascii_uppercase())); + assert!(HttpRequestTool::is_safe_response_header( + &safe.to_ascii_uppercase() + )); } for sensitive in [ From 47656d3857074cbcb6bb2ba92d8c7aec04417d25 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 8 Oct 2026 14:20:02 +0300 Subject: [PATCH 4/5] fix(network): correct inverted response header redaction The safe-header check was negated, so diagnostic headers were redacted while untrusted headers were exposed. The branches are swapped so only allowlisted headers have their values shown. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools-std/src/network/http_request.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinytools-std/src/network/http_request.rs b/crates/tinytools-std/src/network/http_request.rs index b1282bb..e91b8bc 100644 --- a/crates/tinytools-std/src/network/http_request.rs +++ b/crates/tinytools-std/src/network/http_request.rs @@ -268,14 +268,14 @@ impl HttpRequestTool { // Response headers are untrusted and service-specific headers // can carry credentials under arbitrary names. Only expose // values from this small set of useful diagnostic headers. - if !Self::is_safe_response_header(name.as_str()) { - format!("{}: ***REDACTED***", name.as_str()) - } else { + if Self::is_safe_response_header(name.as_str()) { format!( "{}: {}", name.as_str(), value.to_str().unwrap_or("") ) + } else { + format!("{}: ***REDACTED***", name.as_str()) } }) .collect::>() From cebfb6dcab5c38e9c316ef58d4895809c6fba3b2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 8 Oct 2026 14:21:27 +0300 Subject: [PATCH 5/5] test(network): cover redaction of auth headers in responses Extend the response formatting test to include WWW-Authenticate, X-Api-Key and X-Service-Token headers, asserting their values are redacted and the raw secrets never appear in the output. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinytools-std/src/network/http_request_tests.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/tinytools-std/src/network/http_request_tests.rs b/crates/tinytools-std/src/network/http_request_tests.rs index 4b47020..e064ace 100644 --- a/crates/tinytools-std/src/network/http_request_tests.rs +++ b/crates/tinytools-std/src/network/http_request_tests.rs @@ -481,7 +481,7 @@ async fn a_hook_failure_is_returned_as_the_error_without_a_retry() { #[tokio::test] async fn a_body_is_sent_and_a_success_response_is_formatted_with_redacted_cookies() { - let ok = "HTTP/1.1 200 OK\r\nSet-Cookie: session=abc\r\nX-Other: 1\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello"; + let ok = "HTTP/1.1 200 OK\r\nSet-Cookie: session=abc\r\nWWW-Authenticate: Bearer secret-challenge\r\nX-Api-Key: api-secret\r\nX-Service-Token: service-secret\r\nX-Other: 1\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello"; let (addr, seen) = serve(vec![ok.to_string()]).await; let tool = test_tool(vec![]); let response = tool @@ -499,6 +499,16 @@ async fn a_body_is_sent_and_a_success_response_is_formatted_with_redacted_cookie assert!(text.contains("hello"), "{text}"); assert!(text.contains("set-cookie: ***REDACTED***"), "{text}"); assert!(!text.contains("session=abc"), "{text}"); + for secret in ["secret-challenge", "api-secret", "service-secret"] { + assert!(!text.contains(secret), "response leaked {secret}: {text}"); + } + for header in ["www-authenticate", "x-api-key", "x-service-token"] { + assert!( + text.to_ascii_lowercase() + .contains(&format!("{header}: ***redacted***")), + "{text}" + ); + } assert!(seen.lock().unwrap()[0].ends_with("payload")); }