use axum::http::{HeaderValue, request::Parts as RequestParts};
use std::sync::Arc;
pub const MAX_ORIGIN_ENTRIES: usize = 32;
pub const MAX_ORIGIN_BYTES: usize = 256;
pub fn parse_allowed_origins(raw: &str) -> Vec<HeaderValue> {
let mut out: Vec<HeaderValue> = Vec::new();
for entry in raw.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if out.len() >= MAX_ORIGIN_ENTRIES {
tracing::warn!(
"cors allowed origins exceeded {} entries, keeping the first {} entries",
MAX_ORIGIN_ENTRIES,
MAX_ORIGIN_ENTRIES
);
break;
}
if entry.len() > MAX_ORIGIN_BYTES {
let clipped: String = entry.chars().take(MAX_ORIGIN_BYTES).collect();
tracing::warn!(
"cors allowed origin too long ({} bytes > {}), dropped: {}...",
entry.len(),
MAX_ORIGIN_BYTES,
clipped
);
continue;
}
if let Ok(v) = HeaderValue::from_str(entry) {
if !out.contains(&v) {
out.push(v);
}
} else {
tracing::warn!("cors allowed origin is not a valid header value, dropped: {entry}");
}
}
out
}
pub fn origin_is_allowed(
origin: &HeaderValue,
parts: &RequestParts,
allowed: &Arc<[HeaderValue]>,
) -> bool {
if allowed.iter().any(|a| a == origin) {
return true;
}
match crate::ws::host_from_headers(&parts.headers) {
Some(host) => crate::ws::origin_matches_host(origin, host),
None => false,
}
}
pub fn log_cors_policy(allowed: &[HeaderValue]) {
if allowed.is_empty() {
tracing::info!("cors policy: same-origin only");
} else {
let list: Vec<&str> = allowed.iter().map(|v| v.to_str().unwrap_or("<non-utf8>")).collect();
tracing::info!("cors policy: same-origin + explicit allowlist {:?}", list);
}
}
#[cfg(test)]
pub fn decide(origin: &str, host: Option<&str>, allowed: &[&str]) -> bool {
use axum::http::header;
let Ok(origin) = HeaderValue::from_str(origin) else {
return false;
};
let allowed: Vec<HeaderValue> =
allowed.iter().filter_map(|a| HeaderValue::from_str(a).ok()).collect();
let arc: Arc<[HeaderValue]> = allowed.into();
let mut builder = axum::http::Request::builder().method("GET").uri("/");
if let Some(h) = host {
builder = builder.header(header::HOST, h);
}
let req = builder.header(header::ORIGIN, origin.clone()).body(()).expect("valid request");
let (parts, ()) = req.into_parts();
origin_is_allowed(&origin, &parts, &arc)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_allowlist_skips_empty_segments() {
let out = parse_allowed_origins("https://a.example.com,,https://b.example.com,");
assert_eq!(out.len(), 2);
assert_eq!(out[0], "https://a.example.com");
assert_eq!(out[1], "https://b.example.com");
}
#[test]
fn parse_allowlist_dedups_keeping_first_order() {
let out = parse_allowed_origins(
"https://a.example.com, https://b.example.com ,https://a.example.com",
);
assert_eq!(out.len(), 2);
assert_eq!(out[0], "https://a.example.com");
}
#[test]
fn parse_allowlist_preserves_exact_bytes() {
let out = parse_allowed_origins("HTTPS://A.Example.COM:8443");
assert_eq!(out[0], "HTTPS://A.Example.COM:8443");
}
#[test]
fn parse_allowlist_drops_too_long_entry() {
let long = format!("https://a.example.com/{}", "x".repeat(MAX_ORIGIN_BYTES));
let out = parse_allowed_origins(&long);
assert!(out.is_empty(), "超长条目必须丢弃: {out:?}");
}
#[test]
fn parse_allowlist_caps_entries() {
let raw: String = (0..MAX_ORIGIN_ENTRIES + 5)
.map(|i| format!("https://{i}.example.com"))
.collect::<Vec<_>>()
.join(",");
let out = parse_allowed_origins(&raw);
assert_eq!(out.len(), MAX_ORIGIN_ENTRIES);
assert_eq!(out[0], "https://0.example.com");
}
#[test]
fn parse_allowlist_exact_cap_is_not_over_limit() {
let raw: String = (0..MAX_ORIGIN_ENTRIES)
.map(|i| format!("https://{i}.example.com"))
.collect::<Vec<_>>()
.join(",");
let out = parse_allowed_origins(&raw);
assert_eq!(out.len(), MAX_ORIGIN_ENTRIES);
assert_eq!(
out[MAX_ORIGIN_ENTRIES - 1],
format!("https://{}.example.com", MAX_ORIGIN_ENTRIES - 1)
);
}
#[test]
fn parse_allowlist_over_cap_keeps_exactly_first_n() {
let raw: String = (0..MAX_ORIGIN_ENTRIES + 3)
.map(|i| format!("https://{i}.example.com"))
.collect::<Vec<_>>()
.join(",");
let out = parse_allowed_origins(&raw);
assert_eq!(out.len(), MAX_ORIGIN_ENTRIES);
for (i, v) in out.iter().enumerate() {
assert_eq!(v.to_str().unwrap(), format!("https://{i}.example.com"));
}
}
#[test]
fn parse_allowlist_drops_multibyte_overlong_entry_without_panic() {
let long = format!("http://x.example.com/{}", "中".repeat(90)); assert!(long.len() > MAX_ORIGIN_BYTES);
let out = parse_allowed_origins(&long);
assert!(out.is_empty(), "超长多字节条目必须被丢弃: {out:?}");
}
#[test]
fn parse_allowlist_keeps_multibyte_entry_within_limit() {
let ok = format!("http://x.example.com/{}", "中".repeat(50)); assert!(ok.len() <= MAX_ORIGIN_BYTES);
let out = parse_allowed_origins(&ok);
assert_eq!(out.len(), 1);
}
#[test]
fn parse_allowlist_empty_input_is_empty() {
assert!(parse_allowed_origins("").is_empty());
assert!(parse_allowed_origins(" ").is_empty());
assert!(parse_allowed_origins(",,").is_empty());
}
#[test]
fn cors_allows_same_origin() {
assert!(decide("http://127.0.0.1:9077", Some("127.0.0.1:9077"), &[]));
assert!(decide("https://omniterm.lan", Some("omniterm.lan:443"), &[]));
assert!(decide("http://Omniterm.LAN", Some("omniterm.lan"), &[]));
}
#[test]
fn cors_allows_proxy_subdomain_origin() {
assert!(decide("http://3000.omniterm.lan:9777", Some("3000.omniterm.lan:9777"), &[]));
}
#[test]
fn cors_rejects_cross_site() {
assert!(!decide("https://evil.com", Some("127.0.0.1:9077"), &[]));
assert!(!decide("127.0.0.1:9077", Some("127.0.0.1:9077"), &[]));
assert!(!decide("http://:9077", Some("127.0.0.1:9077"), &[]));
}
#[test]
fn cors_rejects_when_host_absent() {
assert!(!decide("http://127.0.0.1:9077", None, &[]));
}
#[test]
fn cors_allows_whitelisted_even_when_host_differs() {
assert!(decide(
"https://term.example.com",
Some("omniterm:9777"),
&["https://term.example.com"]
));
assert!(!decide(
"https://other.example.com",
Some("omniterm:9777"),
&["https://term.example.com"]
));
}
#[test]
fn cors_whitelist_is_exact_byte_match() {
assert!(!decide(
"https://term.example.com:8443",
Some("omniterm:9777"),
&["https://term.example.com"]
));
assert!(decide(
"https://term.example.com",
Some("omniterm:9777"),
&["https://term.example.com"]
));
}
}