mod common;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn raw(host: &str, request: &str) -> String {
let mut socket = tokio::net::TcpStream::connect(host).await.expect("connect");
socket
.write_all(request.as_bytes())
.await
.expect("write request");
let mut response = String::new();
socket
.read_to_string(&mut response)
.await
.expect("read response");
response
}
fn header<'a>(response: &'a str, name: &str) -> Option<&'a str> {
let head = response.split("\r\n\r\n").next()?;
head.lines()
.skip(1)
.find_map(|line| {
line.split_once(':')
.filter(|(k, _)| k.eq_ignore_ascii_case(name))
})
.map(|(_, v)| v.trim())
}
fn status(response: &str) -> u16 {
response
.split_whitespace()
.nth(1)
.and_then(|c| c.parse().ok())
.unwrap_or_else(|| panic!("no status line in: {response}"))
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn every_response_carries_the_headers_a_browser_needs() {
let server = common::boot().await;
let host = &server.host;
let response = raw(
host,
&format!(
"GET /parse/health HTTP/1.1\r\nHost: {host}\r\nOrigin: https://app.example\r\n\
Connection: close\r\n\r\n"
),
)
.await;
assert_eq!(status(&response), 200, "{response}");
assert_eq!(
header(&response, "Access-Control-Allow-Origin"),
Some("*"),
"{response}"
);
assert_eq!(
header(&response, "Access-Control-Allow-Methods"),
Some("GET,PUT,POST,DELETE,OPTIONS"),
"{response}"
);
assert_eq!(
header(&response, "Access-Control-Expose-Headers"),
Some("X-Parse-Job-Status-Id, X-Parse-Push-Status-Id"),
"{response}"
);
let allowed = header(&response, "Access-Control-Allow-Headers").expect("allow-headers");
for required in [
"X-Parse-Application-Id",
"X-Parse-Session-Token",
"X-Parse-Master-Key",
"Content-Type",
] {
assert!(
allowed.contains(required),
"{required} missing from {allowed}"
);
}
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn an_error_response_carries_them_as_well() {
let server = common::boot().await;
let host = &server.host;
let response = raw(
host,
&format!(
"GET /parse/classes/Post HTTP/1.1\r\nHost: {host}\r\nOrigin: https://app.example\r\n\
Connection: close\r\n\r\n"
),
)
.await;
assert_ne!(status(&response), 200, "this request should be refused");
assert_eq!(
header(&response, "Access-Control-Allow-Origin"),
Some("*"),
"an error response must still be readable by the page: {response}"
);
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_preflight_is_answered_rather_than_routed() {
let server = common::boot().await;
let host = &server.host;
for path in ["/parse/classes/Post", "/parse/users", "/parse/nonexistent"] {
let response = raw(
host,
&format!(
"OPTIONS {path} HTTP/1.1\r\nHost: {host}\r\nOrigin: https://app.example\r\n\
Access-Control-Request-Method: POST\r\n\
Access-Control-Request-Headers: X-Parse-Application-Id\r\n\
Connection: close\r\n\r\n"
),
)
.await;
assert_eq!(status(&response), 200, "preflight on {path}: {response}");
assert_eq!(
header(&response, "Access-Control-Allow-Origin"),
Some("*"),
"{response}"
);
assert!(
header(&response, "Access-Control-Allow-Headers")
.expect("allow-headers")
.contains("X-Parse-Application-Id"),
"the preflight must allow the header it was asked about: {response}"
);
}
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn a_configured_allowlist_echoes_a_listed_origin_and_refuses_an_unlisted_one() {
let server = common::boot_fresh_with(|mut config| {
config.allow_origin = vec![
"https://a.example".to_string(),
"https://b.example".to_string(),
];
config.allow_headers = vec!["X-Custom".to_string()];
config
})
.await;
let host = &server.host;
let listed = raw(
host,
&format!(
"GET /parse/health HTTP/1.1\r\nHost: {host}\r\nOrigin: https://b.example\r\n\
Connection: close\r\n\r\n"
),
)
.await;
assert_eq!(
header(&listed, "Access-Control-Allow-Origin"),
Some("https://b.example"),
"{listed}"
);
let unlisted = raw(
host,
&format!(
"GET /parse/health HTTP/1.1\r\nHost: {host}\r\nOrigin: https://evil.example\r\n\
Connection: close\r\n\r\n"
),
)
.await;
assert_eq!(
header(&unlisted, "Access-Control-Allow-Origin"),
Some("https://a.example"),
"an unlisted origin must never be echoed back: {unlisted}"
);
let allowed = header(&listed, "Access-Control-Allow-Headers").expect("allow-headers");
assert!(allowed.contains("X-Custom"), "{allowed}");
assert!(allowed.contains("X-Parse-Application-Id"), "{allowed}");
}
#[tokio::test]
#[ignore = "needs MongoDB on 127.0.0.1:27017"]
async fn an_explicitly_empty_allow_origin_is_not_a_wildcard() {
let server = common::boot_fresh_with(|mut c| {
c.allow_origin = Vec::new();
c
})
.await;
let host = &server.host;
let response = raw(
host,
&format!(
"GET /parse/health HTTP/1.1\r\nHost: {host}\r\nOrigin: https://evil.example\r\n\
Connection: close\r\n\r\n"
),
)
.await;
let allowed = header(&response, "Access-Control-Allow-Origin");
assert_ne!(
allowed,
Some("*"),
"an explicitly empty allowlist must not echo a wildcard: {response}"
);
assert_ne!(
allowed,
Some("https://evil.example"),
"and must not echo an origin it does not list: {response}"
);
}