Skip to main content

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}