From 657d1c21f51e96b89fce4f388d7a3d80ddcd4ca1 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Thu, 5 Nov 2026 07:47:15 +0100 Subject: [PATCH 1/2] fix(tiles): omit authenticated HEAD refusal details across logout --- crates/engine/src/tests/service_logins.rs | 102 ++++++++++++++++++++++ crates/tiles/src/cache.rs | 72 ++++++++++++++- docs/user-guide/data.md | 3 + 3 files changed, 175 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/service_logins.rs b/crates/engine/src/tests/service_logins.rs index 6437dc508..b4c745320 100644 --- a/crates/engine/src/tests/service_logins.rs +++ b/crates/engine/src/tests/service_logins.rs @@ -1278,3 +1278,105 @@ fn notebook_output_keeps_no_login_binding() { let (saved, _) = s.project.clone().saved_text(None).unwrap(); assert!(!saved.contains(rustgis_data::login_address::PREFIX), "the saved project keeps a binding: {saved}"); } + +/// The server has the credential before the lifecycle action, but its refusal arrives after it. +/// Exercise the public HEAD transport and the real session commands, without a credential store. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn delayed_head_refusal_keeps_secrets_out_of_history_across_login_lifecycle() { + use rustgis_data::diagnostics::{Filter, History}; + use std::io::{Read, Write}; + use std::sync::mpsc::channel; + use std::time::Duration; + + for action in ["active", "logout", "replace", "drop", "anonymous"] { + for basic in [false, true] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let secret = "delayed-head/sentinel+757"; + let value = if basic { logins::basic("head-user", secret) } else { secret.to_string() }; + let encoded = value.bytes().map(|b| format!("%{b:02X}")).collect::(); + let payload = value.strip_prefix("Basic ").unwrap_or(&value).to_string(); + let echo = format!("{value} {payload} {encoded}"); + let (received_tx, received_rx) = channel(); + let (release_tx, release_rx) = channel(); + let expected = value.clone(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + let mut request = Vec::new(); + let mut buf = [0u8; 2048]; + while request.len() < 32768 && !request.windows(4).any(|w| w == b"\r\n\r\n") { + let n = stream.read(&mut buf).unwrap(); + assert_ne!(n, 0); + request.extend_from_slice(&buf[..n]); + } + assert!(request.ends_with(b"\r\n\r\n")); + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with("HEAD /probe HTTP/1.1\r\n")); + if action == "anonymous" { + assert!(!request.to_ascii_lowercase().contains("x-key:") && !request.to_ascii_lowercase().contains("authorization:")); + } else { + let name = if basic { "authorization" } else { "x-key" }; + assert!(request.to_ascii_lowercase().contains(&format!("{name}: {}\r\n", expected.to_ascii_lowercase()))); + } + received_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + let id = if action == "anonymous" { "anonymous-request-id" } else { &echo }; + write!(stream, "HTTP/1.1 403 Forbidden\r\nx-request-id: {id}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").unwrap(); + }); + let mut s = session(None); + let id = new( + &mut s, + json!({"kind":"service", "address":base, "auth":if basic {"basic"} else {"header"}, "user":"head-user", "keyName":"X-Key"}), + ); + let credential = if basic { "password" } else { "apiKey" }; + let login = json!({"id":id, credential:secret, "allowInsecure":true}); + let plain = format!("{base}/probe"); + let url = if action == "anonymous" { + plain.clone() + } else { + s.run("connection.login", &login).unwrap(); + bound(&mut s, &id, &plain) + }; + let history = History::default(); + let _scope = history.enter(); + let (done_tx, done_rx) = channel(); + crate::render::rustgis_tiles::cache::head(&url, Box::new(move |r| done_tx.send(r).unwrap())); + received_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + match action { + "logout" => { + s.run("connection.logout", &json!({"id":id})).unwrap(); + } + "replace" => { + s.run("connection.login", &json!({"id":id,credential:"replacement-head-secret","allowInsecure":true})).unwrap(); + } + "drop" => drop(s), + _ => {} + } + if matches!(action, "logout" | "replace" | "drop") { + assert!(logins::apply(&url).is_err(), "{action} must revoke the old generation"); + } + release_tx.send(()).unwrap(); + assert!(done_rx.recv_timeout(Duration::from_secs(10)).unwrap().is_err()); + server.join().unwrap(); + let records = history.records(); + assert_eq!(records.len(), 1, "{action}: {records:?}"); + let network = records[0].network.as_ref().unwrap(); + assert_eq!(network.status, Some(403)); + assert_eq!(network.method, "HEAD"); + assert_eq!(network.url, plain); + let text = format!("{records:?}\n{}", history.report(Filter::All)); + assert!(text.contains("403"), "{action}: {text}"); + assert!(text.contains(&plain), "{action}: {text}"); + for private in [secret, &value, &payload, &encoded] { + assert!(!text.contains(private), "{action}: credential reached History or copied report"); + } + if action == "anonymous" { + assert!(text.contains("x-request-id: anonymous-request-id"), "{text}"); + } else { + assert!(!text.contains("x-request-id"), "{action}: {text}"); + } + } + } +} diff --git a/crates/tiles/src/cache.rs b/crates/tiles/src/cache.rs index 07ecdf8ac..84e891e6f 100644 --- a/crates/tiles/src/cache.rs +++ b/crates/tiles/src/cache.rs @@ -1408,8 +1408,14 @@ mod native { continue; } if resp.status().as_u16() >= 400 { - // The selected headers say who refused and what it wants: kept, as a GET's - // refusal keeps them, so the history's details aren't only a status. + // A server can echo credentials in retained headers, even after logout has + // removed their redaction patterns. Remember every hop, including before a + // cross-origin redirect: authenticated refusals keep only the status. + if carried_login { + let status = resp.status().as_u16(); + return Err(Failure::without_answer("", format!("http status: {status}"), Reason::Status(status))); + } + // Anonymous refusals retain the selected diagnostic headers. let headers: Vec<(String, String)> = resp .headers() .iter() @@ -2442,6 +2448,68 @@ mod tests { assert!(logins::deactivate(&binding)); } + /// Once any hop carried a login, a later anonymous hop must not regain response details. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn delayed_head_refusal_after_cross_origin_redirect_is_status_only() { + use crate::logins::{self, Method}; + use rustgis_data::diagnostics::{Capture, Filter, History}; + use std::io::{Read, Write}; + use std::sync::mpsc::channel; + use std::time::Duration; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let other = format!("http://{}", listener.local_addr().unwrap()); + let secret = "redirect-head-sentinel-757"; + let (received_tx, received_rx) = channel(); + let (release_tx, release_rx) = channel(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + let mut request = Vec::new(); + let mut buf = [0u8; 2048]; + while request.len() < 32768 && !request.windows(4).any(|w| w == b"\r\n\r\n") { + let n = stream.read(&mut buf).unwrap(); + assert_ne!(n, 0); + request.extend_from_slice(&buf[..n]); + } + assert!(request.ends_with(b"\r\n\r\n")); + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with("HEAD /refused HTTP/1.1\r\n")); + assert!(!request.to_ascii_lowercase().contains("x-key:") && !request.contains(secret)); + received_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + write!(stream, "HTTP/1.1 403 Forbidden\r\nx-request-id: {secret}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").unwrap(); + }); + let (base, asked) = serve_recorded(vec![ + format!("HTTP/1.1 302 Found\r\nLocation: {other}/refused\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").into_bytes(), + ]); + let binding = logins::activate(&base, "redirect-head", Method::Header("X-Key".into()), secret, true).unwrap(); + let foreign = logins::activate(&other, "foreign-head", Method::Header("X-Key".into()), "foreign-head-secret", true).unwrap(); + let url = logins::bind(&binding, &format!("{base}/start")).unwrap(); + let history = History::default(); + let _scope = history.enter(); + let capture = Capture::current(); + let worker = std::thread::spawn(move || { + let _scope = capture.enter(); + native::head_with(&native::agent(), &url) + }); + let request = asked.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(request.to_ascii_lowercase().contains(&format!("x-key: {secret}\r\n"))); + received_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(logins::deactivate(&binding)); + release_tx.send(()).unwrap(); + assert_eq!(worker.join().unwrap().unwrap_err(), "http status: 403"); + server.join().unwrap(); + let records = history.records(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].network.as_ref().unwrap().status, Some(403)); + let text = format!("{records:?}\n{}", history.report(Filter::All)); + assert!(text.contains("403")); + assert!(!text.contains(secret) && !text.contains("x-request-id") && !text.contains("foreign-head-secret")); + assert!(logins::deactivate(&foreign)); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn logins_head_and_post_carry_the_login() { diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 3e33ef619..d052412d8 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -816,6 +816,9 @@ unchanged and reported. A plain `http://` address sends the login unencrypted, so the form asks for confirmation and the command needs `"allowInsecure": true`. Error messages, notices, the preferences, projects, History and what the [AI Assistant](assistant.md) sees never hold the password or key. +On the desktop, a failed HEAD request that carried a web-service login records the HTTP status +without response headers, including after logout or a redirect to another origin: a server could +echo credentials in those headers. Anonymous HEAD failures keep their selected diagnostic headers. Runtime login bindings are also removed from recorded command parameters, including addresses embedded in text, when saving, recovering or exporting them. The live session keeps its originals. From 5173ba03744f174e438c770e77fef64956d63561 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Thu, 5 Nov 2026 08:14:21 +0100 Subject: [PATCH 2/2] test(tiles): isolate delayed HEAD privacy regressions --- crates/engine/src/tests/service_logins.rs | 23 ++++++++++++++++++++++- crates/tiles/src/cache.rs | 6 ++++-- docs/user-guide/data.md | 3 ++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests/service_logins.rs b/crates/engine/src/tests/service_logins.rs index b4c745320..b700d1f54 100644 --- a/crates/engine/src/tests/service_logins.rs +++ b/crates/engine/src/tests/service_logins.rs @@ -1289,6 +1289,25 @@ fn delayed_head_refusal_keeps_secrets_out_of_history_across_login_lifecycle() { use std::sync::mpsc::channel; use std::time::Duration; + if std::env::var_os("RUSTGIS_TEST_SERVICE_HEAD_DIAGNOSTICS").is_none() { + // Other engine tests can fill the native HEAD admission limit and skip this probe. + // Isolate the scenario so every iteration exercises the real public HEAD transport. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "tests::service_logins::delayed_head_refusal_keeps_secrets_out_of_history_across_login_lifecycle", + "--nocapture", + "--test-threads=1", + ]) + .env("RUSTGIS_TEST_SERVICE_HEAD_DIAGNOSTICS", "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "{stdout}\n{}", String::from_utf8_lossy(&output.stderr)); + assert!(stdout.contains("1 passed; 0 failed"), "the child must run the delayed-HEAD test: {stdout}"); + return; + } + for action in ["active", "logout", "replace", "drop", "anonymous"] { for basic in [false, true] { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -1318,7 +1337,9 @@ fn delayed_head_refusal_keeps_secrets_out_of_history_across_login_lifecycle() { assert!(!request.to_ascii_lowercase().contains("x-key:") && !request.to_ascii_lowercase().contains("authorization:")); } else { let name = if basic { "authorization" } else { "x-key" }; - assert!(request.to_ascii_lowercase().contains(&format!("{name}: {}\r\n", expected.to_ascii_lowercase()))); + assert!(request.lines().any(|line| { + line.split_once(':').is_some_and(|(n, v)| n.eq_ignore_ascii_case(name) && v.trim_start() == expected) + })); } received_tx.send(()).unwrap(); release_rx.recv_timeout(Duration::from_secs(10)).unwrap(); diff --git a/crates/tiles/src/cache.rs b/crates/tiles/src/cache.rs index 84e891e6f..d6dc6b792 100644 --- a/crates/tiles/src/cache.rs +++ b/crates/tiles/src/cache.rs @@ -1410,7 +1410,7 @@ mod native { if resp.status().as_u16() >= 400 { // A server can echo credentials in retained headers, even after logout has // removed their redaction patterns. Remember every hop, including before a - // cross-origin redirect: authenticated refusals keep only the status. + // cross-origin redirect: HEAD refusals keep only the status after any login hop. if carried_login { let status = resp.status().as_u16(); return Err(Failure::without_answer("", format!("http status: {status}"), Reason::Status(status))); @@ -2499,7 +2499,9 @@ mod tests { received_rx.recv_timeout(Duration::from_secs(10)).unwrap(); assert!(logins::deactivate(&binding)); release_tx.send(()).unwrap(); - assert_eq!(worker.join().unwrap().unwrap_err(), "http status: 403"); + // Other parallel tests can register short credentials that scrub ordinary message + // words. Check the failure status here and its structured diagnostic status below. + assert!(worker.join().unwrap().unwrap_err().contains("403")); server.join().unwrap(); let records = history.records(); assert_eq!(records.len(), 1); diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index d052412d8..726b5399b 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -818,7 +818,8 @@ and the command needs `"allowInsecure": true`. Error messages, notices, the pref projects, History and what the [AI Assistant](assistant.md) sees never hold the password or key. On the desktop, a failed HEAD request that carried a web-service login records the HTTP status without response headers, including after logout or a redirect to another origin: a server could -echo credentials in those headers. Anonymous HEAD failures keep their selected diagnostic headers. +echo credentials in those headers. HEAD requests that never carried a login keep their selected +diagnostic headers on refusal. Runtime login bindings are also removed from recorded command parameters, including addresses embedded in text, when saving, recovering or exporting them. The live session keeps its originals.