251 lines
8.9 KiB
Rust
251 lines
8.9 KiB
Rust
use dever_runtime::api::{self, CookieOptions, SameSite};
|
|
use dever_runtime::bytes::Bytes;
|
|
use dever_runtime::http::{Header, Request};
|
|
use dever_runtime::{http, net, task};
|
|
use std::time::Duration;
|
|
|
|
fn request(path: &str, cookie: &str) -> Request {
|
|
Request {
|
|
method: "GET".into(),
|
|
target: path.into(),
|
|
headers: vec![Header {
|
|
name: "cookie".into(),
|
|
value: Bytes::from_text(cookie),
|
|
}],
|
|
body: Bytes::from_text(""),
|
|
}
|
|
}
|
|
|
|
fn cookie_options() -> CookieOptions {
|
|
CookieOptions {
|
|
path: "/".into(),
|
|
domain: None,
|
|
same_site: SameSite::Lax,
|
|
secure: true,
|
|
http_only: true,
|
|
max_age: Some(3600),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_scope_is_isolated_and_merges_validated_response_metadata() {
|
|
let first = api::scoped_request(
|
|
request("/first?ignored=1", "session=v1.Zmlyc3Q; theme=dark"),
|
|
|_| async {
|
|
assert_eq!(api::method().unwrap(), "GET");
|
|
assert_eq!(api::path().unwrap(), "/first");
|
|
assert!(api::request_id().unwrap().parse::<u64>().is_ok());
|
|
assert!(api::header("cookie").is_err());
|
|
assert!(api::header("x-forwarded-for").is_err());
|
|
assert_eq!(api::cookie("theme").unwrap().as_deref(), Some("dark"));
|
|
assert!(api::cookie("session").is_err());
|
|
assert_eq!(api::cookie("missing").unwrap(), None);
|
|
let incoming = api::secret_cookie("session").unwrap().unwrap();
|
|
assert_eq!(format!("{incoming:?}"), "Secret([REDACTED])");
|
|
api::response_header("x-request-state", "first").unwrap();
|
|
api::response_secret_cookie(
|
|
"session",
|
|
dever_runtime::secret::Secret::from_input(vec![0, 255, 0]),
|
|
cookie_options(),
|
|
)
|
|
.unwrap();
|
|
api::response_secret_cookie("copy", incoming, cookie_options()).unwrap();
|
|
api::response_cookie("theme", "dark", cookie_options()).unwrap();
|
|
let mut insecure = cookie_options();
|
|
insecure.secure = false;
|
|
assert!(
|
|
api::response_secret_cookie(
|
|
"session",
|
|
dever_runtime::secret::Secret::from_input(b"unsafe".to_vec()),
|
|
insecure,
|
|
)
|
|
.is_err()
|
|
);
|
|
api::commit_response_metadata().unwrap();
|
|
Ok(api::success(dever_runtime::wire::Encoded::null()))
|
|
},
|
|
);
|
|
let second = api::scoped_request(request("/second", "session=v1.c2Vjb25k"), |_| async {
|
|
assert_eq!(api::path().unwrap(), "/second");
|
|
assert!(api::response_header("set-cookie", "bypass").is_err());
|
|
assert!(api::response_header("content-type", "text/html").is_err());
|
|
assert!(api::response_header("x-unsafe", "injected\r\nheader").is_err());
|
|
api::response_header("x-request-state", "second").unwrap();
|
|
api::commit_response_metadata().unwrap();
|
|
Ok(api::success(dever_runtime::wire::Encoded::null()))
|
|
});
|
|
let (first, second) = tokio::join!(first, second);
|
|
let first = first.unwrap();
|
|
let second = second.unwrap();
|
|
assert!(
|
|
first
|
|
.headers
|
|
.iter()
|
|
.any(|header| header.name == "x-request-state" && header.value.values() == b"first")
|
|
);
|
|
assert!(
|
|
second
|
|
.headers
|
|
.iter()
|
|
.any(|header| header.name == "x-request-state" && header.value.values() == b"second")
|
|
);
|
|
assert!(first.headers.iter().any(|header| {
|
|
header.name == "set-cookie"
|
|
&& header
|
|
.value
|
|
.values()
|
|
.starts_with(b"session=v1.AP8A; Path=/")
|
|
}));
|
|
assert!(first.headers.iter().any(|header| {
|
|
header.name == "set-cookie"
|
|
&& header
|
|
.value
|
|
.values()
|
|
.starts_with(b"copy=v1.Zmlyc3Q; Path=/")
|
|
}));
|
|
assert!(
|
|
first
|
|
.headers
|
|
.iter()
|
|
.any(|header| header.name == "set-cookie"
|
|
&& header.value.values().starts_with(b"theme=dark; Path=/"))
|
|
);
|
|
assert!(
|
|
!second
|
|
.headers
|
|
.iter()
|
|
.any(|header| header.name == "set-cookie")
|
|
);
|
|
assert!(api::request_id().is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_or_duplicate_cookie_is_a_bad_request_before_handler_execution() {
|
|
for cookie in ["session=one; session=two", "session=one; broken"] {
|
|
let result = api::scoped_request(request("/profile", cookie), |_| async {
|
|
Err("malformed Cookie reached the API handler".into())
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.status, 400);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn quoted_cookie_value_uses_the_same_secret_codec() {
|
|
let response = api::scoped_request(request("/profile", "session=\"v1.Zmlyc3Q\""), |_| async {
|
|
let token = api::secret_cookie("session")?.ok_or("missing quoted session")?;
|
|
api::response_secret_cookie("copy", token, cookie_options())?;
|
|
api::commit_response_metadata()?;
|
|
Ok(api::success(dever_runtime::wire::Encoded::null()))
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(response.headers.iter().any(|header| {
|
|
header.name == "set-cookie"
|
|
&& header
|
|
.value
|
|
.values()
|
|
.starts_with(b"copy=v1.Zmlyc3Q; Path=/")
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uncommitted_response_metadata_is_discarded_on_failure() {
|
|
let response = api::scoped_request(request("/profile", ""), |_| async {
|
|
api::response_header("x-staged", "discarded")?;
|
|
Ok(api::internal_error())
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status, 500);
|
|
assert!(
|
|
!response
|
|
.headers
|
|
.iter()
|
|
.any(|header| header.name == "x-staged")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cookie_and_model_text_validation_reject_invalid_values() {
|
|
assert!(api::text_bounds("name", Some(""), 1, Some(64)).is_err());
|
|
assert!(api::text_bounds("name", Some("abc"), 1, Some(2)).is_err());
|
|
assert!(api::text_bounds("name", None, 1, Some(2)).is_ok());
|
|
assert!(api::response_cookie("session", "a;b", cookie_options()).is_err());
|
|
assert!(api::response_cookie("session", "valid", cookie_options()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn client_address_comes_from_the_tcp_peer_not_forwarded_headers() {
|
|
let limits = http::Limits {
|
|
header_bytes: 8192,
|
|
body_bytes: 1024,
|
|
timeout_ms: 1000,
|
|
connections: 2,
|
|
http2: None,
|
|
};
|
|
task::run_entry_with(
|
|
task::RuntimeConfig {
|
|
worker_threads: 1,
|
|
max_blocking_threads: 2,
|
|
task_capacity: 16,
|
|
},
|
|
async {
|
|
tokio::time::timeout(Duration::from_secs(8), async {
|
|
let listener = net::listen("127.0.0.1", 0).await?;
|
|
let port = net::port(&listener)?;
|
|
let owned = listener.clone();
|
|
let server = task::run(async move {
|
|
http::serve(
|
|
|request| {
|
|
api::scoped_request(request, |_| async {
|
|
let peer =
|
|
api::client_address()?.ok_or("missing TCP peer address")?;
|
|
let mut body = dever_runtime::wire::Encoder::default();
|
|
body.text(&peer)?;
|
|
Ok(api::success(body.finish()?))
|
|
})
|
|
},
|
|
owned,
|
|
limits,
|
|
)
|
|
.await
|
|
})
|
|
.await?;
|
|
let response = http::send(
|
|
"127.0.0.1",
|
|
port,
|
|
Request {
|
|
method: "GET".into(),
|
|
target: "/peer".into(),
|
|
headers: vec![
|
|
Header {
|
|
name: "host".into(),
|
|
value: Bytes::from_text("localhost"),
|
|
},
|
|
Header {
|
|
name: "x-forwarded-for".into(),
|
|
value: Bytes::from_text("203.0.113.99"),
|
|
},
|
|
],
|
|
body: Bytes::new(vec![]),
|
|
},
|
|
limits,
|
|
)
|
|
.await?;
|
|
assert_eq!(response.status, 200);
|
|
let envelope: serde_json::Value = serde_json::from_slice(response.body.values())
|
|
.map_err(|error| error.to_string())?;
|
|
assert_eq!(envelope["data"], "127.0.0.1");
|
|
task::stop(server).await?;
|
|
listener.close()?;
|
|
Ok::<(), String>(())
|
|
})
|
|
.await
|
|
.map_err(|_| "API peer test deadline exceeded".to_owned())?
|
|
},
|
|
)
|
|
.unwrap();
|
|
}
|