netget/client/xmlrpc/response_guard.rs
1//! Bounds on what an XML-RPC server is allowed to answer with, applied before the `xmlrpc`
2//! crate's parser ever sees the bytes.
3//!
4//! # Why this exists
5//!
6//! `xmlrpc` 0.15's `Parser::parse_value` → `parse_value_inner` → `parse_value` recurses once
7//! per `<value>` element with **no depth counter**, and the crate reads the whole response
8//! body with no size limit. `<value><array><data>` is about twenty bytes per level, so a
9//! hostile or compromised server can answer the very first call with a few megabytes that
10//! drive the parser tens of thousands of frames deep. A Rust stack overflow is a `SIGSEGV`
11//! against the guard page, not a panic: `spawn_blocking` cannot contain it, `catch_unwind`
12//! cannot see it, and **the whole NetGet process dies**.
13//!
14//! # The seam
15//!
16//! `Request::call_url` owns both the fetch and the parse, so there is no way in through it —
17//! but `Request::call` takes any [`xmlrpc::Transport`], and `Transport` is a public trait with
18//! an associated `Stream: Read` that anything can implement. (The reqwest 0.11
19//! `RequestBuilder` is one *provided* implementation of it, not the signature; this file's
20//! own doc comment used to say otherwise and that reading is what left the bug open.)
21//!
22//! So NetGet fetches the response itself with its own reqwest 0.12 client, refuses it if it
23//! is too large or too deeply nested, and hands the crate a [`PrefetchedTransport`] wrapping
24//! bytes that are already known to be safe to parse. The crate still does the parsing, so
25//! fault handling, type coverage and error shapes are unchanged.
26//!
27//! # What it refuses
28//!
29//! - A body larger than [`MAX_RESPONSE_BYTES`], measured as it streams in, so the cap is never
30//! exceeded even transiently.
31//! - Element nesting deeper than [`MAX_ELEMENT_DEPTH`].
32//! - A body `quick-xml` cannot scan at all. We cannot certify a depth we could not measure,
33//! so an unscannable body is refused rather than passed through hopefully. A response that
34//! is not well-formed XML would fail in the crate's parser anyway.
35//!
36//! Refuse, never truncate: a response cut off at the cap is indistinguishable from a complete
37//! one once it has been parsed, and the model would be told the server said something it did
38//! not say. Each refusal carries a stable `decision=fail_closed_*` tag, in the shape
39//! `src/server/radius/` established.
40//!
41//! # What this does not cover
42//!
43//! [`super::MAX_VALUE_DEPTH`] bounds NetGet's own recursive walk of the parsed value. That is
44//! a second, independent way for a deep reply to kill the process, and it only ever mattered
45//! once the crate's parser had survived. Both bounds are needed; neither replaces the other.
46
47use std::error::Error;
48use std::io::Cursor;
49use std::time::Duration;
50
51/// Largest XML-RPC response body NetGet will read.
52///
53/// XML-RPC replies are small by nature — `system.listMethods` on a large server is tens of
54/// kilobytes. 8 MiB is far past any real payload and still small enough that scanning it costs
55/// milliseconds. It is a backstop, not the main guard: at ~20 bytes per nesting level even
56/// 1 MiB would buy ~50 000 parser frames, which is why [`MAX_ELEMENT_DEPTH`] exists.
57pub const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
58
59/// Deepest XML element nesting NetGet will hand to the crate's parser.
60///
61/// The parser recurses once per `<value>`, and one XML-RPC value level costs at least three
62/// elements (`<value><array><data>`), so this admits roughly 64 nested values — the same
63/// ceiling [`super::MAX_VALUE_DEPTH`] puts on NetGet's own walk of the result, so anything the
64/// scan lets through is something the conversion can also handle. Real XML-RPC APIs are a
65/// handful of levels deep. 256 parser frames is three orders of magnitude below the stack a
66/// blocking-pool thread has.
67pub const MAX_ELEMENT_DEPTH: usize = 256;
68
69/// Why a server's answer was refused before it was parsed.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub enum ResponseRefusal {
72 /// The body passed [`MAX_RESPONSE_BYTES`] while streaming in.
73 TooLarge { seen: usize },
74 /// Element nesting passed [`MAX_ELEMENT_DEPTH`].
75 TooDeep { depth: usize },
76 /// The body could not be scanned, so its depth is unknown.
77 Unscannable { detail: String },
78}
79
80impl ResponseRefusal {
81 /// A stable tag an operator can grep for.
82 pub fn decision_tag(&self) -> &'static str {
83 match self {
84 Self::TooLarge { .. } => "fail_closed_response_too_large",
85 Self::TooDeep { .. } => "fail_closed_response_too_deep",
86 Self::Unscannable { .. } => "fail_closed_response_unscannable",
87 }
88 }
89}
90
91impl std::fmt::Display for ResponseRefusal {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 match self {
94 Self::TooLarge { seen } => write!(
95 f,
96 "server response exceeded {} bytes (stopped at {})",
97 MAX_RESPONSE_BYTES, seen
98 ),
99 Self::TooDeep { depth } => write!(
100 f,
101 "server response nested deeper than {} XML elements (reached {})",
102 MAX_ELEMENT_DEPTH, depth
103 ),
104 Self::Unscannable { detail } => write!(
105 f,
106 "server response could not be scanned for nesting depth: {}",
107 detail
108 ),
109 }
110 }
111}
112
113impl std::error::Error for ResponseRefusal {}
114
115/// Measure element nesting without building a document, and refuse anything too deep.
116///
117/// Returns the deepest nesting seen. `quick-xml` does not expand entities and treats a DTD
118/// internal subset as opaque text, so this scan cannot itself be turned into an amplification
119/// — and neither can the crate's own parser, whose `xml-rs` backend rejects every entity
120/// outside the five predefined ones.
121///
122/// End-name checking is off on purpose: mismatched tags are the real parser's business to
123/// reject, and turning them into a scan error here would refuse responses on a question this
124/// function has no opinion about. A stray close tag still decrements the depth, which is the
125/// conservative direction.
126pub fn scan_depth(body: &[u8]) -> Result<usize, ResponseRefusal> {
127 use quick_xml::events::Event;
128
129 let mut reader = quick_xml::Reader::from_reader(body);
130 reader.config_mut().check_end_names = false;
131
132 let mut buf = Vec::new();
133 let mut depth: usize = 0;
134 let mut deepest: usize = 0;
135
136 loop {
137 match reader.read_event_into(&mut buf) {
138 Ok(Event::Start(_)) => {
139 depth += 1;
140 deepest = deepest.max(depth);
141 if depth > MAX_ELEMENT_DEPTH {
142 return Err(ResponseRefusal::TooDeep { depth });
143 }
144 }
145 Ok(Event::Empty(_)) => {
146 // A leaf. It occupies one level but closes immediately.
147 deepest = deepest.max(depth + 1);
148 if depth + 1 > MAX_ELEMENT_DEPTH {
149 return Err(ResponseRefusal::TooDeep { depth: depth + 1 });
150 }
151 }
152 Ok(Event::End(_)) => {
153 depth = depth.saturating_sub(1);
154 }
155 Ok(Event::Eof) => break,
156 Ok(_) => {}
157 Err(e) => {
158 return Err(ResponseRefusal::Unscannable {
159 detail: e.to_string(),
160 })
161 }
162 }
163 buf.clear();
164 }
165
166 Ok(deepest)
167}
168
169/// Read a response body, refusing at [`MAX_RESPONSE_BYTES`] rather than buffering past it.
170///
171/// The check is per chunk as it arrives, so a server advertising a small `Content-Length` and
172/// then sending gigabytes gets no further than the cap.
173pub async fn read_body_capped(response: reqwest::Response) -> Result<Vec<u8>, anyhow::Error> {
174 let mut response = response;
175 let mut body: Vec<u8> = Vec::new();
176 while let Some(chunk) = response.chunk().await? {
177 if body.len() + chunk.len() > MAX_RESPONSE_BYTES {
178 return Err(ResponseRefusal::TooLarge {
179 seen: body.len() + chunk.len(),
180 }
181 .into());
182 }
183 body.extend_from_slice(&chunk);
184 }
185 Ok(body)
186}
187
188/// The header checks `xmlrpc::transport::http::check_response` performs, reproduced against
189/// reqwest 0.12 so replacing the transport does not quietly relax them.
190///
191/// A 4xx/5xx is an error, and a `Content-Type` that is present and parseable must be
192/// `text/xml`. A missing or unparseable header is ignored, exactly as the crate ignores it.
193pub fn check_response(response: &reqwest::Response) -> Result<(), anyhow::Error> {
194 let status = response.status();
195 if status.is_client_error() || status.is_server_error() {
196 anyhow::bail!("server response indicates error: {}", status);
197 }
198
199 if let Some(value) = response
200 .headers()
201 .get(reqwest::header::CONTENT_TYPE)
202 .and_then(|v| v.to_str().ok())
203 {
204 let essence = value
205 .split(';')
206 .next()
207 .unwrap_or("")
208 .trim()
209 .to_ascii_lowercase();
210 if let Some((ty, sub)) = essence.split_once('/') {
211 if !(ty == "text" && sub == "xml") {
212 anyhow::bail!("expected Content-Type 'text/xml', got '{}/{}'", ty, sub);
213 }
214 }
215 }
216
217 Ok(())
218}
219
220/// An [`xmlrpc::Transport`] that answers with bytes NetGet has already fetched and screened.
221///
222/// The `Request` argument is ignored: it was serialised and sent before this point, which is
223/// the whole reason the fetch could be bounded at all.
224#[derive(Debug)]
225pub struct PrefetchedTransport {
226 body: Vec<u8>,
227}
228
229impl PrefetchedTransport {
230 /// Wrap a body that [`scan_depth`] and [`read_body_capped`] have already accepted.
231 pub fn new(body: Vec<u8>) -> Self {
232 Self { body }
233 }
234}
235
236impl xmlrpc::Transport for PrefetchedTransport {
237 type Stream = Cursor<Vec<u8>>;
238
239 fn transmit(
240 self,
241 _request: &xmlrpc::Request<'_>,
242 ) -> Result<Self::Stream, Box<dyn Error + Send + Sync>> {
243 Ok(Cursor::new(self.body))
244 }
245}
246
247/// The HTTP client used for one XML-RPC endpoint.
248///
249/// Built once per (host, timeout) and cached. Building a `reqwest::Client` loads the platform
250/// root store — on macOS that reads the keychain through Security.framework, synchronously and
251/// serialised across processes — so it is built on the blocking pool and never on a request
252/// path. `client_for_endpoint_with_timeout` additionally skips the system resolver when the
253/// host is a literal IP, which `getaddrinfo("127.0.0.1")` makes worth doing.
254pub async fn http_client_for(server_url: &str, timeout: Duration) -> reqwest::Client {
255 use std::collections::HashMap;
256 use std::sync::{Mutex, OnceLock};
257
258 static CACHE: OnceLock<Mutex<HashMap<(String, u64), reqwest::Client>>> = OnceLock::new();
259 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
260
261 let key = (
262 crate::llm::ollama_client::host_of(server_url).to_string(),
263 timeout.as_secs(),
264 );
265
266 if let Ok(guard) = cache.lock() {
267 if let Some(client) = guard.get(&key) {
268 return client.clone();
269 }
270 }
271
272 let url = server_url.to_string();
273 let built = tokio::task::spawn_blocking(move || {
274 crate::llm::ollama_client::client_for_endpoint_with_timeout(&url, timeout)
275 })
276 .await
277 .unwrap_or_else(|_| reqwest::Client::new());
278
279 if let Ok(mut guard) = cache.lock() {
280 // A concurrent build is possible and harmless - both clients are equivalent.
281 guard.insert(key, built.clone());
282 }
283 built
284}