cratefield_runtime_cloudflare/ports/http.rs
1//! `HttpClient` over `worker::Fetch`.
2//!
3//! # A failure message here never carries the request URL
4//!
5//! This is the production path for every venture on this harness, and the
6//! runtime whose errors reach Workers Logs through `console_error!`. A
7//! `worker::Error` is not safe to stringify: `Error::JsError(s)` and
8//! `Error::UnknownJsError { .. }` carry workerd's own message, and workerd
9//! names the destination it could not reach — "Fetch API cannot load:
10//! `<the whole URL>`".
11//!
12//! For some callers that URL *is* the credential. An APNs request path is
13//! the device token (`/3/device/<token>`) and a Web Push endpoint is a
14//! bearer capability — whoever holds it can push to that browser
15//! indefinitely, which is why [`cratefield_core::Recipient`] prints
16//! fingerprints and says the value must appear "never in a log, an event
17//! payload, or an error body". A transport failure would otherwise write
18//! that credential into Workers Logs while the caller two lines away is
19//! carefully printing a fingerprint (issue #229; the native port was the
20//! same leak, issue #228).
21//!
22//! So no error is ever stringified whole here. Every failure goes through
23//! [`transport`], which keeps the destination's origin, drops its path, and
24//! then hands the result to [`cratefield_core::scrub_text`] for every other
25//! shape of secret a layer below might have quoted.
26
27use std::fmt::Write as _;
28
29use async_trait::async_trait;
30use bytes::Bytes;
31use cratefield_core::{HttpClient, HttpError, HttpPolicy, scrub_request_url, scrub_text};
32use worker::send::IntoSendFuture;
33use worker::{Fetch, Headers, Method, Request as WorkerRequest, RequestInit};
34
35/// The Workers implementation of the [`HttpClient`] port: one `fetch` per
36/// send, through the isolate's own stack.
37pub struct FetchClient;
38
39#[async_trait]
40impl HttpClient for FetchClient {
41 async fn send(
42 &self,
43 request: http::Request<Bytes>,
44 ) -> Result<http::Response<Bytes>, HttpError> {
45 let policy = HttpPolicy::of_request(&request);
46 let (parts, body) = request.into_parts();
47 // Kept for the whole exchange: it is what every failure below is
48 // scrubbed against, and the only thing here that knows which parts
49 // of a message are this request's URL.
50 let url = parts.uri.to_string();
51 let mut init = RequestInit::new();
52 init.method = Method::from(parts.method.as_str().to_string());
53 let headers = Headers::new();
54 for (name, value) in &parts.headers {
55 let _ = headers.set(name.as_str(), value.to_str().unwrap_or_default());
56 }
57 init.headers = headers;
58
59 // A body is attached only when there is one. The Fetch spec refuses
60 // to construct a Request whose method is GET or HEAD and whose body
61 // is non-null, and an empty JS string is not null: setting it
62 // unconditionally makes every GET through this port throw a
63 // TypeError before it leaves the isolate.
64 //
65 // Nothing caught it because every adapter shipped so far POSTs
66 // (Resend, Turnstile). The first GET consumer is OpenID Connect
67 // discovery in the `auth-oidc` crate, which fetches a configuration
68 // document and a JWKS.
69 if !body.is_empty() {
70 // The port's adapters send JSON bodies; non-UTF-8 is a hard
71 // error rather than a lossy corruption.
72 let body_text =
73 String::from_utf8(body.to_vec()).map_err(|err| transport(&err, &url))?;
74 init.with_body(Some(worker::wasm_bindgen::JsValue::from_str(&body_text)));
75 }
76 let worker_request =
77 WorkerRequest::new_with_init(&url, &init).map_err(|err| transport(&err, &url))?;
78
79 let mut response = Fetch::Request(worker_request)
80 .send()
81 .into_send()
82 .await
83 .map_err(|err| transport(&err, &url))?;
84
85 // `bytes()` buffers the whole body, so refuse a declared body the
86 // cap has already out before allocating it (issue #136).
87 let declared = response
88 .headers()
89 .get("content-length")
90 .ok()
91 .flatten()
92 .and_then(|length| length.trim().parse::<usize>().ok());
93 if declared.is_some_and(|declared| declared > policy.max_response_bytes) {
94 return Err(HttpError::ResponseTooLarge {
95 limit: policy.max_response_bytes,
96 });
97 }
98
99 let mut builder = http::Response::builder().status(response.status_code());
100 let response_headers = response.headers();
101 if let Some(headers) = builder.headers_mut() {
102 for (name, value) in response_headers {
103 if let (Ok(name), Ok(value)) = (
104 http::HeaderName::try_from(name.as_str()),
105 http::HeaderValue::try_from(value.as_str()),
106 ) {
107 headers.insert(name, value);
108 }
109 }
110 }
111 let bytes = response
112 .bytes()
113 .into_send()
114 .await
115 .map_err(|err| transport(&err, &url))?;
116 builder
117 .body(Bytes::from(bytes))
118 .map_err(|err| transport(&err, &url))
119 }
120}
121
122/// The only place in this module that builds an [`HttpError::Transport`],
123/// so a sixth failure site cannot be added that reports a raw error string
124/// — which is exactly how the first five came to (issue #229).
125fn transport(err: &dyn std::error::Error, url: &str) -> HttpError {
126 HttpError::Transport(safe_message(err, url))
127}
128
129/// An error as a message this crate may hand on — to [`HttpError`], and
130/// from there to `console_error!`, a dead-letter row and an operator's
131/// terminal.
132///
133/// Three things happen here, and the last two are why this is not
134/// `err.to_string()`:
135///
136/// 1. **The causes go in.** `worker::Error`'s `Display` is deliberately
137/// thin for every variant that wraps another error — `Error::Io` prints
138/// "I/O error", `Error::Http` prints "HTTP error" — because the crate
139/// expects consumers to walk `source()`, which it implements for the
140/// wrapped Rust errors *and* for a JS error's `cause` chain. Asking
141/// `Display` alone therefore throws away the half that says what went
142/// wrong. This is the same thing the native port learned about
143/// `reqwest::Error` (issue #228), and it means redaction makes these
144/// messages more diagnosable, not less.
145/// 2. **The URL comes out.** With the chain in, a workerd fetch failure
146/// reads "Fetch API cannot load: `<the whole URL>`", path and query
147/// included — see the module note for why that is a disclosure and not
148/// a detail. [`cratefield_core::scrub_request_url`] cuts it back to the
149/// origin, and redacts the bare request target a cause is free to quote
150/// on its own.
151/// 3. **Everything else is scrubbed too.** A cause may quote a `Bearer`
152/// credential, a signed token or an address that has nothing to do with
153/// this request's URL, so the result goes through
154/// [`cratefield_core::scrub_text`] — the same pass every log field takes
155/// (issue #135).
156fn safe_message(err: &dyn std::error::Error, url: &str) -> String {
157 let mut message = err.to_string();
158 let mut cause = err.source();
159 while let Some(current) = cause {
160 let _ = write!(message, ": {current}");
161 cause = current.source();
162 }
163 scrub_text(&scrub_request_url(&message, url))
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 /// The APNs shape, because it is the worst case: the device token is
171 /// the request *path*, so a message that quotes the URL publishes the
172 /// credential.
173 const TOKEN: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff";
174
175 fn apns_url() -> String {
176 format!("https://api.push.apple.com/3/device/{TOKEN}")
177 }
178
179 fn assert_safe(message: &str) {
180 assert!(
181 !message.contains(TOKEN),
182 "the device token leaked: {message}"
183 );
184 assert!(
185 !message.contains("/3/device"),
186 "the request path leaked: {message}"
187 );
188 }
189
190 /// What workerd actually throws when a fetch cannot be completed: a
191 /// `TypeError` whose message names the destination in full. Arriving
192 /// as `Error::JsError` (a thrown string) or inside `UnknownJsError`'s
193 /// message, it is the same disclosure either way.
194 #[test]
195 fn a_workerd_fetch_failure_names_the_origin_and_never_the_request_path() {
196 let url = apns_url();
197 let err = worker::Error::JsError(format!("TypeError: Fetch API cannot load: {url}."));
198
199 let message = safe_message(&err, &url);
200
201 assert_safe(&message);
202 // And it is still an error somebody can act on: which host, and
203 // what the runtime actually said.
204 assert!(
205 message.contains("https://api.push.apple.com"),
206 "the destination is named: {message}"
207 );
208 assert!(
209 message.contains("Fetch API cannot load"),
210 "the cause survives the redaction: {message}"
211 );
212 }
213
214 /// The property the native port found and this one had to be checked
215 /// for separately: `Display` is nearly empty for the variants that
216 /// wrap another error, and the detail lives in `source()`. Walking the
217 /// chain is what makes the redacted message *better* than the leaky
218 /// one — and a cause quotes whatever it was handed, so it is scrubbed
219 /// too.
220 #[test]
221 fn the_cause_chain_is_walked_and_scrubbed() {
222 let url = apns_url();
223 let inner =
224 std::io::Error::other(format!("connection refused sending to /3/device/{TOKEN}"));
225 let err = worker::Error::Io(inner);
226
227 assert_eq!(
228 err.to_string(),
229 "I/O error",
230 "if `Display` ever carries the cause, this pass can stop appending it"
231 );
232
233 let message = safe_message(&err, &url);
234
235 assert_safe(&message);
236 assert!(
237 message.contains("connection refused"),
238 "the cause is what makes this diagnosable: {message}"
239 );
240 }
241
242 /// `scrub_text`'s own rules still apply on top: a cause is free to
243 /// quote a credential that has nothing to do with this request's URL.
244 #[test]
245 fn a_secret_a_cause_quotes_is_scrubbed_even_when_it_is_not_the_url() {
246 let url = apns_url();
247 let err = worker::Error::JsError(
248 "rejected the Authorization: Bearer abcdefghijklmnop12345 we sent".to_owned(),
249 );
250
251 let message = safe_message(&err, &url);
252
253 assert!(
254 !message.contains("abcdefghijklmnop12345"),
255 "scrub_text is the second net: {message}"
256 );
257 }
258
259 /// Every failure site in `send` hands a different error type to
260 /// [`transport`], and each one is a `Transport` variant whose message
261 /// has been through the redaction — including the two that are not
262 /// `worker::Error` at all.
263 #[test]
264 fn every_error_type_a_failure_site_produces_is_redacted() {
265 let url = apns_url();
266 let quoting = |what: &str| format!("{what} at {url}");
267
268 // 1. A non-UTF-8 request body (`String::from_utf8`).
269 let utf8 = String::from_utf8(vec![0xff, 0xfe]).expect_err("not UTF-8");
270 // 2-4. Request construction, `Fetch::send`, and reading the body,
271 // all of which fail as `worker::Error` — as a thrown JS
272 // string, and as a wrapped Rust error with a cause.
273 let js = worker::Error::JsError(quoting("TypeError: Fetch API cannot load:"));
274 let rust = worker::Error::RustError(quoting("could not construct a request"));
275 let io = worker::Error::Io(std::io::Error::other(quoting("network connection lost")));
276 // 5. Building the `http::Response` back (`http::Error`).
277 let http_err = http::Response::builder()
278 .status(200)
279 .header("x-\u{0}bad", "v")
280 .body(())
281 .expect_err("an invalid header name is a builder error");
282
283 let sites: [&dyn std::error::Error; 5] = [&utf8, &js, &rust, &io, &http_err];
284 for err in sites {
285 let HttpError::Transport(message) = transport(err, &url) else {
286 panic!("every site reports a transport failure");
287 };
288 assert_safe(&message);
289 }
290 }
291
292 /// The five sites were five copies of `err.to_string()`, which is how
293 /// one leak became five. Only one of them may build the error now, so
294 /// a sixth site added later cannot quietly reintroduce the pattern:
295 /// the module has exactly one `HttpError::Transport(` in it, in
296 /// [`transport`].
297 #[test]
298 fn only_one_place_in_this_module_builds_a_transport_error() {
299 let source = include_str!("http.rs");
300 let module = source
301 .split_once("#[cfg(test)]")
302 .map_or(source, |(before, _)| before);
303
304 assert_eq!(
305 module.matches("HttpError::Transport(").count(),
306 1,
307 "a failure site must call `transport`, not build the error itself"
308 );
309 }
310}