1use actix_cors::Cors;
23use actix_web::body::MessageBody;
24use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse};
25use actix_web::http::header;
26use actix_web::middleware::{Condition, DefaultHeaders, Next};
27use actix_web::App;
28use std::collections::HashSet;
29use std::net::IpAddr;
30use tracing::info;
31use tracing::warn;
32
33use crate::rate_limit::{KeyExtractor, RateLimit, RateLimiterConfig, SimpleKeyExtractionError};
34
35const DEFAULT_RATE_LIMIT_PER_SECOND: u64 = 10;
38const DEFAULT_RATE_LIMIT_BURST: u32 = 20;
40
41#[derive(Clone, Debug)]
52pub struct ClientIpKeyExtractor {
53 trust_xff: bool,
54 trusted_hops: usize,
58}
59
60impl ClientIpKeyExtractor {
61 #[cfg(test)]
63 fn peer_ip() -> Self {
64 Self {
65 trust_xff: false,
66 trusted_hops: 1,
67 }
68 }
69
70 fn client_ip_from_xff(&self, req: &ServiceRequest) -> Option<IpAddr> {
71 let hops = self.trusted_hops.max(1);
72 let entries: Vec<&str> = req
78 .headers()
79 .get_all("x-forwarded-for")
80 .filter_map(|v| v.to_str().ok())
81 .flat_map(|line| line.split(','))
82 .map(|s| s.trim())
83 .filter(|s| !s.is_empty())
84 .collect();
85 if entries.len() < hops {
88 return None;
89 }
90 parse_forwarded_ip(entries[entries.len() - hops])
91 }
92}
93
94impl KeyExtractor for ClientIpKeyExtractor {
95 type Key = IpAddr;
96 type KeyExtractionError = SimpleKeyExtractionError;
97
98 fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError> {
99 if self.trust_xff {
100 if let Some(client) = self.client_ip_from_xff(req) {
101 return Ok(mask_ipv6_prefix(client));
102 }
103 }
105 let ip = req.peer_addr().map(|socket| socket.ip()).ok_or_else(|| {
106 SimpleKeyExtractionError::new("Could not extract peer IP address from request")
107 })?;
108 Ok(mask_ipv6_prefix(ip))
109 }
110}
111
112fn mask_ipv6_prefix(ip: IpAddr) -> IpAddr {
115 match ip {
116 IpAddr::V6(v6) => {
117 let mut octets = v6.octets();
118 octets[7..16].fill(0);
119 IpAddr::V6(octets.into())
120 }
121 v4 => v4,
122 }
123}
124
125fn parse_forwarded_ip(s: &str) -> Option<IpAddr> {
128 let s = s.trim();
129 if let Ok(ip) = s.parse::<IpAddr>() {
130 return Some(ip);
131 }
132 if let Ok(sa) = s.parse::<std::net::SocketAddr>() {
133 return Some(sa.ip());
134 }
135 let unbracketed = s.strip_prefix('[').and_then(|x| x.strip_suffix(']'))?;
137 unbracketed.parse::<IpAddr>().ok()
138}
139
140fn rate_limiter_config(
141 per_second: u64,
142 burst: u32,
143 key_extractor: ClientIpKeyExtractor,
144) -> RateLimiterConfig<ClientIpKeyExtractor> {
145 let ms_per_request = (1000 / per_second.max(1)).max(1);
149 RateLimiterConfig::new(
150 std::time::Duration::from_millis(ms_per_request),
151 burst.max(1),
152 key_extractor,
153 )
154}
155
156pub fn build_rate_limiter() -> RateLimiterConfig<ClientIpKeyExtractor> {
169 let per_second = std::env::var("BAMBOO_RATE_LIMIT_PER_SECOND")
170 .ok()
171 .and_then(|v| v.trim().parse::<u64>().ok())
172 .unwrap_or(DEFAULT_RATE_LIMIT_PER_SECOND);
173 let burst = std::env::var("BAMBOO_RATE_LIMIT_BURST")
174 .ok()
175 .and_then(|v| v.trim().parse::<u32>().ok())
176 .unwrap_or(DEFAULT_RATE_LIMIT_BURST);
177
178 let trust_xff = std::env::var("BAMBOO_RATE_LIMIT_TRUST_XFF")
179 .ok()
180 .map(|v| {
181 let t = v.trim();
182 t == "1" || t.eq_ignore_ascii_case("true")
183 })
184 .unwrap_or(false);
185 let trusted_hops = std::env::var("BAMBOO_RATE_LIMIT_TRUSTED_HOPS")
186 .ok()
187 .and_then(|v| v.trim().parse::<usize>().ok())
188 .filter(|n| *n >= 1)
189 .unwrap_or(1);
190
191 if trust_xff {
192 warn!(
193 "Rate limiter is trusting X-Forwarded-For (trusted_hops={trusted_hops}). \
194 Only enable this when the server is reachable exclusively through a trusted \
195 reverse proxy — otherwise clients can spoof their rate-limit key."
196 );
197 }
198
199 rate_limiter_config(
200 per_second,
201 burst,
202 ClientIpKeyExtractor {
203 trust_xff,
204 trusted_hops,
205 },
206 )
207}
208
209pub fn is_loopback_bind(bind: &str) -> bool {
224 let candidate = bind.trim();
225 let unbracketed = candidate
226 .strip_prefix('[')
227 .and_then(|s| s.strip_suffix(']'))
228 .unwrap_or(candidate);
229
230 if unbracketed.eq_ignore_ascii_case("localhost") {
231 return true;
232 }
233
234 unbracketed
235 .parse::<IpAddr>()
236 .map(|ip| ip.is_loopback())
237 .unwrap_or(false)
238}
239
240pub fn require_limiter_for_nonloopback(bind: &str, limiter_applied: bool) -> Result<(), String> {
255 if !limiter_applied && !is_loopback_bind(bind) {
256 return Err(format!(
257 "refusing to serve on non-loopback bind '{bind}' without a rate limiter: it would \
258 run unthrottled and re-open the per-IP DoS surface closed by #13. Use a \
259 limiter-applying serve path (e.g. start_with_bind_and_static / run_with_bind) or \
260 bind to loopback (127.0.0.1 / localhost / ::1)."
261 ));
262 }
263 Ok(())
264}
265
266const DEFAULT_CSP: &str = concat!(
271 "default-src 'self'; ",
272 "base-uri 'self'; ",
273 "object-src 'none'; ",
274 "frame-ancestors 'none'; ",
275 "script-src 'self'; ",
276 "style-src 'self' 'unsafe-inline'; ",
277 "img-src 'self' data: https:; ",
278 "font-src 'self' data:; ",
279 "connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* http://bodhi.bigduu.com:9562 https://bodhi.bigduu.com:9562; ",
280 "form-action 'self';"
281);
282
283fn normalize_csp_source_token(token: &str) -> Option<String> {
284 let trimmed = token.trim();
285 if trimmed.is_empty() {
286 return None;
287 }
288
289 if trimmed.starts_with("'") {
290 return Some(trimmed.to_string());
291 }
292
293 normalize_origin(trimmed).or_else(|| Some(trimmed.to_string()))
294}
295
296fn parse_csp_connect_src_append(raw: &str) -> Vec<String> {
297 raw.split(|c: char| c == ',' || c.is_ascii_whitespace())
298 .filter_map(normalize_csp_source_token)
299 .collect()
300}
301
302fn append_connect_src_sources(base_csp: &str, extra_sources: &[String]) -> String {
303 if extra_sources.is_empty() {
304 return base_csp.to_string();
305 }
306
307 let connect_src_marker = "connect-src ";
308 if let Some(start) = base_csp.find(connect_src_marker) {
309 let value_start = start + connect_src_marker.len();
310 if let Some(relative_end) = base_csp[value_start..].find(';') {
311 let value_end = value_start + relative_end;
312 let existing_value = base_csp[value_start..value_end].trim();
313 let mut merged = if existing_value.is_empty() {
314 String::new()
315 } else {
316 existing_value.to_string()
317 };
318
319 for source in extra_sources {
320 if merged.split_whitespace().any(|token| token == source) {
321 continue;
322 }
323 if !merged.is_empty() {
324 merged.push(' ');
325 }
326 merged.push_str(source);
327 }
328
329 let mut result = String::with_capacity(base_csp.len() + merged.len() + 1);
330 result.push_str(&base_csp[..value_start]);
331 result.push_str(&merged);
332 result.push_str(&base_csp[value_end..]);
333 return result;
334 }
335 }
336
337 base_csp.to_string()
338}
339
340fn resolve_default_csp() -> String {
341 const ENV_KEY: &str = "BAMBOO_CSP_CONNECT_SRC";
342
343 let extra_sources = match std::env::var(ENV_KEY) {
344 Ok(raw) => parse_csp_connect_src_append(&raw),
345 Err(_) => Vec::new(),
346 };
347
348 if !extra_sources.is_empty() {
349 info!(
350 "Extending CSP connect-src via {} with {} source(s)",
351 ENV_KEY,
352 extra_sources.len()
353 );
354 }
355
356 append_connect_src_sources(DEFAULT_CSP, &extra_sources)
357}
358
359fn resolve_csp_header_value(override_value: Option<&str>) -> header::HeaderValue {
360 let default_csp = resolve_default_csp();
361 let csp = override_value.unwrap_or(default_csp.as_str());
362 match header::HeaderValue::from_str(csp) {
363 Ok(v) => v,
364 Err(e) => {
365 warn!(
367 "Invalid BAMBOO_CSP value ({}); falling back to DEFAULT_CSP",
368 e
369 );
370 header::HeaderValue::from_str(default_csp.as_str())
371 .unwrap_or_else(|_| header::HeaderValue::from_static(DEFAULT_CSP))
372 }
373 }
374}
375
376#[derive(Debug, Clone, Default)]
383struct CorsAllowlist {
384 exact_origins: HashSet<String>,
385 hosts: Vec<HostPattern>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
389enum HostPattern {
390 Exact(String),
391 Suffix(String), }
393
394fn normalize_origin(origin: &str) -> Option<String> {
395 let url = url::Url::parse(origin).ok()?;
396
397 let scheme = url.scheme().to_ascii_lowercase();
398 let host = url.host()?;
399 let host_str = match host {
400 url::Host::Domain(d) => d.to_ascii_lowercase(),
401 url::Host::Ipv4(v4) => v4.to_string(),
402 url::Host::Ipv6(v6) => format!("[{v6}]"),
403 };
404
405 let port = url.port();
406 let default_port = match scheme.as_str() {
407 "http" => Some(80),
408 "https" => Some(443),
409 _ => None,
410 };
411 let port = match (port, default_port) {
412 (Some(p), Some(d)) if p == d => None,
413 (p, _) => p,
414 };
415
416 Some(match port {
417 Some(p) => format!("{scheme}://{host_str}:{p}"),
418 None => format!("{scheme}://{host_str}"),
419 })
420}
421
422fn parse_cors_allowlist(raw: &str) -> CorsAllowlist {
423 let mut allow = CorsAllowlist::default();
424
425 for item in raw.split(',') {
426 let token = item.trim();
427 if token.is_empty() {
428 continue;
429 }
430
431 if token.contains("://") {
432 match normalize_origin(token) {
435 Some(origin) => {
436 allow.exact_origins.insert(origin);
437 }
438 None => {
439 warn!(
440 "Invalid CORS origin entry '{}'; expected an origin like https://app.example.com",
441 token
442 );
443 }
444 }
445 continue;
446 }
447
448 let host = token.to_ascii_lowercase();
450 if let Some(rest) = host.strip_prefix("*.") {
451 if !rest.is_empty() {
453 allow.hosts.push(HostPattern::Suffix(format!(".{rest}")));
454 }
455 } else {
456 allow.hosts.push(HostPattern::Exact(host));
457 }
458 }
459
460 allow
461}
462
463fn parse_cors_allowlist_env() -> CorsAllowlist {
464 const ENV_KEY: &str = "BAMBOO_CORS_ALLOW_ORIGINS";
468
469 let raw = match std::env::var(ENV_KEY) {
470 Ok(v) => v,
471 Err(_) => return CorsAllowlist::default(),
472 };
473
474 let allow = parse_cors_allowlist(&raw);
475
476 if !allow.exact_origins.is_empty() || !allow.hosts.is_empty() {
477 info!(
478 "CORS allowlist enabled via BAMBOO_CORS_ALLOW_ORIGINS ({} exact origin(s), {} host pattern(s))",
479 allow.exact_origins.len(),
480 allow.hosts.len()
481 );
482 }
483
484 allow
485}
486
487fn is_allowed_by_allowlist(origin: &str, allow: &CorsAllowlist) -> bool {
488 if let Some(normalized) = normalize_origin(origin) {
489 if allow.exact_origins.contains(&normalized) {
490 return true;
491 }
492 }
493
494 if allow.exact_origins.contains(origin) {
496 return true;
497 }
498
499 let url = match url::Url::parse(origin) {
504 Ok(u) => u,
505 Err(_) => return false,
506 };
507
508 let host = match url.host_str() {
509 Some(h) => h.to_ascii_lowercase(),
510 None => return false,
511 };
512
513 for pat in &allow.hosts {
514 match pat {
515 HostPattern::Exact(h) => {
516 if &host == h {
517 return true;
518 }
519 }
520 HostPattern::Suffix(suffix) => {
521 if host.ends_with(suffix) {
522 return true;
525 }
526 }
527 }
528 }
529
530 false
531}
532
533fn is_local_dev_origin(o: &str) -> bool {
534 o.starts_with("http://localhost:")
535 || o.starts_with("http://127.0.0.1:")
536 || o.starts_with("https://localhost:")
537 || o.starts_with("https://127.0.0.1:")
538 || o.starts_with("http://mac.local:")
539 || o.starts_with("https://mac.local:")
540 || o.starts_with("http://bodhi.bigduu.com:")
541 || o.starts_with("https://bodhi.bigduu.com:")
542 || o.starts_with("http://[::1]:")
543 || o.starts_with("https://[::1]:")
544}
545
546pub fn build_security_headers() -> DefaultHeaders {
565 let csp_override = std::env::var("BAMBOO_CSP").ok();
566 let csp_value = resolve_csp_header_value(csp_override.as_deref());
567
568 DefaultHeaders::new()
569 .add(("X-Frame-Options", "DENY"))
570 .add(("X-Content-Type-Options", "nosniff"))
571 .add(("X-XSS-Protection", "1; mode=block"))
572 .add(("Referrer-Policy", "strict-origin-when-cross-origin"))
573 .add((header::CONTENT_SECURITY_POLICY, csp_value))
575}
576
577pub async fn add_asset_cache_headers<B: MessageBody + 'static>(
591 req: ServiceRequest,
592 next: Next<B>,
593) -> Result<ServiceResponse<B>, actix_web::Error> {
594 let is_asset = req.path().starts_with("/assets/");
595 let mut res = next.call(req).await?;
596 if is_asset {
597 res.headers_mut().insert(
598 header::CACHE_CONTROL,
599 header::HeaderValue::from_static("public, max-age=31536000, immutable"),
600 );
601 }
602 Ok(res)
603}
604
605pub fn build_cors(bind_addr: &str, port: u16) -> Cors {
644 let allowlist = parse_cors_allowlist_env();
645
646 let cors = if bind_addr == "127.0.0.1" || bind_addr == "localhost" || bind_addr == "::1" {
647 info!("CORS configured for development mode: allowing local/Tauri origins (+ optional allowlist)");
651 Cors::default()
652 .allowed_origin_fn(move |origin, _req_head| {
653 let o = match origin.to_str() {
654 Ok(v) => v,
655 Err(_) => return false,
656 };
657
658 if is_allowed_by_allowlist(o, &allowlist) {
659 return true;
660 }
661
662 if is_local_dev_origin(o) {
663 return true;
664 }
665
666 o == "tauri://localhost"
667 || o == "https://tauri.localhost"
668 || o == "http://tauri.localhost"
669 })
670 .allow_any_method()
671 .allow_any_header()
672 .supports_credentials()
673 .max_age(3600)
674 } else if bind_addr == "0.0.0.0" {
675 info!("CORS configured for 0.0.0.0 bind: allowing localhost/loopback origins (+ optional allowlist)");
685 Cors::default()
686 .allowed_origin_fn(move |origin, _req_head| {
687 let o = match origin.to_str() {
688 Ok(v) => v,
689 Err(_) => return false,
690 };
691
692 if is_allowed_by_allowlist(o, &allowlist) {
694 return true;
695 }
696
697 if is_local_dev_origin(o) {
699 return true;
700 }
701
702 if o == "tauri://localhost"
704 || o == "https://tauri.localhost"
705 || o == "http://tauri.localhost"
706 {
707 return true;
708 }
709
710 if o == format!("http://localhost:{port}")
712 || o == format!("http://127.0.0.1:{port}")
713 {
714 return true;
715 }
716
717 false
718 })
719 .allow_any_method()
722 .allow_any_header()
725 .supports_credentials()
726 .max_age(3600)
727 } else {
728 info!(
730 "CORS configured for custom bind address: {} (+ optional allowlist)",
731 bind_addr
732 );
733 let bind_host = bind_addr.to_ascii_lowercase();
734 let allowlist = allowlist.clone();
735 Cors::default()
736 .allowed_origin_fn(move |origin, _req_head| {
737 let o = match origin.to_str() {
738 Ok(v) => v,
739 Err(_) => return false,
740 };
741
742 if is_allowed_by_allowlist(o, &allowlist) {
743 return true;
744 }
745
746 let url = match url::Url::parse(o) {
749 Ok(u) => u,
750 Err(_) => return false,
751 };
752 let Some(host) = url.host_str() else {
753 return false;
754 };
755 host.eq_ignore_ascii_case(&bind_host)
756 })
757 .allow_any_method()
758 .allow_any_header()
759 .supports_credentials()
760 .max_age(3600)
761 };
762
763 cors.expose_headers([header::ETAG])
766}
767
768pub fn wrap_governor_and_cors<T, B>(
790 app: App<T>,
791 rate_limiter: &RateLimiterConfig<ClientIpKeyExtractor>,
792 apply_rate_limit: bool,
793 bind_addr: &str,
794 port: u16,
795) -> App<
796 impl ServiceFactory<
797 ServiceRequest,
798 Config = (),
799 Response = ServiceResponse<impl MessageBody>,
800 Error = actix_web::Error,
801 InitError = (),
802 >,
803>
804where
805 T: ServiceFactory<
806 ServiceRequest,
807 Config = (),
808 Response = ServiceResponse<B>,
809 Error = actix_web::Error,
810 InitError = (),
811 > + 'static,
812 B: MessageBody + 'static,
813{
814 app.wrap(Condition::new(
815 apply_rate_limit,
816 RateLimit::new(rate_limiter),
817 ))
818 .wrap(build_cors(bind_addr, port))
819}
820
821#[cfg(test)]
822mod tests {
823 use super::*;
824
825 #[test]
826 fn rate_limiter_config_clamps_degenerate_values() {
827 let _ = rate_limiter_config(0, 0, ClientIpKeyExtractor::peer_ip());
830 let _ = rate_limiter_config(1000, 1, ClientIpKeyExtractor::peer_ip());
831 }
832
833 #[test]
834 fn loopback_binds_skip_rate_limiter() {
835 for b in ["127.0.0.1", "localhost", "::1"] {
838 assert!(
839 is_loopback_bind(b),
840 "{b} should be loopback (limiter skipped)"
841 );
842 }
843 for b in ["0.0.0.0", "192.168.1.10", "::"] {
844 assert!(!is_loopback_bind(b), "{b} should be throttled");
845 }
846 }
847
848 #[test]
849 fn loopback_binds_recognizes_full_loopback_range_and_bracketed_ipv6() {
850 for b in ["127.0.0.2", "127.255.255.255", "[::1]", "LOCALHOST"] {
854 assert!(is_loopback_bind(b), "{b} is a loopback address/host");
855 }
856 }
857
858 #[actix_web::test]
859 async fn asset_cache_headers_only_tag_hashed_assets() {
860 use actix_web::http::header::CACHE_CONTROL;
861 use actix_web::{test, web, App, HttpResponse};
862
863 let app = test::init_service(
864 App::new()
865 .wrap(actix_web::middleware::from_fn(add_asset_cache_headers))
866 .route(
867 "/assets/main-abc123.css",
868 web::get().to(|| async { HttpResponse::Ok().finish() }),
869 )
870 .route(
871 "/index.html",
872 web::get().to(|| async { HttpResponse::Ok().finish() }),
873 ),
874 )
875 .await;
876
877 let req = test::TestRequest::get()
879 .uri("/assets/main-abc123.css")
880 .to_request();
881 let res = test::call_service(&app, req).await;
882 assert_eq!(
883 res.headers()
884 .get(CACHE_CONTROL)
885 .and_then(|v| v.to_str().ok()),
886 Some("public, max-age=31536000, immutable"),
887 );
888
889 let req = test::TestRequest::get().uri("/index.html").to_request();
892 let res = test::call_service(&app, req).await;
893 assert!(
894 res.headers().get(CACHE_CONTROL).is_none(),
895 "non-asset routes must not be long-cached"
896 );
897 }
898
899 #[actix_web::test]
900 async fn rate_limiter_throttles_with_429_after_burst() {
901 use crate::rate_limit::RateLimit;
902 use actix_web::http::StatusCode;
903 use actix_web::{test, web, App, HttpResponse};
904 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
905
906 let conf = rate_limiter_config(1, 2, ClientIpKeyExtractor::peer_ip());
908 let app = test::init_service(
909 App::new()
910 .wrap(RateLimit::new(&conf))
911 .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
912 )
913 .await;
914
915 let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 9999);
916 let (mut saw_ok, mut saw_429) = (false, false);
917 for _ in 0..6 {
918 let req = test::TestRequest::get().uri("/").peer_addr(ip).to_request();
919 match test::call_service(&app, req).await.status() {
920 StatusCode::OK => saw_ok = true,
921 StatusCode::TOO_MANY_REQUESTS => saw_429 = true,
922 other => panic!("unexpected status {other}"),
923 }
924 }
925 assert!(saw_ok, "requests within the burst must pass");
926 assert!(saw_429, "requests beyond the burst must be 429'd (#13)");
927
928 let other_ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 8888);
932 let req = test::TestRequest::get()
933 .uri("/")
934 .peer_addr(other_ip)
935 .to_request();
936 assert_eq!(
937 test::call_service(&app, req).await.status(),
938 StatusCode::OK,
939 "a different IP gets its own fresh bucket (per-IP, not global)"
940 );
941 }
942
943 #[actix_web::test]
944 async fn key_extractor_default_ignores_xff_and_uses_peer_ip() {
945 use actix_web::test;
946 use std::net::{Ipv4Addr, SocketAddr};
947
948 let ke = ClientIpKeyExtractor::peer_ip();
951 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 5000);
952 let req = test::TestRequest::get()
953 .peer_addr(peer)
954 .insert_header(("x-forwarded-for", "1.2.3.4"))
955 .to_srv_request();
956 assert_eq!(
957 ke.extract(&req).unwrap(),
958 IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))
959 );
960 }
961
962 #[actix_web::test]
963 async fn key_extractor_xff_uses_rightmost_at_one_hop_not_client_prefix() {
964 use actix_web::test;
965 use std::net::{Ipv4Addr, SocketAddr};
966
967 let ke = ClientIpKeyExtractor {
970 trust_xff: true,
971 trusted_hops: 1,
972 };
973 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000); let req = test::TestRequest::get()
975 .peer_addr(peer)
976 .insert_header(("x-forwarded-for", "1.1.1.1, 2.2.2.2"))
977 .to_srv_request();
978 assert_eq!(
979 ke.extract(&req).unwrap(),
980 IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
981 );
982 }
983
984 #[actix_web::test]
985 async fn key_extractor_xff_two_hops_takes_second_from_right() {
986 use actix_web::test;
987 use std::net::{Ipv4Addr, SocketAddr};
988
989 let ke = ClientIpKeyExtractor {
990 trust_xff: true,
991 trusted_hops: 2,
992 };
993 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
994 let req = test::TestRequest::get()
995 .peer_addr(peer)
996 .insert_header(("x-forwarded-for", "1.1.1.1, 2.2.2.2, 3.3.3.3"))
997 .to_srv_request();
998 assert_eq!(
999 ke.extract(&req).unwrap(),
1000 IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
1001 );
1002 }
1003
1004 #[actix_web::test]
1005 async fn key_extractor_xff_fails_closed_to_peer_when_header_too_short_or_absent() {
1006 use actix_web::test;
1007 use std::net::{Ipv4Addr, SocketAddr};
1008
1009 let ke = ClientIpKeyExtractor {
1010 trust_xff: true,
1011 trusted_hops: 2,
1012 };
1013 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
1014
1015 let short = test::TestRequest::get()
1017 .peer_addr(peer)
1018 .insert_header(("x-forwarded-for", "9.9.9.9"))
1019 .to_srv_request();
1020 assert_eq!(
1021 ke.extract(&short).unwrap(),
1022 IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
1023 );
1024
1025 let none = test::TestRequest::get().peer_addr(peer).to_srv_request();
1027 assert_eq!(
1028 ke.extract(&none).unwrap(),
1029 IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
1030 );
1031 }
1032
1033 #[actix_web::test]
1034 async fn key_extractor_xff_flattens_multiple_header_lines_in_order() {
1035 use actix_web::test;
1036 use std::net::{Ipv4Addr, SocketAddr};
1037
1038 let ke = ClientIpKeyExtractor {
1043 trust_xff: true,
1044 trusted_hops: 1,
1045 };
1046 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
1047 let req = test::TestRequest::get()
1048 .peer_addr(peer)
1049 .append_header(("x-forwarded-for", "1.1.1.1"))
1050 .append_header(("x-forwarded-for", "2.2.2.2"))
1051 .to_srv_request();
1052 assert_eq!(
1053 ke.extract(&req).unwrap(),
1054 IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
1055 );
1056 }
1057
1058 #[test]
1059 fn parse_forwarded_ip_handles_bare_port_and_bracketed_forms() {
1060 use std::net::{Ipv4Addr, Ipv6Addr};
1061
1062 assert_eq!(
1063 parse_forwarded_ip("1.2.3.4"),
1064 Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
1065 );
1066 assert_eq!(
1067 parse_forwarded_ip("1.2.3.4:5678"),
1068 Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
1069 );
1070 assert_eq!(
1071 parse_forwarded_ip("[::1]:9000"),
1072 Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
1073 );
1074 assert_eq!(
1075 parse_forwarded_ip("[::1]"),
1076 Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
1077 );
1078 assert_eq!(parse_forwarded_ip("not-an-ip"), None);
1079 }
1080
1081 #[test]
1082 fn mask_ipv6_prefix_zeroes_lower_bytes_and_leaves_ipv4() {
1083 use std::net::{Ipv4Addr, Ipv6Addr};
1084
1085 let v4 = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
1086 assert_eq!(mask_ipv6_prefix(v4), v4);
1087
1088 let v6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6));
1089 assert_eq!(
1091 mask_ipv6_prefix(v6),
1092 IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0))
1093 );
1094 }
1095
1096 macro_rules! probe_cors_and_preflight {
1104 ($app:expr, $ip:expr, $origin:expr, $gets:expr) => {{
1105 use actix_web::http::header;
1106 use actix_web::test;
1107
1108 let mut status = actix_web::http::StatusCode::OK;
1109 let mut has_acao = false;
1110 for _ in 0..$gets {
1111 let res = test::call_service(
1112 &$app,
1113 test::TestRequest::get()
1114 .uri("/")
1115 .peer_addr($ip)
1116 .insert_header((header::ORIGIN, $origin))
1117 .to_request(),
1118 )
1119 .await;
1120 status = res.status();
1121 has_acao = res
1122 .headers()
1123 .contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN);
1124 }
1125
1126 let pre = test::call_service(
1127 &$app,
1128 test::TestRequest::default()
1129 .method(actix_web::http::Method::OPTIONS)
1130 .uri("/")
1131 .peer_addr($ip)
1132 .insert_header((header::ORIGIN, $origin))
1133 .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "GET"))
1134 .to_request(),
1135 )
1136 .await;
1137
1138 (status, has_acao, pre.status())
1139 }};
1140 }
1141
1142 #[actix_web::test]
1147 async fn cors_exposes_session_etag_for_every_bind_mode() {
1148 use crate::routes::configure_routes;
1149 use crate::AppState;
1150 use actix_web::http::{header, Method, StatusCode};
1151 use actix_web::{test, web, App};
1152 use bamboo_agent_core::Session;
1153 use tempfile::tempdir;
1154
1155 let temp_dir = tempdir().expect("tempdir");
1156 bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1157 let state = web::Data::new(
1158 AppState::new(temp_dir.path().to_path_buf())
1159 .await
1160 .expect("app state"),
1161 );
1162 let session_id = "cors-etag-session";
1163 let mut session = Session::new(session_id, "model");
1164 state.save_and_cache_session(&mut session).await;
1165
1166 for (bind_addr, origin) in [
1167 ("127.0.0.1", "http://127.0.0.1:1420"),
1168 ("0.0.0.0", "http://127.0.0.1:1420"),
1169 ("192.0.2.10", "http://192.0.2.10:1420"),
1170 ] {
1171 let app = test::init_service(
1172 App::new()
1173 .app_data(state.clone())
1174 .wrap(build_cors(bind_addr, 9562))
1175 .configure(configure_routes),
1176 )
1177 .await;
1178
1179 let response = test::call_service(
1180 &app,
1181 test::TestRequest::get()
1182 .uri(&format!("/api/v1/sessions/{session_id}"))
1183 .insert_header((header::ORIGIN, origin))
1184 .to_request(),
1185 )
1186 .await;
1187 assert_eq!(response.status(), StatusCode::OK, "bind {bind_addr}");
1188 assert_eq!(
1189 response
1190 .headers()
1191 .get(header::ETAG)
1192 .and_then(|value| value.to_str().ok()),
1193 Some("\"0\""),
1194 "the real session response must still carry its CAS token for bind {bind_addr}"
1195 );
1196
1197 let exposed = response
1198 .headers()
1199 .get(header::ACCESS_CONTROL_EXPOSE_HEADERS)
1200 .and_then(|value| value.to_str().ok())
1201 .unwrap_or_default()
1202 .split(',')
1203 .map(str::trim)
1204 .filter(|value| !value.is_empty())
1205 .collect::<Vec<_>>();
1206 assert!(
1207 exposed
1208 .iter()
1209 .any(|value| value.eq_ignore_ascii_case("etag")),
1210 "ETag must be browser-readable for bind {bind_addr}; exposed={exposed:?}"
1211 );
1212 assert_eq!(
1213 exposed.len(),
1214 1,
1215 "do not broadly expose unrelated response headers for bind {bind_addr}"
1216 );
1217
1218 let preflight = test::call_service(
1219 &app,
1220 test::TestRequest::default()
1221 .method(Method::OPTIONS)
1222 .uri(&format!("/api/v1/sessions/{session_id}"))
1223 .insert_header((header::ORIGIN, origin))
1224 .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "PATCH"))
1225 .insert_header((
1226 header::ACCESS_CONTROL_REQUEST_HEADERS,
1227 "content-type, if-match",
1228 ))
1229 .to_request(),
1230 )
1231 .await;
1232 assert_eq!(
1233 preflight.status(),
1234 StatusCode::OK,
1235 "existing PATCH preflight behavior must remain intact for bind {bind_addr}"
1236 );
1237 let allowed_headers = preflight
1238 .headers()
1239 .get(header::ACCESS_CONTROL_ALLOW_HEADERS)
1240 .and_then(|value| value.to_str().ok())
1241 .unwrap_or_default();
1242 assert!(
1243 allowed_headers
1244 .split(',')
1245 .any(|value| value.trim().eq_ignore_ascii_case("if-match")),
1246 "If-Match must remain allowed for bind {bind_addr}; allowed={allowed_headers}"
1247 );
1248 }
1249
1250 state.shutdown().await;
1251 }
1252
1253 #[actix_web::test]
1265 async fn governor_inside_cors_makes_429_cors_readable_and_exempts_preflight() {
1266 use actix_web::http::StatusCode;
1267 use actix_web::{test, web, App, HttpResponse};
1268 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1269
1270 let conf = rate_limiter_config(1, 1, ClientIpKeyExtractor::peer_ip());
1272 let app = test::init_service(
1273 wrap_governor_and_cors(
1274 App::new(),
1275 &conf,
1276 true,
1277 "0.0.0.0",
1278 9562,
1279 )
1280 .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
1281 )
1282 .await;
1283
1284 let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 9999);
1285 let (get_status, get_has_acao, preflight_status) =
1286 probe_cors_and_preflight!(app, ip, "http://localhost:5173", 2);
1287
1288 assert_eq!(
1289 get_status,
1290 StatusCode::TOO_MANY_REQUESTS,
1291 "the 2nd GET past the burst must be throttled (#13 guarantee intact)"
1292 );
1293 assert!(
1294 get_has_acao,
1295 "a 429 must carry Access-Control-Allow-Origin so a browser sees a readable 429, \
1296 not an opaque network error (#169 part 2)"
1297 );
1298 assert_ne!(
1299 preflight_status,
1300 StatusCode::TOO_MANY_REQUESTS,
1301 "a CORS preflight must NOT be throttled — it never reaches Governor (#169 part 2)"
1302 );
1303 }
1304
1305 #[actix_web::test]
1314 async fn governor_outside_cors_regression_drops_cors_and_throttles_preflight() {
1315 use crate::rate_limit::RateLimit;
1316 use actix_web::http::StatusCode;
1317 use actix_web::{test, web, App, HttpResponse};
1318 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1319
1320 let conf = rate_limiter_config(1, 1, ClientIpKeyExtractor::peer_ip());
1321 let app = test::init_service(
1322 App::new()
1323 .wrap(build_cors("0.0.0.0", 9562)) .wrap(RateLimit::new(&conf)) .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
1326 )
1327 .await;
1328
1329 let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 8)), 9999);
1330 let (get_status, get_has_acao, preflight_status) =
1331 probe_cors_and_preflight!(app, ip, "http://localhost:5173", 2);
1332
1333 assert_eq!(
1334 get_status,
1335 StatusCode::TOO_MANY_REQUESTS,
1336 "still a 429 in the wrong order..."
1337 );
1338 assert!(
1339 !get_has_acao,
1340 "...but WITHOUT CORS headers — the browser-opaque failure #169 part 2 fixes"
1341 );
1342 assert_eq!(
1343 preflight_status,
1344 StatusCode::TOO_MANY_REQUESTS,
1345 "and the preflight IS throttled in the wrong order (counted against the bucket)"
1346 );
1347 }
1348
1349 #[test]
1352 fn require_limiter_rejects_nonloopback_without_limiter() {
1353 for b in ["0.0.0.0", "192.168.1.10", "::"] {
1355 assert!(
1356 require_limiter_for_nonloopback(b, false).is_err(),
1357 "{b} without a limiter must be rejected (#169 part 3)"
1358 );
1359 }
1360 }
1361
1362 #[test]
1363 fn require_limiter_allows_loopback_and_limited_binds() {
1364 for b in ["127.0.0.1", "localhost", "::1"] {
1367 assert!(
1368 require_limiter_for_nonloopback(b, false).is_ok(),
1369 "{b} loopback must stay allowed without a limiter (desktop behavior)"
1370 );
1371 }
1372 for b in ["0.0.0.0", "192.168.1.10"] {
1374 assert!(
1375 require_limiter_for_nonloopback(b, true).is_ok(),
1376 "{b} with a limiter applied must be allowed"
1377 );
1378 }
1379 }
1380
1381 #[test]
1382 fn default_csp_keeps_scripts_strict_but_allows_inline_styles() {
1383 assert!(DEFAULT_CSP.contains("script-src 'self'"));
1384 assert!(DEFAULT_CSP.contains("style-src 'self' 'unsafe-inline'"));
1385 assert!(!DEFAULT_CSP.contains("unsafe-eval"));
1386 }
1387
1388 #[test]
1389 fn connect_src_append_normalizes_explicit_origins() {
1390 let sources = parse_csp_connect_src_append(
1391 "https://bodhi.bigduu.com:9562, http://bodhi.bigduu.com:9562/",
1392 );
1393 assert_eq!(
1394 sources,
1395 vec![
1396 "https://bodhi.bigduu.com:9562".to_string(),
1397 "http://bodhi.bigduu.com:9562".to_string(),
1398 ]
1399 );
1400 }
1401
1402 #[test]
1403 fn append_connect_src_sources_extends_default_csp() {
1404 let csp = append_connect_src_sources(
1405 DEFAULT_CSP,
1406 &[
1407 "https://bodhi.bigduu.com:9562".to_string(),
1408 "http://bodhi.bigduu.com:9562".to_string(),
1409 ],
1410 );
1411
1412 assert!(csp.contains("connect-src 'self' ws: wss:"));
1413 assert!(csp.contains("https://bodhi.bigduu.com:9562"));
1414 assert!(csp.contains("http://bodhi.bigduu.com:9562"));
1415 }
1416
1417 #[test]
1418 fn invalid_override_falls_back_to_default() {
1419 let v = resolve_csp_header_value(Some("default-src 'self'\nscript-src 'self'"));
1421 let rendered = v.to_str().expect("header should be valid utf-8");
1422 assert!(rendered.contains("connect-src 'self' ws: wss:"));
1423 assert!(rendered.contains("http://127.0.0.1:*"));
1424 assert!(rendered.contains("http://localhost:*"));
1425 assert!(rendered.contains("http://bodhi.bigduu.com:9562"));
1426 assert!(rendered.contains("https://bodhi.bigduu.com:9562"));
1427 assert!(rendered.contains("style-src 'self' 'unsafe-inline'"));
1428 }
1429
1430 #[test]
1431 fn cors_allowlist_parses_hosts_and_origins() {
1432 let allow = parse_cors_allowlist(
1433 "https://app.example.com/, app.example2.com, *.example.net , http://localhost:5173",
1434 );
1435 assert!(allow.exact_origins.contains("https://app.example.com"));
1436 assert!(allow.exact_origins.contains("http://localhost:5173"));
1437 assert!(allow
1438 .hosts
1439 .contains(&HostPattern::Exact("app.example2.com".to_string())));
1440 assert!(allow
1441 .hosts
1442 .contains(&HostPattern::Suffix(".example.net".to_string())));
1443 }
1444
1445 #[test]
1446 fn cors_allowlist_matches_exact_and_wildcard_hosts() {
1447 let mut allow = CorsAllowlist::default();
1448 allow
1449 .exact_origins
1450 .insert("https://app.example.com".to_string());
1451 allow
1452 .hosts
1453 .push(HostPattern::Exact("app2.example.com".to_string()));
1454 allow
1455 .hosts
1456 .push(HostPattern::Suffix(".example.net".to_string()));
1457
1458 assert!(is_allowed_by_allowlist("https://app.example.com", &allow));
1459 assert!(is_allowed_by_allowlist(
1460 "https://app.example.com:443",
1461 &allow
1462 ));
1463 assert!(is_allowed_by_allowlist(
1464 "http://app2.example.com:5173",
1465 &allow
1466 ));
1467 assert!(is_allowed_by_allowlist("https://x.example.net", &allow));
1468 assert!(!is_allowed_by_allowlist("https://example.net", &allow));
1469 assert!(!is_allowed_by_allowlist("https://evil.com", &allow));
1470 }
1471
1472 #[test]
1473 fn local_dev_origin_allows_mac_local_and_bodhi_domain() {
1474 assert!(is_local_dev_origin("http://mac.local:1420"));
1475 assert!(is_local_dev_origin("https://mac.local:1420"));
1476 assert!(is_local_dev_origin("http://bodhi.bigduu.com:9562"));
1477 assert!(is_local_dev_origin("https://bodhi.bigduu.com:9562"));
1478 assert!(!is_local_dev_origin("http://evil.com:1420"));
1479 }
1480}