use axum::extract::Request;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use http::header::HeaderValue;
use http::StatusCode;
use crate::state::AppState;
pub const DEFAULT_ALLOWED_HEADERS: &str = "X-Parse-Master-Key, X-Parse-REST-API-Key, \
X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, \
X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, X-Parse-Request-Id, \
Content-Type, Pragma, Cache-Control";
const ALLOW_METHODS: &str = "GET,PUT,POST,DELETE,OPTIONS";
const EXPOSE_HEADERS: &str = "X-Parse-Job-Status-Id, X-Parse-Push-Status-Id";
pub async fn layer(
axum::extract::State(state): axum::extract::State<AppState>,
request: Request,
next: Next,
) -> Response {
let config = state.config();
let allow_origin = resolve_origin(
&config.allow_origin,
request.headers().get(http::header::ORIGIN),
);
let allow_headers = join_allowed_headers(&config.allow_headers);
let mut response = if request.method() == http::Method::OPTIONS {
(StatusCode::OK, "OK".to_string()).into_response()
} else {
next.run(request).await
};
let headers = response.headers_mut();
for (name, value) in [
(http::header::ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin),
(
http::header::ACCESS_CONTROL_ALLOW_METHODS,
ALLOW_METHODS.to_string(),
),
(http::header::ACCESS_CONTROL_ALLOW_HEADERS, allow_headers),
(
http::header::ACCESS_CONTROL_EXPOSE_HEADERS,
EXPOSE_HEADERS.to_string(),
),
] {
if let Ok(value) = HeaderValue::from_str(&value) {
headers.insert(name, value);
}
}
response
}
fn resolve_origin(configured: &[String], request_origin: Option<&HeaderValue>) -> String {
let first = configured.first().map(String::as_str).unwrap_or("");
let Some(origin) = request_origin.and_then(|v| v.to_str().ok()) else {
return first.to_string();
};
if configured.iter().any(|allowed| allowed == origin) {
origin.to_string()
} else {
first.to_string()
}
}
fn join_allowed_headers(extra: &[String]) -> String {
if extra.is_empty() {
return DEFAULT_ALLOWED_HEADERS.to_string();
}
format!("{DEFAULT_ALLOWED_HEADERS}, {}", extra.join(", "))
}
#[cfg(test)]
mod tests {
use super::*;
fn origin(value: &str) -> HeaderValue {
HeaderValue::from_str(value).expect("test literal")
}
#[test]
fn the_default_list_is_upstreams_twelve_headers() {
let names: Vec<&str> = DEFAULT_ALLOWED_HEADERS.split(", ").collect();
assert_eq!(names.len(), 12, "{DEFAULT_ALLOWED_HEADERS}");
for required in [
"X-Parse-Application-Id",
"X-Parse-Session-Token",
"X-Parse-Master-Key",
"Content-Type",
] {
assert!(names.contains(&required), "missing {required}");
}
assert!(names.contains(&"X-Parse-Javascript-Key"));
}
#[test]
fn an_unconfigured_server_allows_every_origin() {
assert_eq!(
resolve_origin(&["*".to_string()], Some(&origin("https://app.example"))),
"*"
);
assert_eq!(resolve_origin(&["*".to_string()], None), "*");
}
#[test]
fn an_explicitly_empty_allowlist_is_closed_not_open() {
assert_eq!(resolve_origin(&[], None), "");
assert_eq!(
resolve_origin(&[], Some(&origin("https://app.example"))),
""
);
}
#[test]
fn a_listed_origin_is_echoed_and_an_unlisted_one_gets_the_first_entry() {
let configured = vec![
"https://a.example".to_string(),
"https://b.example".to_string(),
];
assert_eq!(
resolve_origin(&configured, Some(&origin("https://b.example"))),
"https://b.example"
);
assert_eq!(
resolve_origin(&configured, Some(&origin("https://evil.example"))),
"https://a.example",
"an unlisted origin must not be echoed back"
);
assert_eq!(resolve_origin(&configured, None), "https://a.example");
}
#[test]
fn configured_headers_append_to_the_defaults_rather_than_replacing_them() {
let joined = join_allowed_headers(&["X-Custom".to_string(), "X-Other".to_string()]);
assert!(joined.starts_with(DEFAULT_ALLOWED_HEADERS));
assert!(joined.ends_with("X-Custom, X-Other"));
assert_eq!(join_allowed_headers(&[]), DEFAULT_ALLOWED_HEADERS);
}
}