use churust_client::Client;
use churust_core::{Call, Churust, Response};
use http::header::LOCATION;
use http::{HeaderValue, StatusCode};
use std::sync::{Arc, Mutex};
type Observed = Vec<(String, Option<String>)>;
#[derive(Default, Clone)]
struct Seen(Arc<Mutex<Observed>>);
async fn recording_server() -> (String, Seen) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let seen = Seen::default();
let sink = seen.clone();
let app = Churust::server()
.port(port)
.host("127.0.0.1")
.state(sink)
.routing(|r| {
r.get("/landing", |c: Call| async move {
let seen = c.state::<Seen>().unwrap();
let mut log = seen.0.lock().unwrap();
for name in ["authorization", "cookie", "x-api-key"] {
log.push((name.to_string(), c.header(name).map(str::to_string)));
}
"landed"
});
})
.build();
tokio::spawn(async move { app.start_on(listener, std::future::pending()).await });
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
(format!("http://127.0.0.1:{port}"), seen)
}
async fn bouncing_server(target: String) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Churust::server()
.port(port)
.host("127.0.0.1")
.state(target)
.routing(|r| {
r.get("/start", |c: Call| async move {
let target = c.state::<String>().unwrap();
Response::new(StatusCode::FOUND).with_header(
LOCATION,
HeaderValue::from_str(&format!("{target}/landing")).unwrap(),
)
});
r.get("/local", |_c: Call| async {
Response::new(StatusCode::FOUND)
.with_header(LOCATION, HeaderValue::from_static("/landing"))
});
r.get("/landing", |c: Call| async move {
format!(
"same-origin auth={:?}",
c.header("authorization").map(str::to_string)
)
});
})
.build();
tokio::spawn(async move { app.start_on(listener, std::future::pending()).await });
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
format!("http://127.0.0.1:{port}")
}
#[tokio::test]
async fn credentials_are_not_replayed_to_another_origin() {
let (victim, seen) = recording_server().await;
let attacker = bouncing_server(victim).await;
let client = Client::new();
let res = client
.get(format!("{attacker}/start"))
.bearer("SECRET-TOKEN")
.header("cookie", "session=abc123")
.header("x-api-key", "KEY-9000")
.send()
.await
.expect("request");
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text().unwrap(), "landed");
let log = seen.0.lock().unwrap().clone();
let value = |n: &str| {
log.iter()
.find(|(k, _)| k == n)
.and_then(|(_, v)| v.clone())
};
assert_eq!(
value("authorization"),
None,
"the bearer token was replayed to a different origin"
);
assert_eq!(
value("cookie"),
None,
"the session cookie was replayed to a different origin"
);
assert_eq!(value("x-api-key"), Some("KEY-9000".to_string()));
}
#[tokio::test]
async fn credentials_survive_a_same_origin_redirect() {
let base = bouncing_server("http://unused.invalid".into()).await;
let client = Client::new();
let res = client
.get(format!("{base}/local"))
.bearer("SECRET-TOKEN")
.send()
.await
.expect("request");
assert_eq!(
res.text().unwrap(),
r#"same-origin auth=Some("Bearer SECRET-TOKEN")"#,
"a same-origin redirect must not lose the credential"
);
}
#[tokio::test]
async fn a_redirect_cannot_downgrade_https_to_http() {
let (plain, _seen) = recording_server().await;
let attacker = bouncing_server(plain).await;
let client = Client::new();
let ok = client.get(format!("{attacker}/start")).send().await;
assert!(ok.is_ok());
let res = client.get("https://127.0.0.1:1/start").send().await;
assert!(res.is_err(), "an https request resolved over plaintext");
}