use actix_cors::Cors;
use actix_governor::governor::middleware::NoOpMiddleware;
use actix_governor::{
GovernorConfig, GovernorConfigBuilder, KeyExtractor, SimpleKeyExtractionError,
};
use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::http::header;
use actix_web::middleware::{DefaultHeaders, Next};
use std::collections::HashSet;
use std::net::IpAddr;
use tracing::info;
use tracing::warn;
const DEFAULT_RATE_LIMIT_PER_SECOND: u64 = 10;
const DEFAULT_RATE_LIMIT_BURST: u32 = 20;
#[derive(Clone, Debug)]
pub struct ClientIpKeyExtractor {
trust_xff: bool,
trusted_hops: usize,
}
impl ClientIpKeyExtractor {
#[cfg(test)]
fn peer_ip() -> Self {
Self {
trust_xff: false,
trusted_hops: 1,
}
}
fn client_ip_from_xff(&self, req: &ServiceRequest) -> Option<IpAddr> {
let hops = self.trusted_hops.max(1);
let entries: Vec<&str> = req
.headers()
.get_all("x-forwarded-for")
.filter_map(|v| v.to_str().ok())
.flat_map(|line| line.split(','))
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if entries.len() < hops {
return None;
}
parse_forwarded_ip(entries[entries.len() - hops])
}
}
impl KeyExtractor for ClientIpKeyExtractor {
type Key = IpAddr;
type KeyExtractionError = SimpleKeyExtractionError<&'static str>;
fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError> {
if self.trust_xff {
if let Some(client) = self.client_ip_from_xff(req) {
return Ok(mask_ipv6_prefix(client));
}
}
let ip = req.peer_addr().map(|socket| socket.ip()).ok_or_else(|| {
SimpleKeyExtractionError::new("Could not extract peer IP address from request")
})?;
Ok(mask_ipv6_prefix(ip))
}
}
fn mask_ipv6_prefix(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => {
let mut octets = v6.octets();
octets[7..16].fill(0);
IpAddr::V6(octets.into())
}
v4 => v4,
}
}
fn parse_forwarded_ip(s: &str) -> Option<IpAddr> {
let s = s.trim();
if let Ok(ip) = s.parse::<IpAddr>() {
return Some(ip);
}
if let Ok(sa) = s.parse::<std::net::SocketAddr>() {
return Some(sa.ip());
}
let unbracketed = s.strip_prefix('[').and_then(|x| x.strip_suffix(']'))?;
unbracketed.parse::<IpAddr>().ok()
}
fn rate_limiter_config(
per_second: u64,
burst: u32,
key_extractor: ClientIpKeyExtractor,
) -> GovernorConfig<ClientIpKeyExtractor, NoOpMiddleware> {
let ms_per_request = (1000 / per_second.max(1)).max(1);
GovernorConfigBuilder::default()
.milliseconds_per_request(ms_per_request)
.burst_size(burst.max(1))
.key_extractor(key_extractor)
.finish()
.expect("rate limiter config is valid (non-zero period and burst)")
}
pub fn build_rate_limiter() -> GovernorConfig<ClientIpKeyExtractor, NoOpMiddleware> {
let per_second = std::env::var("BAMBOO_RATE_LIMIT_PER_SECOND")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_RATE_LIMIT_PER_SECOND);
let burst = std::env::var("BAMBOO_RATE_LIMIT_BURST")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.unwrap_or(DEFAULT_RATE_LIMIT_BURST);
let trust_xff = std::env::var("BAMBOO_RATE_LIMIT_TRUST_XFF")
.ok()
.map(|v| {
let t = v.trim();
t == "1" || t.eq_ignore_ascii_case("true")
})
.unwrap_or(false);
let trusted_hops = std::env::var("BAMBOO_RATE_LIMIT_TRUSTED_HOPS")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|n| *n >= 1)
.unwrap_or(1);
if trust_xff {
warn!(
"Rate limiter is trusting X-Forwarded-For (trusted_hops={trusted_hops}). \
Only enable this when the server is reachable exclusively through a trusted \
reverse proxy — otherwise clients can spoof their rate-limit key."
);
}
rate_limiter_config(
per_second,
burst,
ClientIpKeyExtractor {
trust_xff,
trusted_hops,
},
)
}
pub fn is_loopback_bind(bind: &str) -> bool {
matches!(bind, "127.0.0.1" | "localhost" | "::1")
}
pub fn require_limiter_for_nonloopback(bind: &str, limiter_applied: bool) -> Result<(), String> {
if !limiter_applied && !is_loopback_bind(bind) {
return Err(format!(
"refusing to serve on non-loopback bind '{bind}' without a rate limiter: it would \
run unthrottled and re-open the per-IP DoS surface closed by #13. Use a \
limiter-applying serve path (e.g. start_with_bind_and_static / run_with_bind) or \
bind to loopback (127.0.0.1 / localhost / ::1)."
));
}
Ok(())
}
const DEFAULT_CSP: &str = concat!(
"default-src 'self'; ",
"base-uri 'self'; ",
"object-src 'none'; ",
"frame-ancestors 'none'; ",
"script-src 'self'; ",
"style-src 'self' 'unsafe-inline'; ",
"img-src 'self' data: https:; ",
"font-src 'self' data:; ",
"connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* http://bodhi.bigduu.com:9562 https://bodhi.bigduu.com:9562; ",
"form-action 'self';"
);
fn normalize_csp_source_token(token: &str) -> Option<String> {
let trimmed = token.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with("'") {
return Some(trimmed.to_string());
}
normalize_origin(trimmed).or_else(|| Some(trimmed.to_string()))
}
fn parse_csp_connect_src_append(raw: &str) -> Vec<String> {
raw.split(|c: char| c == ',' || c.is_ascii_whitespace())
.filter_map(normalize_csp_source_token)
.collect()
}
fn append_connect_src_sources(base_csp: &str, extra_sources: &[String]) -> String {
if extra_sources.is_empty() {
return base_csp.to_string();
}
let connect_src_marker = "connect-src ";
if let Some(start) = base_csp.find(connect_src_marker) {
let value_start = start + connect_src_marker.len();
if let Some(relative_end) = base_csp[value_start..].find(';') {
let value_end = value_start + relative_end;
let existing_value = base_csp[value_start..value_end].trim();
let mut merged = if existing_value.is_empty() {
String::new()
} else {
existing_value.to_string()
};
for source in extra_sources {
if merged.split_whitespace().any(|token| token == source) {
continue;
}
if !merged.is_empty() {
merged.push(' ');
}
merged.push_str(source);
}
let mut result = String::with_capacity(base_csp.len() + merged.len() + 1);
result.push_str(&base_csp[..value_start]);
result.push_str(&merged);
result.push_str(&base_csp[value_end..]);
return result;
}
}
base_csp.to_string()
}
fn resolve_default_csp() -> String {
const ENV_KEY: &str = "BAMBOO_CSP_CONNECT_SRC";
let extra_sources = match std::env::var(ENV_KEY) {
Ok(raw) => parse_csp_connect_src_append(&raw),
Err(_) => Vec::new(),
};
if !extra_sources.is_empty() {
info!(
"Extending CSP connect-src via {} with {} source(s)",
ENV_KEY,
extra_sources.len()
);
}
append_connect_src_sources(DEFAULT_CSP, &extra_sources)
}
fn resolve_csp_header_value(override_value: Option<&str>) -> header::HeaderValue {
let default_csp = resolve_default_csp();
let csp = override_value.unwrap_or(default_csp.as_str());
match header::HeaderValue::from_str(csp) {
Ok(v) => v,
Err(e) => {
warn!(
"Invalid BAMBOO_CSP value ({}); falling back to DEFAULT_CSP",
e
);
header::HeaderValue::from_str(default_csp.as_str())
.unwrap_or_else(|_| header::HeaderValue::from_static(DEFAULT_CSP))
}
}
}
#[derive(Debug, Clone, Default)]
struct CorsAllowlist {
exact_origins: HashSet<String>,
hosts: Vec<HostPattern>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum HostPattern {
Exact(String),
Suffix(String), }
fn normalize_origin(origin: &str) -> Option<String> {
let url = url::Url::parse(origin).ok()?;
let scheme = url.scheme().to_ascii_lowercase();
let host = url.host()?;
let host_str = match host {
url::Host::Domain(d) => d.to_ascii_lowercase(),
url::Host::Ipv4(v4) => v4.to_string(),
url::Host::Ipv6(v6) => format!("[{v6}]"),
};
let port = url.port();
let default_port = match scheme.as_str() {
"http" => Some(80),
"https" => Some(443),
_ => None,
};
let port = match (port, default_port) {
(Some(p), Some(d)) if p == d => None,
(p, _) => p,
};
Some(match port {
Some(p) => format!("{scheme}://{host_str}:{p}"),
None => format!("{scheme}://{host_str}"),
})
}
fn parse_cors_allowlist(raw: &str) -> CorsAllowlist {
let mut allow = CorsAllowlist::default();
for item in raw.split(',') {
let token = item.trim();
if token.is_empty() {
continue;
}
if token.contains("://") {
match normalize_origin(token) {
Some(origin) => {
allow.exact_origins.insert(origin);
}
None => {
warn!(
"Invalid CORS origin entry '{}'; expected an origin like https://app.example.com",
token
);
}
}
continue;
}
let host = token.to_ascii_lowercase();
if let Some(rest) = host.strip_prefix("*.") {
if !rest.is_empty() {
allow.hosts.push(HostPattern::Suffix(format!(".{rest}")));
}
} else {
allow.hosts.push(HostPattern::Exact(host));
}
}
allow
}
fn parse_cors_allowlist_env() -> CorsAllowlist {
const ENV_KEY: &str = "BAMBOO_CORS_ALLOW_ORIGINS";
let raw = match std::env::var(ENV_KEY) {
Ok(v) => v,
Err(_) => return CorsAllowlist::default(),
};
let allow = parse_cors_allowlist(&raw);
if !allow.exact_origins.is_empty() || !allow.hosts.is_empty() {
info!(
"CORS allowlist enabled via BAMBOO_CORS_ALLOW_ORIGINS ({} exact origin(s), {} host pattern(s))",
allow.exact_origins.len(),
allow.hosts.len()
);
}
allow
}
fn is_allowed_by_allowlist(origin: &str, allow: &CorsAllowlist) -> bool {
if let Some(normalized) = normalize_origin(origin) {
if allow.exact_origins.contains(&normalized) {
return true;
}
}
if allow.exact_origins.contains(origin) {
return true;
}
let url = match url::Url::parse(origin) {
Ok(u) => u,
Err(_) => return false,
};
let host = match url.host_str() {
Some(h) => h.to_ascii_lowercase(),
None => return false,
};
for pat in &allow.hosts {
match pat {
HostPattern::Exact(h) => {
if &host == h {
return true;
}
}
HostPattern::Suffix(suffix) => {
if host.ends_with(suffix) {
return true;
}
}
}
}
false
}
fn is_local_dev_origin(o: &str) -> bool {
o.starts_with("http://localhost:")
|| o.starts_with("http://127.0.0.1:")
|| o.starts_with("https://localhost:")
|| o.starts_with("https://127.0.0.1:")
|| o.starts_with("http://mac.local:")
|| o.starts_with("https://mac.local:")
|| o.starts_with("http://bodhi.bigduu.com:")
|| o.starts_with("https://bodhi.bigduu.com:")
|| o.starts_with("http://[::1]:")
|| o.starts_with("https://[::1]:")
}
pub fn build_security_headers() -> DefaultHeaders {
let csp_override = std::env::var("BAMBOO_CSP").ok();
let csp_value = resolve_csp_header_value(csp_override.as_deref());
DefaultHeaders::new()
.add(("X-Frame-Options", "DENY"))
.add(("X-Content-Type-Options", "nosniff"))
.add(("X-XSS-Protection", "1; mode=block"))
.add(("Referrer-Policy", "strict-origin-when-cross-origin"))
.add((header::CONTENT_SECURITY_POLICY, csp_value))
}
pub async fn add_asset_cache_headers<B: MessageBody + 'static>(
req: ServiceRequest,
next: Next<B>,
) -> Result<ServiceResponse<B>, actix_web::Error> {
let is_asset = req.path().starts_with("/assets/");
let mut res = next.call(req).await?;
if is_asset {
res.headers_mut().insert(
header::CACHE_CONTROL,
header::HeaderValue::from_static("public, max-age=31536000, immutable"),
);
}
Ok(res)
}
pub fn build_cors(bind_addr: &str, port: u16) -> Cors {
let allowlist = parse_cors_allowlist_env();
let cors = if bind_addr == "127.0.0.1" || bind_addr == "localhost" || bind_addr == "::1" {
info!("CORS configured for development mode: allowing local/Tauri origins (+ optional allowlist)");
Cors::default()
.allowed_origin_fn(move |origin, _req_head| {
let o = match origin.to_str() {
Ok(v) => v,
Err(_) => return false,
};
if is_allowed_by_allowlist(o, &allowlist) {
return true;
}
if is_local_dev_origin(o) {
return true;
}
o == "tauri://localhost"
|| o == "https://tauri.localhost"
|| o == "http://tauri.localhost"
})
.allow_any_method()
.allow_any_header()
.supports_credentials()
.max_age(3600)
} else if bind_addr == "0.0.0.0" {
info!("CORS configured for 0.0.0.0 bind: allowing localhost/loopback origins (+ optional allowlist)");
Cors::default()
.allowed_origin_fn(move |origin, _req_head| {
let o = match origin.to_str() {
Ok(v) => v,
Err(_) => return false,
};
if is_allowed_by_allowlist(o, &allowlist) {
return true;
}
if is_local_dev_origin(o) {
return true;
}
if o == "tauri://localhost"
|| o == "https://tauri.localhost"
|| o == "http://tauri.localhost"
{
return true;
}
if o == format!("http://localhost:{port}")
|| o == format!("http://127.0.0.1:{port}")
{
return true;
}
false
})
.allow_any_method()
.allow_any_header()
.supports_credentials()
.max_age(3600)
} else {
info!(
"CORS configured for custom bind address: {} (+ optional allowlist)",
bind_addr
);
let bind_host = bind_addr.to_ascii_lowercase();
let allowlist = allowlist.clone();
Cors::default()
.allowed_origin_fn(move |origin, _req_head| {
let o = match origin.to_str() {
Ok(v) => v,
Err(_) => return false,
};
if is_allowed_by_allowlist(o, &allowlist) {
return true;
}
let url = match url::Url::parse(o) {
Ok(u) => u,
Err(_) => return false,
};
let Some(host) = url.host_str() else {
return false;
};
host.eq_ignore_ascii_case(&bind_host)
})
.allow_any_method()
.allow_any_header()
.supports_credentials()
.max_age(3600)
};
cors
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_limiter_config_clamps_degenerate_values() {
let _ = rate_limiter_config(0, 0, ClientIpKeyExtractor::peer_ip());
let _ = rate_limiter_config(1000, 1, ClientIpKeyExtractor::peer_ip());
}
#[test]
fn loopback_binds_skip_rate_limiter() {
for b in ["127.0.0.1", "localhost", "::1"] {
assert!(
is_loopback_bind(b),
"{b} should be loopback (limiter skipped)"
);
}
for b in ["0.0.0.0", "192.168.1.10", "::"] {
assert!(!is_loopback_bind(b), "{b} should be throttled");
}
}
#[actix_web::test]
async fn asset_cache_headers_only_tag_hashed_assets() {
use actix_web::http::header::CACHE_CONTROL;
use actix_web::{test, web, App, HttpResponse};
let app = test::init_service(
App::new()
.wrap(actix_web::middleware::from_fn(add_asset_cache_headers))
.route(
"/assets/main-abc123.css",
web::get().to(|| async { HttpResponse::Ok().finish() }),
)
.route(
"/index.html",
web::get().to(|| async { HttpResponse::Ok().finish() }),
),
)
.await;
let req = test::TestRequest::get()
.uri("/assets/main-abc123.css")
.to_request();
let res = test::call_service(&app, req).await;
assert_eq!(
res.headers()
.get(CACHE_CONTROL)
.and_then(|v| v.to_str().ok()),
Some("public, max-age=31536000, immutable"),
);
let req = test::TestRequest::get().uri("/index.html").to_request();
let res = test::call_service(&app, req).await;
assert!(
res.headers().get(CACHE_CONTROL).is_none(),
"non-asset routes must not be long-cached"
);
}
#[actix_web::test]
async fn rate_limiter_throttles_with_429_after_burst() {
use actix_governor::Governor;
use actix_web::http::StatusCode;
use actix_web::{test, web, App, HttpResponse};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let conf = rate_limiter_config(1, 2, ClientIpKeyExtractor::peer_ip());
let app = test::init_service(
App::new()
.wrap(Governor::new(&conf))
.route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
)
.await;
let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 9999);
let (mut saw_ok, mut saw_429) = (false, false);
for _ in 0..6 {
let req = test::TestRequest::get().uri("/").peer_addr(ip).to_request();
match test::call_service(&app, req).await.status() {
StatusCode::OK => saw_ok = true,
StatusCode::TOO_MANY_REQUESTS => saw_429 = true,
other => panic!("unexpected status {other}"),
}
}
assert!(saw_ok, "requests within the burst must pass");
assert!(saw_429, "requests beyond the burst must be 429'd (#13)");
let other_ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 8888);
let req = test::TestRequest::get()
.uri("/")
.peer_addr(other_ip)
.to_request();
assert_eq!(
test::call_service(&app, req).await.status(),
StatusCode::OK,
"a different IP gets its own fresh bucket (per-IP, not global)"
);
}
#[actix_web::test]
async fn key_extractor_default_ignores_xff_and_uses_peer_ip() {
use actix_web::test;
use std::net::{Ipv4Addr, SocketAddr};
let ke = ClientIpKeyExtractor::peer_ip();
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 5000);
let req = test::TestRequest::get()
.peer_addr(peer)
.insert_header(("x-forwarded-for", "1.2.3.4"))
.to_srv_request();
assert_eq!(
ke.extract(&req).unwrap(),
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))
);
}
#[actix_web::test]
async fn key_extractor_xff_uses_rightmost_at_one_hop_not_client_prefix() {
use actix_web::test;
use std::net::{Ipv4Addr, SocketAddr};
let ke = ClientIpKeyExtractor {
trust_xff: true,
trusted_hops: 1,
};
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000); let req = test::TestRequest::get()
.peer_addr(peer)
.insert_header(("x-forwarded-for", "1.1.1.1, 2.2.2.2"))
.to_srv_request();
assert_eq!(
ke.extract(&req).unwrap(),
IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
);
}
#[actix_web::test]
async fn key_extractor_xff_two_hops_takes_second_from_right() {
use actix_web::test;
use std::net::{Ipv4Addr, SocketAddr};
let ke = ClientIpKeyExtractor {
trust_xff: true,
trusted_hops: 2,
};
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
let req = test::TestRequest::get()
.peer_addr(peer)
.insert_header(("x-forwarded-for", "1.1.1.1, 2.2.2.2, 3.3.3.3"))
.to_srv_request();
assert_eq!(
ke.extract(&req).unwrap(),
IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
);
}
#[actix_web::test]
async fn key_extractor_xff_fails_closed_to_peer_when_header_too_short_or_absent() {
use actix_web::test;
use std::net::{Ipv4Addr, SocketAddr};
let ke = ClientIpKeyExtractor {
trust_xff: true,
trusted_hops: 2,
};
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
let short = test::TestRequest::get()
.peer_addr(peer)
.insert_header(("x-forwarded-for", "9.9.9.9"))
.to_srv_request();
assert_eq!(
ke.extract(&short).unwrap(),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
);
let none = test::TestRequest::get().peer_addr(peer).to_srv_request();
assert_eq!(
ke.extract(&none).unwrap(),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
);
}
#[actix_web::test]
async fn key_extractor_xff_flattens_multiple_header_lines_in_order() {
use actix_web::test;
use std::net::{Ipv4Addr, SocketAddr};
let ke = ClientIpKeyExtractor {
trust_xff: true,
trusted_hops: 1,
};
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
let req = test::TestRequest::get()
.peer_addr(peer)
.append_header(("x-forwarded-for", "1.1.1.1"))
.append_header(("x-forwarded-for", "2.2.2.2"))
.to_srv_request();
assert_eq!(
ke.extract(&req).unwrap(),
IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
);
}
#[test]
fn parse_forwarded_ip_handles_bare_port_and_bracketed_forms() {
use std::net::{Ipv4Addr, Ipv6Addr};
assert_eq!(
parse_forwarded_ip("1.2.3.4"),
Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
);
assert_eq!(
parse_forwarded_ip("1.2.3.4:5678"),
Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
);
assert_eq!(
parse_forwarded_ip("[::1]:9000"),
Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
);
assert_eq!(
parse_forwarded_ip("[::1]"),
Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
);
assert_eq!(parse_forwarded_ip("not-an-ip"), None);
}
#[test]
fn mask_ipv6_prefix_zeroes_lower_bytes_and_leaves_ipv4() {
use std::net::{Ipv4Addr, Ipv6Addr};
let v4 = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
assert_eq!(mask_ipv6_prefix(v4), v4);
let v6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6));
assert_eq!(
mask_ipv6_prefix(v6),
IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0))
);
}
macro_rules! probe_cors_and_preflight {
($app:expr, $ip:expr, $origin:expr, $gets:expr) => {{
use actix_web::http::header;
use actix_web::test;
let mut status = actix_web::http::StatusCode::OK;
let mut has_acao = false;
for _ in 0..$gets {
let res = test::call_service(
&$app,
test::TestRequest::get()
.uri("/")
.peer_addr($ip)
.insert_header((header::ORIGIN, $origin))
.to_request(),
)
.await;
status = res.status();
has_acao = res
.headers()
.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN);
}
let pre = test::call_service(
&$app,
test::TestRequest::default()
.method(actix_web::http::Method::OPTIONS)
.uri("/")
.peer_addr($ip)
.insert_header((header::ORIGIN, $origin))
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "GET"))
.to_request(),
)
.await;
(status, has_acao, pre.status())
}};
}
#[actix_web::test]
async fn governor_inside_cors_makes_429_cors_readable_and_exempts_preflight() {
use actix_governor::Governor;
use actix_web::http::StatusCode;
use actix_web::{test, web, App, HttpResponse};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let conf = rate_limiter_config(1, 1, ClientIpKeyExtractor::peer_ip());
let app = test::init_service(
App::new()
.wrap(Governor::new(&conf)) .wrap(build_cors("0.0.0.0", 9562)) .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
)
.await;
let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 9999);
let (get_status, get_has_acao, preflight_status) =
probe_cors_and_preflight!(app, ip, "http://localhost:5173", 2);
assert_eq!(
get_status,
StatusCode::TOO_MANY_REQUESTS,
"the 2nd GET past the burst must be throttled (#13 guarantee intact)"
);
assert!(
get_has_acao,
"a 429 must carry Access-Control-Allow-Origin so a browser sees a readable 429, \
not an opaque network error (#169 part 2)"
);
assert_ne!(
preflight_status,
StatusCode::TOO_MANY_REQUESTS,
"a CORS preflight must NOT be throttled — it never reaches Governor (#169 part 2)"
);
}
#[actix_web::test]
async fn governor_outside_cors_regression_drops_cors_and_throttles_preflight() {
use actix_governor::Governor;
use actix_web::http::StatusCode;
use actix_web::{test, web, App, HttpResponse};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let conf = rate_limiter_config(1, 1, ClientIpKeyExtractor::peer_ip());
let app = test::init_service(
App::new()
.wrap(build_cors("0.0.0.0", 9562)) .wrap(Governor::new(&conf)) .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
)
.await;
let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 8)), 9999);
let (get_status, get_has_acao, preflight_status) =
probe_cors_and_preflight!(app, ip, "http://localhost:5173", 2);
assert_eq!(
get_status,
StatusCode::TOO_MANY_REQUESTS,
"still a 429 in the wrong order..."
);
assert!(
!get_has_acao,
"...but WITHOUT CORS headers — the browser-opaque failure #169 part 2 fixes"
);
assert_eq!(
preflight_status,
StatusCode::TOO_MANY_REQUESTS,
"and the preflight IS throttled in the wrong order (counted against the bucket)"
);
}
#[test]
fn require_limiter_rejects_nonloopback_without_limiter() {
for b in ["0.0.0.0", "192.168.1.10", "::"] {
assert!(
require_limiter_for_nonloopback(b, false).is_err(),
"{b} without a limiter must be rejected (#169 part 3)"
);
}
}
#[test]
fn require_limiter_allows_loopback_and_limited_binds() {
for b in ["127.0.0.1", "localhost", "::1"] {
assert!(
require_limiter_for_nonloopback(b, false).is_ok(),
"{b} loopback must stay allowed without a limiter (desktop behavior)"
);
}
for b in ["0.0.0.0", "192.168.1.10"] {
assert!(
require_limiter_for_nonloopback(b, true).is_ok(),
"{b} with a limiter applied must be allowed"
);
}
}
#[test]
fn default_csp_keeps_scripts_strict_but_allows_inline_styles() {
assert!(DEFAULT_CSP.contains("script-src 'self'"));
assert!(DEFAULT_CSP.contains("style-src 'self' 'unsafe-inline'"));
assert!(!DEFAULT_CSP.contains("unsafe-eval"));
}
#[test]
fn connect_src_append_normalizes_explicit_origins() {
let sources = parse_csp_connect_src_append(
"https://bodhi.bigduu.com:9562, http://bodhi.bigduu.com:9562/",
);
assert_eq!(
sources,
vec![
"https://bodhi.bigduu.com:9562".to_string(),
"http://bodhi.bigduu.com:9562".to_string(),
]
);
}
#[test]
fn append_connect_src_sources_extends_default_csp() {
let csp = append_connect_src_sources(
DEFAULT_CSP,
&[
"https://bodhi.bigduu.com:9562".to_string(),
"http://bodhi.bigduu.com:9562".to_string(),
],
);
assert!(csp.contains("connect-src 'self' ws: wss:"));
assert!(csp.contains("https://bodhi.bigduu.com:9562"));
assert!(csp.contains("http://bodhi.bigduu.com:9562"));
}
#[test]
fn invalid_override_falls_back_to_default() {
let v = resolve_csp_header_value(Some("default-src 'self'\nscript-src 'self'"));
let rendered = v.to_str().expect("header should be valid utf-8");
assert!(rendered.contains("connect-src 'self' ws: wss:"));
assert!(rendered.contains("http://127.0.0.1:*"));
assert!(rendered.contains("http://localhost:*"));
assert!(rendered.contains("http://bodhi.bigduu.com:9562"));
assert!(rendered.contains("https://bodhi.bigduu.com:9562"));
assert!(rendered.contains("style-src 'self' 'unsafe-inline'"));
}
#[test]
fn cors_allowlist_parses_hosts_and_origins() {
let allow = parse_cors_allowlist(
"https://app.example.com/, app.example2.com, *.example.net , http://localhost:5173",
);
assert!(allow.exact_origins.contains("https://app.example.com"));
assert!(allow.exact_origins.contains("http://localhost:5173"));
assert!(allow
.hosts
.contains(&HostPattern::Exact("app.example2.com".to_string())));
assert!(allow
.hosts
.contains(&HostPattern::Suffix(".example.net".to_string())));
}
#[test]
fn cors_allowlist_matches_exact_and_wildcard_hosts() {
let mut allow = CorsAllowlist::default();
allow
.exact_origins
.insert("https://app.example.com".to_string());
allow
.hosts
.push(HostPattern::Exact("app2.example.com".to_string()));
allow
.hosts
.push(HostPattern::Suffix(".example.net".to_string()));
assert!(is_allowed_by_allowlist("https://app.example.com", &allow));
assert!(is_allowed_by_allowlist(
"https://app.example.com:443",
&allow
));
assert!(is_allowed_by_allowlist(
"http://app2.example.com:5173",
&allow
));
assert!(is_allowed_by_allowlist("https://x.example.net", &allow));
assert!(!is_allowed_by_allowlist("https://example.net", &allow));
assert!(!is_allowed_by_allowlist("https://evil.com", &allow));
}
#[test]
fn local_dev_origin_allows_mac_local_and_bodhi_domain() {
assert!(is_local_dev_origin("http://mac.local:1420"));
assert!(is_local_dev_origin("https://mac.local:1420"));
assert!(is_local_dev_origin("http://bodhi.bigduu.com:9562"));
assert!(is_local_dev_origin("https://bodhi.bigduu.com:9562"));
assert!(!is_local_dev_origin("http://evil.com:1420"));
}
}