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
//! Message-framing conformance (RFC 9112 §6).
//!
//! hyper owns HTTP/1 framing, so none of this is Churust's implementation —
//! which is exactly why it is worth pinning. These are behaviours the framework
//! depends on and would otherwise notice only after a dependency upgrade
//! changed one of them. A failure here is a signal to read hyper's changelog,
//! not necessarily a bug in this repository.
use churust_core::{Call, Churust};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// Bind an ephemeral port and keep the listener, so nothing can take the port
/// between discovering it and serving on it. Dropping it first is a race that
/// shows up as one test's client reaching another test's server.
async fn bound() -> (tokio::net::TcpListener, std::net::SocketAddr) {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
(l, addr)
}
/// Serve an echo-length app, send `raw` verbatim, and read what comes back.
async fn exchange(raw: &[u8]) -> String {
let (l, addr) = bound().await;
let app = Churust::server()
.routing(|r| {
r.post("/echo", |body: String| async move {
format!("len={}", body.len())
});
r.get("/", |_c: Call| async { "ok" });
})
.build();
let (_tx, rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(async move {
churust_core::engine::serve_on(app, l, async {
let _ = rx.await;
})
.await
});
tokio::time::sleep(Duration::from_millis(150)).await;
let mut sock = tokio::net::TcpStream::connect(addr).await.unwrap();
sock.write_all(raw).await.unwrap();
let mut buf = Vec::new();
// Read to EOF where the server closes; otherwise take what is available.
let _ = tokio::time::timeout(Duration::from_millis(800), sock.read_to_end(&mut buf)).await;
String::from_utf8_lossy(&buf).into_owned()
}
#[tokio::test]
async fn transfer_encoding_wins_over_content_length() {
// RFC 9112 §6.3 rule 3: if a message has both a Transfer-Encoding and a
// Content-Length, the Transfer-Encoding overrides. The mismatch is the
// classic request-smuggling primitive — a proxy that believes one header
// and an origin that believes the other disagree about where the next
// request starts.
//
// The chunked body is 5 bytes; the Content-Length lies and says 99.
let res = exchange(
b"POST /echo HTTP/1.1\r\n\
Host: x\r\n\
Content-Length: 99\r\n\
Transfer-Encoding: chunked\r\n\
\r\n\
5\r\nhello\r\n0\r\n\r\n",
)
.await;
// What matters is the desync, not the status line, and two different
// layers remove it depending on which hyper resolved.
//
// Churust's own guard (`engine::handle`) answers `400` when it can see both
// headers. From hyper 1.11 it cannot: hyper strips the `Content-Length`
// during parsing, so the guard's condition is never true and the request
// reaches the handler framed by the chunked coding — but hyper also sets
// `keep_alive = false` for exactly this message shape, so the connection
// still closes. Both paths end the same way, and the property below is the
// one that actually holds off the attack: whatever an intermediary in front
// believed the body length to be, no leftover byte survives to be read as
// the start of a second request.
//
// `hyper = "1"` still admits pre-1.11 versions, so the guard stays.
assert!(
res.to_ascii_lowercase().contains("connection: close"),
"the connection must be closed, or leftover bytes become the next request: {res}"
);
let responses = res.matches("HTTP/1.1 ").count();
assert_eq!(
responses, 1,
"one ambiguous message produced {responses} responses: {res}"
);
// If it was served rather than refused, the transfer coding must be what
// framed it. Honouring the lying Content-Length instead would mean reading
// 99 bytes for a 5-byte body — the desync itself.
assert!(
res.starts_with("HTTP/1.1 400") || res.contains("len=5"),
"the Content-Length was framed on instead of the transfer coding: {res}"
);
}
#[tokio::test]
async fn a_smuggled_second_request_is_not_served_from_one_message() {
// The CL.TE smuggling shape: the Content-Length covers bytes that, read as
// chunked, terminate early — leaving a second request line in the buffer.
// Exactly one response must come back.
let res = exchange(
b"POST /echo HTTP/1.1\r\n\
Host: x\r\n\
Content-Length: 6\r\n\
Transfer-Encoding: chunked\r\n\
\r\n\
0\r\n\r\n\
GET / HTTP/1.1\r\nHost: x\r\n\r\n",
)
.await;
let responses = res.matches("HTTP/1.1 ").count();
assert!(
responses <= 1,
"one message produced {responses} responses — smuggled request served: {res}"
);
}
#[tokio::test]
async fn duplicate_conflicting_content_lengths_are_rejected() {
// RFC 9112 §6.3 rule 5: differing Content-Length values are unrecoverable.
let res = exchange(
b"POST /echo HTTP/1.1\r\n\
Host: x\r\n\
Content-Length: 5\r\n\
Content-Length: 6\r\n\
\r\n\
hello",
)
.await;
assert!(
res.starts_with("HTTP/1.1 400") || res.is_empty(),
"conflicting Content-Length values were not rejected: {res}"
);
}
#[tokio::test]
async fn an_unrecognised_transfer_encoding_is_refused() {
// RFC 9112 §6.1: a Transfer-Encoding the server cannot decode must not be
// guessed at.
let res = exchange(
b"POST /echo HTTP/1.1\r\n\
Host: x\r\n\
Transfer-Encoding: bogus\r\n\
\r\n\
hello",
)
.await;
assert!(
!res.contains("len="),
"an undecodable Transfer-Encoding was served as a body: {res}"
);
}
#[tokio::test]
async fn a_well_formed_chunked_body_still_works() {
// The tests above pass trivially if chunked is broken outright.
let res = exchange(
b"POST /echo HTTP/1.1\r\n\
Host: x\r\n\
Transfer-Encoding: chunked\r\n\
\r\n\
5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n",
)
.await;
assert!(res.contains("len=11"), "{res}");
}
#[tokio::test]
async fn a_buffered_response_is_framed_with_content_length() {
// Anything that wraps the response body engine-wide — the in-flight guard,
// for one — must stay transparent to `size_hint`. Re-streaming a buffered
// body silently switches every response to chunked encoding, which loses
// the length clients use to size a download and to satisfy a `HEAD`.
let res = exchange(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n").await;
let head = res.to_ascii_lowercase();
assert!(
head.contains("content-length: 2"),
"a buffered body lost its Content-Length: {res}"
);
assert!(
!head.contains("transfer-encoding: chunked"),
"a known-length body must not be chunked: {res}"
);
}
#[tokio::test]
async fn a_head_reply_still_reports_the_length() {
// RFC 9110 §9.3.2: HEAD carries the headers GET would, and clients use the
// length to size a resource before fetching it.
let res = exchange(b"HEAD / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n").await;
assert!(
res.to_ascii_lowercase().contains("content-length: 2"),
"HEAD lost Content-Length: {res}"
);
}
#[tokio::test]
async fn transfer_encoding_first_is_refused_too() {
// Header *order* is the attacker's choice, and the guard only ever saw one
// of the two orderings: below hyper 1.11, hyper deletes the Content-Length
// when it parses a Transfer-Encoding first, so `contains_key` was false and
// the message was served — with the smuggled request answered. The
// Content-Length-first ordering was refused, which made the gap look closed.
let res = exchange(
b"POST /echo HTTP/1.1\r\n\
Host: x\r\n\
Transfer-Encoding: chunked\r\n\
Content-Length: 6\r\n\
\r\n\
0\r\n\r\n\
GET / HTTP/1.1\r\nHost: x\r\n\r\n",
)
.await;
let responses = res.matches("HTTP/1.1 ").count();
assert_eq!(responses, 1, "the smuggled request was answered: {res}");
assert!(
res.to_ascii_lowercase().contains("connection: close"),
"the connection stayed reusable after an ambiguous message: {res}"
);
}