use std::sync::{Arc, Mutex};
use std::time::Duration;
use hyper::StatusCode;
use mini_serve::{handler, json, RouteBuilder};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
const REPLY_DEADLINE: Duration = Duration::from_secs(3);
async fn serve_health() -> u16 {
let app = RouteBuilder::stateless()
.get(
"/health",
handler(|_req, _state| async {
json(StatusCode::OK, &serde_json::json!({"ok": true}))
}),
)
.seal();
app.bind_ephemeral().await.unwrap()
}
async fn send_raw(port: u16, raw: &[u8]) -> String {
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();
let _ = stream.write_all(raw).await;
let mut buf = Vec::new();
let _ = tokio::time::timeout(REPLY_DEADLINE, stream.read_to_end(&mut buf)).await;
String::from_utf8_lossy(&buf).into_owned()
}
fn status_line(response: &str) -> &str {
response.lines().next().unwrap_or("<connection closed with no response>")
}
fn assert_refused(response: &str, what: &str) {
let line = status_line(response);
let refused = response.is_empty()
|| line.starts_with("HTTP/1.1 4")
|| line.starts_with("HTTP/1.1 5");
assert!(
refused,
"{what} was served rather than refused: {line}"
);
}
#[tokio::test]
async fn content_length_with_transfer_encoding_is_refused() {
let port = serve_health().await;
let response = send_raw(
port,
b"POST /health HTTP/1.1\r\n\
Host: localhost\r\n\
Content-Length: 6\r\n\
Transfer-Encoding: chunked\r\n\
\r\n\
0\r\n\r\n\
GET /smuggled HTTP/1.1\r\nHost: localhost\r\n\r\n",
)
.await;
assert_refused(&response, "a request with both Content-Length and Transfer-Encoding");
assert!(
!response.contains("smuggled"),
"the smuggled request was answered: {response}"
);
}
#[tokio::test]
async fn conflicting_content_lengths_are_refused() {
let port = serve_health().await;
let response = send_raw(
port,
b"POST /health HTTP/1.1\r\n\
Host: localhost\r\n\
Content-Length: 6\r\n\
Content-Length: 7\r\n\
\r\n\
hello!!",
)
.await;
assert_refused(&response, "a request with two Content-Length values");
}
#[tokio::test]
async fn a_malformed_chunk_size_is_refused() {
let port = serve_health().await;
let response = send_raw(
port,
b"POST /health HTTP/1.1\r\n\
Host: localhost\r\n\
Transfer-Encoding: chunked\r\n\
\r\n\
ffffffffffffffffff\r\nhello\r\n0\r\n\r\n",
)
.await;
assert_refused(&response, "a request with an overflowing chunk size");
}
#[tokio::test]
async fn an_absolute_form_target_routes_on_its_path() {
let port = serve_health().await;
let response = send_raw(
port,
b"GET http://evil.example/health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
)
.await;
let line = status_line(&response);
assert!(
line.starts_with("HTTP/1.1 200") || line.starts_with("HTTP/1.1 4"),
"an absolute-form target should be served on its path or refused, got: {line}"
);
if line.starts_with("HTTP/1.1 200") {
assert!(
response.contains(r#""ok":true"#),
"served, but not by the /health route: {response}"
);
}
}
#[tokio::test]
async fn an_encoded_crlf_in_the_path_cannot_forge_a_log_line() {
#[derive(Clone, Default)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let log = SharedBuffer::default();
let app = RouteBuilder::stateless()
.with_request_logging_to(Box::new(log.clone()))
.get(
"/health",
handler(|_req, _state| async {
json(StatusCode::OK, &serde_json::json!({"ok": true}))
}),
)
.seal();
let port = app.bind_ephemeral().await.unwrap();
let _ = send_raw(
port,
b"GET /health%0d%0aGET%20/forged%20200%200.000ms HTTP/1.1\r\n\
Host: localhost\r\nConnection: close\r\n\r\n",
)
.await;
tokio::time::sleep(Duration::from_millis(100)).await;
let written = String::from_utf8(log.0.lock().unwrap().clone()).unwrap();
assert!(
written.contains("%0d%0a"),
"the raw path should appear verbatim, got: {written:?}"
);
assert_eq!(
written.trim_end().lines().count(),
1,
"the request forged extra log lines: {written:?}"
);
}
#[tokio::test]
async fn a_bare_lf_in_a_header_value_is_refused() {
let port = serve_health().await;
let response = send_raw(
port,
b"GET /health HTTP/1.1\r\nHost: localhost\r\nX-Probe: a\nInjected: yes\r\n\r\n",
)
.await;
assert!(
!response.contains("Injected"),
"a bare LF in a header value was reflected: {response}"
);
}
#[tokio::test]
async fn http_1_1_without_host_is_refused() {
let port = serve_health().await;
let response = send_raw(port, b"GET /health HTTP/1.1\r\n\r\n").await;
assert_refused(&response, "an HTTP/1.1 request with no Host header");
}