use axum::http::HeaderValue;
use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
fn parse_bind_ip(addr: &str) -> Option<IpAddr> {
if let Ok(sock) = SocketAddr::from_str(addr) {
return Some(sock.ip());
}
let (host, _port) = addr.rsplit_once(':')?;
IpAddr::from_str(host).ok()
}
pub fn check_bind_authorization(
addr_str: &str,
api_key_configured: bool,
allow_unauthenticated_remote: bool,
) -> Result<(), String> {
if api_key_configured || allow_unauthenticated_remote {
return Ok(());
}
let reason = match parse_bind_ip(addr_str) {
Some(ip) if ip.is_loopback() => return Ok(()),
Some(ip) => format!("is not a loopback address (resolved host: {ip})"),
None => "could not be confirmed as a loopback address".to_string(),
};
Err(format!(
"refusing to start: FERROX_ADDR={addr_str:?} {reason} and FERROX_API_KEY is not set -- \
this would serve the API unauthenticated to anyone who can reach it. Fix this by \
setting FERROX_API_KEY, binding a loopback address (127.0.0.1 or ::1) instead, or -- \
only if you understand the risk and authentication is handled elsewhere (e.g. a \
reverse proxy) -- setting FERROX_ALLOW_UNAUTHENTICATED_REMOTE=1."
))
}
pub struct TlsPaths {
pub cert: String,
pub key: String,
}
pub fn tls_paths_from_env() -> Result<Option<TlsPaths>, String> {
match (
std::env::var("FERROX_TLS_CERT"),
std::env::var("FERROX_TLS_KEY"),
) {
(Ok(cert), Ok(key)) => Ok(Some(TlsPaths { cert, key })),
(Err(_), Err(_)) => Ok(None),
_ => Err(
"FERROX_TLS_CERT and FERROX_TLS_KEY must be set together (or neither, to serve \
plain HTTP)"
.to_string(),
),
}
}
pub fn parse_cors_origins(spec: &str) -> Result<Vec<HeaderValue>, String> {
spec.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|origin| {
if origin == "*" {
return Err(
"FERROX_CORS_ORIGINS does not support the \"*\" wildcard -- list the exact \
origin(s) to allow instead"
.to_string(),
);
}
HeaderValue::from_str(origin)
.map_err(|e| format!("FERROX_CORS_ORIGINS: invalid origin {origin:?}: {e}"))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loopback_with_no_key_starts_fine() {
assert!(check_bind_authorization("127.0.0.1:8383", false, false).is_ok());
assert!(check_bind_authorization("[::1]:8383", false, false).is_ok());
}
#[test]
fn non_loopback_with_no_key_and_no_override_is_refused() {
let err = check_bind_authorization("0.0.0.0:8383", false, false)
.expect_err("must refuse an unauthenticated non-loopback bind");
assert!(err.contains("FERROX_API_KEY"));
assert!(err.contains("FERROX_ALLOW_UNAUTHENTICATED_REMOTE"));
}
#[test]
fn non_loopback_with_no_key_and_override_starts() {
assert!(check_bind_authorization("0.0.0.0:8383", false, true).is_ok());
}
#[test]
fn non_loopback_with_key_starts() {
assert!(check_bind_authorization("0.0.0.0:8383", true, false).is_ok());
}
#[test]
fn unparseable_host_fails_closed_without_override_or_key() {
assert!(check_bind_authorization("example.com:8383", false, false).is_err());
assert!(check_bind_authorization("example.com:8383", false, true).is_ok());
assert!(check_bind_authorization("example.com:8383", true, false).is_ok());
}
#[test]
fn tls_paths_requires_both_or_neither() {
let both: (Result<&str, ()>, Result<&str, ()>) = (Ok("cert"), Ok("key"));
assert!(matches!(both, (Ok(_), Ok(_))));
let neither: (Result<&str, ()>, Result<&str, ()>) = (Err(()), Err(()));
assert!(matches!(neither, (Err(_), Err(_))));
}
#[test]
fn cors_origin_parsing_accepts_a_valid_origin() {
let origins = parse_cors_origins("https://chat.example.com").unwrap();
assert_eq!(
origins,
vec![HeaderValue::from_static("https://chat.example.com")]
);
}
#[test]
fn cors_origin_parsing_accepts_multiple_valid_origins() {
let origins =
parse_cors_origins("https://chat.example.com, https://app.example.com").unwrap();
assert_eq!(origins.len(), 2);
assert_eq!(
origins[0],
HeaderValue::from_static("https://chat.example.com")
);
assert_eq!(
origins[1],
HeaderValue::from_static("https://app.example.com")
);
}
#[test]
fn cors_origin_parsing_rejects_an_invalid_header_value() {
let err = parse_cors_origins("https://ok.example.com,bad\nvalue")
.expect_err("a header-value-invalid origin must be rejected");
assert!(err.contains("invalid origin"));
}
#[test]
fn cors_origin_parsing_rejects_wildcard() {
let err =
parse_cors_origins("*").expect_err("the \"*\" wildcard must be explicitly rejected");
assert!(err.contains("wildcard"));
let err = parse_cors_origins("https://chat.example.com,*")
.expect_err("\"*\" must be rejected even mixed in with valid origins");
assert!(err.contains("wildcard"));
}
}