use crate::utils::{aliases::AEngine, runique_log::get_log};
use axum::{
body::Body,
extract::State,
http::{Request, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
pub async fn open_redirect_middleware(
State(engine): State<AEngine>,
req: Request<Body>,
next: Next,
) -> Response {
let response = next.run(req).await;
let headers = response.headers();
let mut targets: Vec<String> = Vec::new();
if response.status().is_redirection()
&& let Some(location) = headers.get(header::LOCATION)
{
targets.push(String::from_utf8_lossy(location.as_bytes()).into_owned());
}
if let Some(refresh) = headers.get(header::REFRESH) {
targets.extend(refresh_targets(&String::from_utf8_lossy(
refresh.as_bytes(),
)));
}
let Some(unsafe_target) = targets.iter().find(|t| !is_safe_redirect(t, &engine)) else {
return response;
};
if let Some(level) = get_log()
.middleware
.as_ref()
.and_then(|m| m.host_validation)
{
crate::runique_log!(level, location = %unsafe_target, "open redirect blocked");
}
(StatusCode::BAD_REQUEST, "Forbidden redirect").into_response()
}
fn refresh_targets(value: &str) -> Vec<String> {
let rest = value
.trim_start()
.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.')
.trim_start();
let rest = rest.strip_prefix([';', ',']).unwrap_or(rest).trim_start();
let mut readings = vec![rest];
if let Some(prefix) = rest.get(..3)
&& prefix.eq_ignore_ascii_case("url")
{
let after = rest[3..].trim_start();
readings.push(after.strip_prefix('=').unwrap_or(after).trim_start());
}
readings
.into_iter()
.map(|reading| match reading.chars().next() {
Some(quote @ ('\'' | '"')) => reading[1..].split(quote).next().unwrap_or(""),
_ => reading,
})
.map(str::trim)
.filter(|url| !url.is_empty())
.map(str::to_string)
.collect()
}
const PROBE_HOST: &str = "runique.invalid";
fn is_safe_redirect(location: &str, engine: &crate::engine::RuniqueEngine) -> bool {
let Ok(base) = url::Url::parse(&format!("http://{PROBE_HOST}/")) else {
return false;
};
let Ok(target) = base.join(location) else {
return false;
};
if !matches!(target.scheme(), "http" | "https") {
return false;
}
let Some(host_name) = target.host_str() else {
return false;
};
if host_name == PROBE_HOST {
return true;
}
let host_with_port = match target.port() {
Some(port) => format!("{host_name}:{port}"),
None => host_name.to_string(),
};
let host = host_with_port.as_str();
if is_local_host(host) {
return true;
}
engine.security_hosts.is_host_allowed(host)
}
pub fn extract_host(location: &str) -> Option<&str> {
let without_scheme = if let Some(rest) = location.strip_prefix("//") {
rest
} else {
location
.strip_prefix("http://")
.or_else(|| location.strip_prefix("https://"))?
};
let host = without_scheme
.split(['/', '?', '#'])
.next()
.filter(|h| !h.is_empty() && !h.contains('@'))?;
Some(host)
}
pub fn is_local_host(host: &str) -> bool {
if host.starts_with('[') {
let addr = host.split(']').next().map(|s| &s[1..]).unwrap_or(host);
return is_loopback_ipv6(addr);
}
let bare = host.split(':').next().unwrap_or(host);
bare == "localhost"
|| bare
.parse::<std::net::Ipv4Addr>()
.is_ok_and(|ip| ip.is_loopback())
}
fn is_loopback_ipv6(addr: &str) -> bool {
if addr == "::1" {
return true;
}
if addr == "0:0:0:0:0:0:0:1" {
return true;
}
if let Some(rest) = addr.to_ascii_lowercase().strip_prefix("::ffff:") {
if rest.starts_with("127.") {
return true;
}
if rest.starts_with("7f") {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_host() {
assert_eq!(extract_host("https://evil.com/path"), Some("evil.com"));
assert_eq!(
extract_host("http://evil.com:8080/path"),
Some("evil.com:8080")
);
assert_eq!(extract_host("//evil.com/path"), Some("evil.com"));
assert_eq!(extract_host("/relative"), None);
assert_eq!(extract_host("https://evil.com"), Some("evil.com"));
assert_eq!(extract_host("https://evil.com?q=1"), Some("evil.com"));
}
#[test]
fn test_is_local_host() {
assert!(is_local_host("localhost"));
assert!(is_local_host("localhost:8080"));
assert!(!is_local_host("notlocalhost.com"));
assert!(!is_local_host("localhost.evil.com"));
assert!(is_local_host("127.0.0.1"));
assert!(is_local_host("127.0.0.1:3000"));
assert!(is_local_host("127.0.0.2"));
assert!(is_local_host("127.1.2.3"));
assert!(is_local_host("127.255.255.255"));
assert!(!is_local_host("128.0.0.1"));
assert!(!is_local_host("127.evil.com"));
assert!(!is_local_host("127.0.0.1.evil.com:443"));
assert!(is_local_host("[::1]"));
assert!(is_local_host("[::1]:8080"));
assert!(is_local_host("[0:0:0:0:0:0:0:1]"));
assert!(is_local_host("[0:0:0:0:0:0:0:1]:443"));
assert!(is_local_host("[::ffff:127.0.0.1]"));
assert!(is_local_host("[::ffff:7f00:1]"));
assert!(is_local_host("[::FFFF:127.0.0.1]"));
assert!(!is_local_host("evil.com"));
assert!(!is_local_host("[::2]"));
}
}