courierust 1.0.7

A bare-metal no_std HTTP and gRPC engine with work-stealing, RFC 9218, and JA3/JA4 fingerprinting.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
//! Outbound proxying: `CONNECT` tunnels (RFC 9110 §9.3.6) for secure
//! targets, the absolute request form (RFC 9112 §3.2.2) for plaintext
//! ones.
//!
//! The proxy in these tests is written with the standard library only and
//! knows nothing about the client: it speaks the standards and records
//! the request lines it was asked to serve. That record is the evidence.
//! A client that quietly skipped the proxy — or one that sent its
//! `Proxy-Authorization` on past the tunnel to the origin — would still
//! return `200`, so the assertions are about what each hop actually saw.

mod common;

use std::io::{Read, Write};
use std::net::{Shutdown, TcpListener, TcpStream};
use std::sync::{Arc, Mutex};

use courierust::courierust_body::Body;
use courierust::courierust_client::{Client, ClientConfig, Proxy, TlsSettings as ClientTls};
use courierust::courierust_http::header::{HeaderName, HeaderValue};
use courierust::courierust_http::method::Method;
use courierust::courierust_http::request::Request;
use courierust::courierust_http::response::Response;
use courierust::courierust_http::uri::PathAndQuery;
use courierust::courierust_server::{Server, ServerConfig, TlsSettings as ServerTls};

/// What a hop was asked to do, in order.
#[derive(Clone, Default)]
struct Seen(Arc<Mutex<Vec<String>>>);

impl Seen {
    fn record(&self, line: &str) {
        self.0.lock().unwrap().push(line.to_string());
    }

    fn lines(&self) -> Vec<String> {
        self.0.lock().unwrap().clone()
    }
}

/// How the test proxy answers.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Reply {
    Serve,
    Refuse,
}

fn spawn_proxy(seen: Seen, reply: Reply) -> std::net::SocketAddr {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let addr = listener.local_addr().unwrap();
    std::thread::spawn(move || {
        for stream in listener.incoming() {
            let Ok(stream) = stream else { continue };
            let seen = seen.clone();
            std::thread::spawn(move || serve_proxy_connection(stream, &seen, reply));
        }
    });
    addr
}

/// Serve one proxied connection: a `CONNECT` is answered and then relayed
/// byte for byte, any other request is forwarded to the origin named by
/// its absolute-form target after rewriting that target to the origin-form
/// a server expects.
fn serve_proxy_connection(mut down: TcpStream, seen: &Seen, reply: Reply) {
    let Some(head) = read_head(&mut down) else {
        return;
    };
    let text = String::from_utf8_lossy(&head).to_string();
    let request_line = text.lines().next().unwrap_or_default().to_string();
    seen.record(&request_line);
    // One line per credential field, value included: a handshake that
    // carries two `Proxy-Authorization` fields — a configured one and the
    // request's own — is exactly the bug this records, and only the value
    // shows which of them survived.
    for line in text.lines().skip(1) {
        if let Some((name, value)) = line.split_once(':') {
            if name.eq_ignore_ascii_case("proxy-authorization") {
                seen.record(&format!("proxy-authorization: {}", value.trim()));
            }
        }
    }
    if reply == Reply::Refuse {
        let _ = down.write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n");
        return;
    }

    let target = request_line
        .split(' ')
        .nth(1)
        .unwrap_or_default()
        .to_string();
    let tunnel = request_line.starts_with("CONNECT ");
    let dial = if tunnel {
        target.clone()
    } else {
        authority_form(&target).to_string()
    };
    let Ok(mut up) = TcpStream::connect(dial) else {
        let _ = down.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n");
        return;
    };

    if tunnel {
        if down
            .write_all(b"HTTP/1.1 200 Connection established\r\n\r\n")
            .is_err()
        {
            return;
        }
    } else {
        let rewritten = text.replacen(&target, origin_form(&target), 1);
        if up.write_all(rewritten.as_bytes()).is_err() {
            return;
        }
    }
    relay(down, up);
}

/// The authority (`host:port`) of an absolute request target.
fn authority_form(target: &str) -> &str {
    let rest = target
        .split_once("://")
        .map(|(_, rest)| rest)
        .unwrap_or(target);
    rest.split('/').next().unwrap_or_default()
}

/// The origin-form target (`/path?query`) of an absolute request target.
fn origin_form(target: &str) -> &str {
    let rest = target
        .split_once("://")
        .map(|(_, rest)| rest)
        .unwrap_or(target);
    match rest.find('/') {
        Some(at) => &rest[at..],
        None => "/",
    }
}

/// Read exactly one request head: the first byte after it belongs to one
/// of the two peers, so nothing past the terminator may be consumed.
fn read_head(stream: &mut TcpStream) -> Option<Vec<u8>> {
    let mut head = Vec::new();
    let mut byte = [0u8; 1];
    loop {
        match stream.read(&mut byte) {
            Ok(1) => head.push(byte[0]),
            _ => return None,
        }
        if head.ends_with(b"\r\n\r\n") {
            return Some(head);
        }
        if head.len() > 16 * 1024 {
            return None;
        }
    }
}

/// Relay both directions until either side stops.
fn relay(down: TcpStream, up: TcpStream) {
    let down_read = down.try_clone().expect("clone the client side");
    let up_read = up.try_clone().expect("clone the origin side");
    let upload = std::thread::spawn(move || {
        let (mut from, mut to) = (down_read, up);
        let _ = std::io::copy(&mut from, &mut to);
        let _ = to.shutdown(Shutdown::Write);
    });
    let (mut from, mut to) = (up_read, down);
    let _ = std::io::copy(&mut from, &mut to);
    let _ = to.shutdown(Shutdown::Write);
    let _ = upload.join();
}

/// An origin that echoes the request and records whether a
/// `Proxy-Authorization` field reached it.
fn spawn_origin(config: ServerConfig, seen: Seen, secure: bool) -> String {
    let handler = move |req: Request<Body>| -> Response<Body> {
        seen.record(if req.headers.contains_key("proxy-authorization") {
            "proxy-authorization"
        } else {
            "no proxy-authorization"
        });
        let mut resp = Response::<Body>::with_status(200.into());
        resp.headers.insert(
            HeaderName::from_lowercase("x-method"),
            HeaderValue::from_bytes(req.method.as_str().as_bytes()).unwrap(),
        );
        resp
    };
    let server = Server::bind_with_config("127.0.0.1:0", config).unwrap();
    let addr = server.local_addr().unwrap();
    let handle = server.serve_background(handler).unwrap();
    std::mem::forget(handle);
    format!("{}://{addr}", if secure { "https" } else { "http" })
}

fn https_origin_config() -> ServerConfig {
    ServerConfig {
        threads: 1,
        tls: Some(ServerTls {
            identity: common::server_identity(),
            alpn: vec![b"http/1.1".to_vec()],
            ..Default::default()
        }),
        ..Default::default()
    }
}

fn tls_client_settings() -> ClientTls {
    ClientTls {
        roots: common::root_store(),
        verify: true,
        alpn: vec![b"http/1.1".to_vec()],
        now: common::NOW,
        ..Default::default()
    }
}

/// An `https://` request reaches the origin through the proxy's tunnel,
/// and the credentials belong to the proxy: the origin never sees them.
#[test]
fn https_requests_go_through_a_connect_tunnel() {
    let proxy_seen = Seen::default();
    let origin_seen = Seen::default();
    let proxy = spawn_proxy(proxy_seen.clone(), Reply::Serve);
    let origin = spawn_origin(https_origin_config(), origin_seen.clone(), true);

    let client = Client::with_config(ClientConfig {
        tls: Some(tls_client_settings()),
        proxy: Some(
            Proxy::new(&format!("http://{proxy}"))
                .unwrap()
                .basic("alice", "s3cret"),
        ),
        ..Default::default()
    });
    let resp = client.get(&format!("{origin}/tunnelled")).unwrap();
    assert_eq!(resp.status.as_u16(), 200);
    assert_eq!(
        resp.headers.get("x-method").unwrap().to_str().unwrap(),
        "GET"
    );

    let authority = origin.trim_start_matches("https://").to_string();
    assert_eq!(
        proxy_seen.lines(),
        vec![
            format!("CONNECT {authority} HTTP/1.1"),
            format!(
                "proxy-authorization: Basic {}",
                courierust::courierust_crypto::base64::encode(b"alice:s3cret")
            ),
        ],
        "the client must ask the proxy for a tunnel, with its credentials"
    );
    assert_eq!(
        origin_seen.lines(),
        vec!["no proxy-authorization".to_string()],
        "proxy credentials must not travel past the proxy"
    );
}

/// A plaintext request through a proxy uses the absolute request form,
/// which is what tells the proxy where the request is going.
#[test]
fn plaintext_requests_use_the_absolute_form() {
    let proxy_seen = Seen::default();
    let origin_seen = Seen::default();
    let proxy = spawn_proxy(proxy_seen.clone(), Reply::Serve);
    let origin = spawn_origin(
        ServerConfig {
            threads: 1,
            ..Default::default()
        },
        origin_seen.clone(),
        false,
    );

    let client = Client::with_config(ClientConfig {
        proxy: Some(Proxy::new(&proxy.to_string()).unwrap()),
        ..Default::default()
    });
    let resp = client.get(&format!("{origin}/plain")).unwrap();
    assert_eq!(resp.status.as_u16(), 200);
    assert_eq!(
        proxy_seen.lines(),
        vec![format!("GET {origin}/plain HTTP/1.1")],
        "the request line must name the origin in absolute form"
    );
}

/// A refused `CONNECT` fails the request with the proxy's status instead
/// of a tunnel that is not there.
#[test]
fn a_refused_connect_surfaces_the_proxy_status() {
    let proxy_seen = Seen::default();
    let proxy = spawn_proxy(proxy_seen.clone(), Reply::Refuse);
    let client = Client::with_config(ClientConfig {
        tls: Some(tls_client_settings()),
        proxy: Some(Proxy::new(&proxy.to_string()).unwrap()),
        ..Default::default()
    });
    let err = client
        .get("https://127.0.0.1:9/never")
        .expect_err("a refused tunnel must fail the request");
    let text = err.to_string();
    assert!(text.contains("403"), "{text}");
    assert!(text.contains("CONNECT"), "{text}");
    assert_eq!(proxy_seen.lines().len(), 1);
}

/// A `Proxy-Authorization` the request carries itself wins over the
/// client's configured default, and the proxy must never be handed two of
/// them to choose from.
#[test]
fn a_request_provided_proxy_credential_wins() {
    let proxy_seen = Seen::default();
    let proxy = spawn_proxy(proxy_seen.clone(), Reply::Serve);
    let origin = spawn_origin(
        ServerConfig {
            threads: 1,
            ..Default::default()
        },
        Seen::default(),
        false,
    );

    let client = Client::with_config(ClientConfig {
        proxy: Some(
            Proxy::new(&proxy.to_string())
                .unwrap()
                .basic("alice", "s3cret"),
        ),
        ..Default::default()
    });
    let req = Request::new(Method::GET, "/own").header("proxy-authorization", "Bearer per-request");
    let resp = client.execute(&origin, req).unwrap();
    assert_eq!(resp.status.as_u16(), 200);

    // A second client — so a second connection, since this proxy relays the
    // rest of a connection it has already routed — shows the *configured*
    // credential going out when the request carries none.
    let configured = Client::with_config(ClientConfig {
        proxy: Some(
            Proxy::new(&proxy.to_string())
                .unwrap()
                .basic("alice", "s3cret"),
        ),
        ..Default::default()
    });
    let resp = configured.get(&format!("{origin}/configured")).unwrap();
    assert_eq!(resp.status.as_u16(), 200);

    assert_eq!(
        proxy_seen.lines(),
        vec![
            format!("GET {origin}/own HTTP/1.1"),
            "proxy-authorization: Bearer per-request".to_string(),
            format!("GET {origin}/configured HTTP/1.1"),
            format!(
                "proxy-authorization: Basic {}",
                courierust::courierust_crypto::base64::encode(b"alice:s3cret")
            ),
        ],
        "the request's own field wins, the configured one is sent when it is absent, and \
         neither hop ever sees two"
    );
}

/// `OPTIONS *` names the server itself. Through a proxy it travels as the
/// absolute form with an empty path (RFC 9110 §9.3.7); the last proxy
/// turns it back into `*` before the origin sees it.
#[test]
fn an_asterisk_target_becomes_an_empty_path_through_a_proxy() {
    let proxy_seen = Seen::default();
    let proxy = spawn_proxy(proxy_seen.clone(), Reply::Serve);
    let origin = spawn_origin(
        ServerConfig {
            threads: 1,
            ..Default::default()
        },
        Seen::default(),
        false,
    );

    let client = Client::with_config(ClientConfig {
        proxy: Some(Proxy::new(&proxy.to_string()).unwrap()),
        ..Default::default()
    });
    let resp = client
        .execute(
            &origin,
            Request::new(Method::OPTIONS, PathAndQuery::from_static("*")),
        )
        .unwrap();
    assert_eq!(resp.status.as_u16(), 200);
    assert_eq!(
        proxy_seen.lines(),
        vec![format!("OPTIONS {origin} HTTP/1.1")],
        "the asterisk target must become the origin with an empty path"
    );
}

/// The two protocol/proxy combinations that cannot work are refused
/// rather than half-served: QUIC is UDP while the tunnel is TCP, and a
/// proxy routes plaintext by reading HTTP/1.1, which is not what h2c
/// frames are.
#[test]
fn proxy_incompatible_protocols_are_refused() {
    let client = Client::with_config(ClientConfig {
        http3: true,
        tls: Some(tls_client_settings()),
        proxy: Some(Proxy::new("http://127.0.0.1:8080").unwrap()),
        ..Default::default()
    });
    let err = client
        .get("https://127.0.0.1:9/never")
        .expect_err("http3 + proxy must be refused");
    assert!(err.to_string().contains("proxy"), "{err}");

    let client = Client::with_config(ClientConfig {
        http2: true,
        proxy: Some(Proxy::new("http://127.0.0.1:8080").unwrap()),
        ..Default::default()
    });
    let err = client
        .get("http://127.0.0.1:9/never")
        .expect_err("h2c + proxy must be refused");
    assert!(err.to_string().contains("h2c"), "{err}");
}