1use actix_cors::Cors;
23use actix_governor::governor::middleware::NoOpMiddleware;
24use actix_governor::{
25 GovernorConfig, GovernorConfigBuilder, KeyExtractor, SimpleKeyExtractionError,
26};
27use actix_web::body::MessageBody;
28use actix_web::dev::{ServiceRequest, ServiceResponse};
29use actix_web::http::header;
30use actix_web::middleware::{DefaultHeaders, Next};
31use std::collections::HashSet;
32use std::net::IpAddr;
33use tracing::info;
34use tracing::warn;
35
36const DEFAULT_RATE_LIMIT_PER_SECOND: u64 = 10;
39const DEFAULT_RATE_LIMIT_BURST: u32 = 20;
41
42#[derive(Clone, Debug)]
53pub struct ClientIpKeyExtractor {
54 trust_xff: bool,
55 trusted_hops: usize,
59}
60
61impl ClientIpKeyExtractor {
62 #[cfg(test)]
64 fn peer_ip() -> Self {
65 Self {
66 trust_xff: false,
67 trusted_hops: 1,
68 }
69 }
70
71 fn client_ip_from_xff(&self, req: &ServiceRequest) -> Option<IpAddr> {
72 let hops = self.trusted_hops.max(1);
73 let entries: Vec<&str> = req
79 .headers()
80 .get_all("x-forwarded-for")
81 .filter_map(|v| v.to_str().ok())
82 .flat_map(|line| line.split(','))
83 .map(|s| s.trim())
84 .filter(|s| !s.is_empty())
85 .collect();
86 if entries.len() < hops {
89 return None;
90 }
91 parse_forwarded_ip(entries[entries.len() - hops])
92 }
93}
94
95impl KeyExtractor for ClientIpKeyExtractor {
96 type Key = IpAddr;
97 type KeyExtractionError = SimpleKeyExtractionError<&'static str>;
98
99 fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError> {
100 if self.trust_xff {
101 if let Some(client) = self.client_ip_from_xff(req) {
102 return Ok(mask_ipv6_prefix(client));
103 }
104 }
106 let ip = req.peer_addr().map(|socket| socket.ip()).ok_or_else(|| {
107 SimpleKeyExtractionError::new("Could not extract peer IP address from request")
108 })?;
109 Ok(mask_ipv6_prefix(ip))
110 }
111}
112
113fn mask_ipv6_prefix(ip: IpAddr) -> IpAddr {
116 match ip {
117 IpAddr::V6(v6) => {
118 let mut octets = v6.octets();
119 octets[7..16].fill(0);
120 IpAddr::V6(octets.into())
121 }
122 v4 => v4,
123 }
124}
125
126fn parse_forwarded_ip(s: &str) -> Option<IpAddr> {
129 let s = s.trim();
130 if let Ok(ip) = s.parse::<IpAddr>() {
131 return Some(ip);
132 }
133 if let Ok(sa) = s.parse::<std::net::SocketAddr>() {
134 return Some(sa.ip());
135 }
136 let unbracketed = s.strip_prefix('[').and_then(|x| x.strip_suffix(']'))?;
138 unbracketed.parse::<IpAddr>().ok()
139}
140
141fn rate_limiter_config(
142 per_second: u64,
143 burst: u32,
144 key_extractor: ClientIpKeyExtractor,
145) -> GovernorConfig<ClientIpKeyExtractor, NoOpMiddleware> {
146 let ms_per_request = (1000 / per_second.max(1)).max(1);
150 GovernorConfigBuilder::default()
151 .milliseconds_per_request(ms_per_request)
152 .burst_size(burst.max(1))
153 .key_extractor(key_extractor)
154 .finish()
155 .expect("rate limiter config is valid (non-zero period and burst)")
156}
157
158pub fn build_rate_limiter() -> GovernorConfig<ClientIpKeyExtractor, NoOpMiddleware> {
171 let per_second = std::env::var("BAMBOO_RATE_LIMIT_PER_SECOND")
172 .ok()
173 .and_then(|v| v.trim().parse::<u64>().ok())
174 .unwrap_or(DEFAULT_RATE_LIMIT_PER_SECOND);
175 let burst = std::env::var("BAMBOO_RATE_LIMIT_BURST")
176 .ok()
177 .and_then(|v| v.trim().parse::<u32>().ok())
178 .unwrap_or(DEFAULT_RATE_LIMIT_BURST);
179
180 let trust_xff = std::env::var("BAMBOO_RATE_LIMIT_TRUST_XFF")
181 .ok()
182 .map(|v| {
183 let t = v.trim();
184 t == "1" || t.eq_ignore_ascii_case("true")
185 })
186 .unwrap_or(false);
187 let trusted_hops = std::env::var("BAMBOO_RATE_LIMIT_TRUSTED_HOPS")
188 .ok()
189 .and_then(|v| v.trim().parse::<usize>().ok())
190 .filter(|n| *n >= 1)
191 .unwrap_or(1);
192
193 if trust_xff {
194 warn!(
195 "Rate limiter is trusting X-Forwarded-For (trusted_hops={trusted_hops}). \
196 Only enable this when the server is reachable exclusively through a trusted \
197 reverse proxy — otherwise clients can spoof their rate-limit key."
198 );
199 }
200
201 rate_limiter_config(
202 per_second,
203 burst,
204 ClientIpKeyExtractor {
205 trust_xff,
206 trusted_hops,
207 },
208 )
209}
210
211pub fn is_loopback_bind(bind: &str) -> bool {
218 matches!(bind, "127.0.0.1" | "localhost" | "::1")
219}
220
221pub fn require_limiter_for_nonloopback(bind: &str, limiter_applied: bool) -> Result<(), String> {
236 if !limiter_applied && !is_loopback_bind(bind) {
237 return Err(format!(
238 "refusing to serve on non-loopback bind '{bind}' without a rate limiter: it would \
239 run unthrottled and re-open the per-IP DoS surface closed by #13. Use a \
240 limiter-applying serve path (e.g. start_with_bind_and_static / run_with_bind) or \
241 bind to loopback (127.0.0.1 / localhost / ::1)."
242 ));
243 }
244 Ok(())
245}
246
247const DEFAULT_CSP: &str = concat!(
252 "default-src 'self'; ",
253 "base-uri 'self'; ",
254 "object-src 'none'; ",
255 "frame-ancestors 'none'; ",
256 "script-src 'self'; ",
257 "style-src 'self' 'unsafe-inline'; ",
258 "img-src 'self' data: https:; ",
259 "font-src 'self' data:; ",
260 "connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* http://bodhi.bigduu.com:9562 https://bodhi.bigduu.com:9562; ",
261 "form-action 'self';"
262);
263
264fn normalize_csp_source_token(token: &str) -> Option<String> {
265 let trimmed = token.trim();
266 if trimmed.is_empty() {
267 return None;
268 }
269
270 if trimmed.starts_with("'") {
271 return Some(trimmed.to_string());
272 }
273
274 normalize_origin(trimmed).or_else(|| Some(trimmed.to_string()))
275}
276
277fn parse_csp_connect_src_append(raw: &str) -> Vec<String> {
278 raw.split(|c: char| c == ',' || c.is_ascii_whitespace())
279 .filter_map(normalize_csp_source_token)
280 .collect()
281}
282
283fn append_connect_src_sources(base_csp: &str, extra_sources: &[String]) -> String {
284 if extra_sources.is_empty() {
285 return base_csp.to_string();
286 }
287
288 let connect_src_marker = "connect-src ";
289 if let Some(start) = base_csp.find(connect_src_marker) {
290 let value_start = start + connect_src_marker.len();
291 if let Some(relative_end) = base_csp[value_start..].find(';') {
292 let value_end = value_start + relative_end;
293 let existing_value = base_csp[value_start..value_end].trim();
294 let mut merged = if existing_value.is_empty() {
295 String::new()
296 } else {
297 existing_value.to_string()
298 };
299
300 for source in extra_sources {
301 if merged.split_whitespace().any(|token| token == source) {
302 continue;
303 }
304 if !merged.is_empty() {
305 merged.push(' ');
306 }
307 merged.push_str(source);
308 }
309
310 let mut result = String::with_capacity(base_csp.len() + merged.len() + 1);
311 result.push_str(&base_csp[..value_start]);
312 result.push_str(&merged);
313 result.push_str(&base_csp[value_end..]);
314 return result;
315 }
316 }
317
318 base_csp.to_string()
319}
320
321fn resolve_default_csp() -> String {
322 const ENV_KEY: &str = "BAMBOO_CSP_CONNECT_SRC";
323
324 let extra_sources = match std::env::var(ENV_KEY) {
325 Ok(raw) => parse_csp_connect_src_append(&raw),
326 Err(_) => Vec::new(),
327 };
328
329 if !extra_sources.is_empty() {
330 info!(
331 "Extending CSP connect-src via {} with {} source(s)",
332 ENV_KEY,
333 extra_sources.len()
334 );
335 }
336
337 append_connect_src_sources(DEFAULT_CSP, &extra_sources)
338}
339
340fn resolve_csp_header_value(override_value: Option<&str>) -> header::HeaderValue {
341 let default_csp = resolve_default_csp();
342 let csp = override_value.unwrap_or(default_csp.as_str());
343 match header::HeaderValue::from_str(csp) {
344 Ok(v) => v,
345 Err(e) => {
346 warn!(
348 "Invalid BAMBOO_CSP value ({}); falling back to DEFAULT_CSP",
349 e
350 );
351 header::HeaderValue::from_str(default_csp.as_str())
352 .unwrap_or_else(|_| header::HeaderValue::from_static(DEFAULT_CSP))
353 }
354 }
355}
356
357#[derive(Debug, Clone, Default)]
364struct CorsAllowlist {
365 exact_origins: HashSet<String>,
366 hosts: Vec<HostPattern>,
367}
368
369#[derive(Debug, Clone, PartialEq, Eq)]
370enum HostPattern {
371 Exact(String),
372 Suffix(String), }
374
375fn normalize_origin(origin: &str) -> Option<String> {
376 let url = url::Url::parse(origin).ok()?;
377
378 let scheme = url.scheme().to_ascii_lowercase();
379 let host = url.host()?;
380 let host_str = match host {
381 url::Host::Domain(d) => d.to_ascii_lowercase(),
382 url::Host::Ipv4(v4) => v4.to_string(),
383 url::Host::Ipv6(v6) => format!("[{v6}]"),
384 };
385
386 let port = url.port();
387 let default_port = match scheme.as_str() {
388 "http" => Some(80),
389 "https" => Some(443),
390 _ => None,
391 };
392 let port = match (port, default_port) {
393 (Some(p), Some(d)) if p == d => None,
394 (p, _) => p,
395 };
396
397 Some(match port {
398 Some(p) => format!("{scheme}://{host_str}:{p}"),
399 None => format!("{scheme}://{host_str}"),
400 })
401}
402
403fn parse_cors_allowlist(raw: &str) -> CorsAllowlist {
404 let mut allow = CorsAllowlist::default();
405
406 for item in raw.split(',') {
407 let token = item.trim();
408 if token.is_empty() {
409 continue;
410 }
411
412 if token.contains("://") {
413 match normalize_origin(token) {
416 Some(origin) => {
417 allow.exact_origins.insert(origin);
418 }
419 None => {
420 warn!(
421 "Invalid CORS origin entry '{}'; expected an origin like https://app.example.com",
422 token
423 );
424 }
425 }
426 continue;
427 }
428
429 let host = token.to_ascii_lowercase();
431 if let Some(rest) = host.strip_prefix("*.") {
432 if !rest.is_empty() {
434 allow.hosts.push(HostPattern::Suffix(format!(".{rest}")));
435 }
436 } else {
437 allow.hosts.push(HostPattern::Exact(host));
438 }
439 }
440
441 allow
442}
443
444fn parse_cors_allowlist_env() -> CorsAllowlist {
445 const ENV_KEY: &str = "BAMBOO_CORS_ALLOW_ORIGINS";
449
450 let raw = match std::env::var(ENV_KEY) {
451 Ok(v) => v,
452 Err(_) => return CorsAllowlist::default(),
453 };
454
455 let allow = parse_cors_allowlist(&raw);
456
457 if !allow.exact_origins.is_empty() || !allow.hosts.is_empty() {
458 info!(
459 "CORS allowlist enabled via BAMBOO_CORS_ALLOW_ORIGINS ({} exact origin(s), {} host pattern(s))",
460 allow.exact_origins.len(),
461 allow.hosts.len()
462 );
463 }
464
465 allow
466}
467
468fn is_allowed_by_allowlist(origin: &str, allow: &CorsAllowlist) -> bool {
469 if let Some(normalized) = normalize_origin(origin) {
470 if allow.exact_origins.contains(&normalized) {
471 return true;
472 }
473 }
474
475 if allow.exact_origins.contains(origin) {
477 return true;
478 }
479
480 let url = match url::Url::parse(origin) {
485 Ok(u) => u,
486 Err(_) => return false,
487 };
488
489 let host = match url.host_str() {
490 Some(h) => h.to_ascii_lowercase(),
491 None => return false,
492 };
493
494 for pat in &allow.hosts {
495 match pat {
496 HostPattern::Exact(h) => {
497 if &host == h {
498 return true;
499 }
500 }
501 HostPattern::Suffix(suffix) => {
502 if host.ends_with(suffix) {
503 return true;
506 }
507 }
508 }
509 }
510
511 false
512}
513
514fn is_local_dev_origin(o: &str) -> bool {
515 o.starts_with("http://localhost:")
516 || o.starts_with("http://127.0.0.1:")
517 || o.starts_with("https://localhost:")
518 || o.starts_with("https://127.0.0.1:")
519 || o.starts_with("http://mac.local:")
520 || o.starts_with("https://mac.local:")
521 || o.starts_with("http://bodhi.bigduu.com:")
522 || o.starts_with("https://bodhi.bigduu.com:")
523 || o.starts_with("http://[::1]:")
524 || o.starts_with("https://[::1]:")
525}
526
527pub fn build_security_headers() -> DefaultHeaders {
546 let csp_override = std::env::var("BAMBOO_CSP").ok();
547 let csp_value = resolve_csp_header_value(csp_override.as_deref());
548
549 DefaultHeaders::new()
550 .add(("X-Frame-Options", "DENY"))
551 .add(("X-Content-Type-Options", "nosniff"))
552 .add(("X-XSS-Protection", "1; mode=block"))
553 .add(("Referrer-Policy", "strict-origin-when-cross-origin"))
554 .add((header::CONTENT_SECURITY_POLICY, csp_value))
556}
557
558pub async fn add_asset_cache_headers<B: MessageBody + 'static>(
572 req: ServiceRequest,
573 next: Next<B>,
574) -> Result<ServiceResponse<B>, actix_web::Error> {
575 let is_asset = req.path().starts_with("/assets/");
576 let mut res = next.call(req).await?;
577 if is_asset {
578 res.headers_mut().insert(
579 header::CACHE_CONTROL,
580 header::HeaderValue::from_static("public, max-age=31536000, immutable"),
581 );
582 }
583 Ok(res)
584}
585
586pub fn build_cors(bind_addr: &str, port: u16) -> Cors {
625 let allowlist = parse_cors_allowlist_env();
626
627 let cors = if bind_addr == "127.0.0.1" || bind_addr == "localhost" || bind_addr == "::1" {
628 info!("CORS configured for development mode: allowing local/Tauri origins (+ optional allowlist)");
632 Cors::default()
633 .allowed_origin_fn(move |origin, _req_head| {
634 let o = match origin.to_str() {
635 Ok(v) => v,
636 Err(_) => return false,
637 };
638
639 if is_allowed_by_allowlist(o, &allowlist) {
640 return true;
641 }
642
643 if is_local_dev_origin(o) {
644 return true;
645 }
646
647 o == "tauri://localhost"
648 || o == "https://tauri.localhost"
649 || o == "http://tauri.localhost"
650 })
651 .allow_any_method()
652 .allow_any_header()
653 .supports_credentials()
654 .max_age(3600)
655 } else if bind_addr == "0.0.0.0" {
656 info!("CORS configured for 0.0.0.0 bind: allowing localhost/loopback origins (+ optional allowlist)");
666 Cors::default()
667 .allowed_origin_fn(move |origin, _req_head| {
668 let o = match origin.to_str() {
669 Ok(v) => v,
670 Err(_) => return false,
671 };
672
673 if is_allowed_by_allowlist(o, &allowlist) {
675 return true;
676 }
677
678 if is_local_dev_origin(o) {
680 return true;
681 }
682
683 if o == "tauri://localhost"
685 || o == "https://tauri.localhost"
686 || o == "http://tauri.localhost"
687 {
688 return true;
689 }
690
691 if o == format!("http://localhost:{port}")
693 || o == format!("http://127.0.0.1:{port}")
694 {
695 return true;
696 }
697
698 false
699 })
700 .allow_any_method()
703 .allow_any_header()
706 .supports_credentials()
707 .max_age(3600)
708 } else {
709 info!(
711 "CORS configured for custom bind address: {} (+ optional allowlist)",
712 bind_addr
713 );
714 let bind_host = bind_addr.to_ascii_lowercase();
715 let allowlist = allowlist.clone();
716 Cors::default()
717 .allowed_origin_fn(move |origin, _req_head| {
718 let o = match origin.to_str() {
719 Ok(v) => v,
720 Err(_) => return false,
721 };
722
723 if is_allowed_by_allowlist(o, &allowlist) {
724 return true;
725 }
726
727 let url = match url::Url::parse(o) {
730 Ok(u) => u,
731 Err(_) => return false,
732 };
733 let Some(host) = url.host_str() else {
734 return false;
735 };
736 host.eq_ignore_ascii_case(&bind_host)
737 })
738 .allow_any_method()
739 .allow_any_header()
740 .supports_credentials()
741 .max_age(3600)
742 };
743
744 cors
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 #[test]
752 fn rate_limiter_config_clamps_degenerate_values() {
753 let _ = rate_limiter_config(0, 0, ClientIpKeyExtractor::peer_ip());
756 let _ = rate_limiter_config(1000, 1, ClientIpKeyExtractor::peer_ip());
757 }
758
759 #[test]
760 fn loopback_binds_skip_rate_limiter() {
761 for b in ["127.0.0.1", "localhost", "::1"] {
764 assert!(
765 is_loopback_bind(b),
766 "{b} should be loopback (limiter skipped)"
767 );
768 }
769 for b in ["0.0.0.0", "192.168.1.10", "::"] {
770 assert!(!is_loopback_bind(b), "{b} should be throttled");
771 }
772 }
773
774 #[actix_web::test]
775 async fn asset_cache_headers_only_tag_hashed_assets() {
776 use actix_web::http::header::CACHE_CONTROL;
777 use actix_web::{test, web, App, HttpResponse};
778
779 let app = test::init_service(
780 App::new()
781 .wrap(actix_web::middleware::from_fn(add_asset_cache_headers))
782 .route(
783 "/assets/main-abc123.css",
784 web::get().to(|| async { HttpResponse::Ok().finish() }),
785 )
786 .route(
787 "/index.html",
788 web::get().to(|| async { HttpResponse::Ok().finish() }),
789 ),
790 )
791 .await;
792
793 let req = test::TestRequest::get()
795 .uri("/assets/main-abc123.css")
796 .to_request();
797 let res = test::call_service(&app, req).await;
798 assert_eq!(
799 res.headers()
800 .get(CACHE_CONTROL)
801 .and_then(|v| v.to_str().ok()),
802 Some("public, max-age=31536000, immutable"),
803 );
804
805 let req = test::TestRequest::get().uri("/index.html").to_request();
808 let res = test::call_service(&app, req).await;
809 assert!(
810 res.headers().get(CACHE_CONTROL).is_none(),
811 "non-asset routes must not be long-cached"
812 );
813 }
814
815 #[actix_web::test]
816 async fn rate_limiter_throttles_with_429_after_burst() {
817 use actix_governor::Governor;
818 use actix_web::http::StatusCode;
819 use actix_web::{test, web, App, HttpResponse};
820 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
821
822 let conf = rate_limiter_config(1, 2, ClientIpKeyExtractor::peer_ip());
824 let app = test::init_service(
825 App::new()
826 .wrap(Governor::new(&conf))
827 .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
828 )
829 .await;
830
831 let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 9999);
832 let (mut saw_ok, mut saw_429) = (false, false);
833 for _ in 0..6 {
834 let req = test::TestRequest::get().uri("/").peer_addr(ip).to_request();
835 match test::call_service(&app, req).await.status() {
836 StatusCode::OK => saw_ok = true,
837 StatusCode::TOO_MANY_REQUESTS => saw_429 = true,
838 other => panic!("unexpected status {other}"),
839 }
840 }
841 assert!(saw_ok, "requests within the burst must pass");
842 assert!(saw_429, "requests beyond the burst must be 429'd (#13)");
843
844 let other_ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 8888);
848 let req = test::TestRequest::get()
849 .uri("/")
850 .peer_addr(other_ip)
851 .to_request();
852 assert_eq!(
853 test::call_service(&app, req).await.status(),
854 StatusCode::OK,
855 "a different IP gets its own fresh bucket (per-IP, not global)"
856 );
857 }
858
859 #[actix_web::test]
860 async fn key_extractor_default_ignores_xff_and_uses_peer_ip() {
861 use actix_web::test;
862 use std::net::{Ipv4Addr, SocketAddr};
863
864 let ke = ClientIpKeyExtractor::peer_ip();
867 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 5000);
868 let req = test::TestRequest::get()
869 .peer_addr(peer)
870 .insert_header(("x-forwarded-for", "1.2.3.4"))
871 .to_srv_request();
872 assert_eq!(
873 ke.extract(&req).unwrap(),
874 IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))
875 );
876 }
877
878 #[actix_web::test]
879 async fn key_extractor_xff_uses_rightmost_at_one_hop_not_client_prefix() {
880 use actix_web::test;
881 use std::net::{Ipv4Addr, SocketAddr};
882
883 let ke = ClientIpKeyExtractor {
886 trust_xff: true,
887 trusted_hops: 1,
888 };
889 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000); let req = test::TestRequest::get()
891 .peer_addr(peer)
892 .insert_header(("x-forwarded-for", "1.1.1.1, 2.2.2.2"))
893 .to_srv_request();
894 assert_eq!(
895 ke.extract(&req).unwrap(),
896 IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
897 );
898 }
899
900 #[actix_web::test]
901 async fn key_extractor_xff_two_hops_takes_second_from_right() {
902 use actix_web::test;
903 use std::net::{Ipv4Addr, SocketAddr};
904
905 let ke = ClientIpKeyExtractor {
906 trust_xff: true,
907 trusted_hops: 2,
908 };
909 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
910 let req = test::TestRequest::get()
911 .peer_addr(peer)
912 .insert_header(("x-forwarded-for", "1.1.1.1, 2.2.2.2, 3.3.3.3"))
913 .to_srv_request();
914 assert_eq!(
915 ke.extract(&req).unwrap(),
916 IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
917 );
918 }
919
920 #[actix_web::test]
921 async fn key_extractor_xff_fails_closed_to_peer_when_header_too_short_or_absent() {
922 use actix_web::test;
923 use std::net::{Ipv4Addr, SocketAddr};
924
925 let ke = ClientIpKeyExtractor {
926 trust_xff: true,
927 trusted_hops: 2,
928 };
929 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
930
931 let short = test::TestRequest::get()
933 .peer_addr(peer)
934 .insert_header(("x-forwarded-for", "9.9.9.9"))
935 .to_srv_request();
936 assert_eq!(
937 ke.extract(&short).unwrap(),
938 IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
939 );
940
941 let none = test::TestRequest::get().peer_addr(peer).to_srv_request();
943 assert_eq!(
944 ke.extract(&none).unwrap(),
945 IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
946 );
947 }
948
949 #[actix_web::test]
950 async fn key_extractor_xff_flattens_multiple_header_lines_in_order() {
951 use actix_web::test;
952 use std::net::{Ipv4Addr, SocketAddr};
953
954 let ke = ClientIpKeyExtractor {
959 trust_xff: true,
960 trusted_hops: 1,
961 };
962 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
963 let req = test::TestRequest::get()
964 .peer_addr(peer)
965 .append_header(("x-forwarded-for", "1.1.1.1"))
966 .append_header(("x-forwarded-for", "2.2.2.2"))
967 .to_srv_request();
968 assert_eq!(
969 ke.extract(&req).unwrap(),
970 IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
971 );
972 }
973
974 #[test]
975 fn parse_forwarded_ip_handles_bare_port_and_bracketed_forms() {
976 use std::net::{Ipv4Addr, Ipv6Addr};
977
978 assert_eq!(
979 parse_forwarded_ip("1.2.3.4"),
980 Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
981 );
982 assert_eq!(
983 parse_forwarded_ip("1.2.3.4:5678"),
984 Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
985 );
986 assert_eq!(
987 parse_forwarded_ip("[::1]:9000"),
988 Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
989 );
990 assert_eq!(
991 parse_forwarded_ip("[::1]"),
992 Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
993 );
994 assert_eq!(parse_forwarded_ip("not-an-ip"), None);
995 }
996
997 #[test]
998 fn mask_ipv6_prefix_zeroes_lower_bytes_and_leaves_ipv4() {
999 use std::net::{Ipv4Addr, Ipv6Addr};
1000
1001 let v4 = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
1002 assert_eq!(mask_ipv6_prefix(v4), v4);
1003
1004 let v6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6));
1005 assert_eq!(
1007 mask_ipv6_prefix(v6),
1008 IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0))
1009 );
1010 }
1011
1012 macro_rules! probe_cors_and_preflight {
1020 ($app:expr, $ip:expr, $origin:expr, $gets:expr) => {{
1021 use actix_web::http::header;
1022 use actix_web::test;
1023
1024 let mut status = actix_web::http::StatusCode::OK;
1025 let mut has_acao = false;
1026 for _ in 0..$gets {
1027 let res = test::call_service(
1028 &$app,
1029 test::TestRequest::get()
1030 .uri("/")
1031 .peer_addr($ip)
1032 .insert_header((header::ORIGIN, $origin))
1033 .to_request(),
1034 )
1035 .await;
1036 status = res.status();
1037 has_acao = res
1038 .headers()
1039 .contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN);
1040 }
1041
1042 let pre = test::call_service(
1043 &$app,
1044 test::TestRequest::default()
1045 .method(actix_web::http::Method::OPTIONS)
1046 .uri("/")
1047 .peer_addr($ip)
1048 .insert_header((header::ORIGIN, $origin))
1049 .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "GET"))
1050 .to_request(),
1051 )
1052 .await;
1053
1054 (status, has_acao, pre.status())
1055 }};
1056 }
1057
1058 #[actix_web::test]
1063 async fn governor_inside_cors_makes_429_cors_readable_and_exempts_preflight() {
1064 use actix_governor::Governor;
1065 use actix_web::http::StatusCode;
1066 use actix_web::{test, web, App, HttpResponse};
1067 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1068
1069 let conf = rate_limiter_config(1, 1, ClientIpKeyExtractor::peer_ip());
1071 let app = test::init_service(
1072 App::new()
1073 .wrap(Governor::new(&conf)) .wrap(build_cors("0.0.0.0", 9562)) .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
1076 )
1077 .await;
1078
1079 let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 9999);
1080 let (get_status, get_has_acao, preflight_status) =
1081 probe_cors_and_preflight!(app, ip, "http://localhost:5173", 2);
1082
1083 assert_eq!(
1084 get_status,
1085 StatusCode::TOO_MANY_REQUESTS,
1086 "the 2nd GET past the burst must be throttled (#13 guarantee intact)"
1087 );
1088 assert!(
1089 get_has_acao,
1090 "a 429 must carry Access-Control-Allow-Origin so a browser sees a readable 429, \
1091 not an opaque network error (#169 part 2)"
1092 );
1093 assert_ne!(
1094 preflight_status,
1095 StatusCode::TOO_MANY_REQUESTS,
1096 "a CORS preflight must NOT be throttled — it never reaches Governor (#169 part 2)"
1097 );
1098 }
1099
1100 #[actix_web::test]
1107 async fn governor_outside_cors_regression_drops_cors_and_throttles_preflight() {
1108 use actix_governor::Governor;
1109 use actix_web::http::StatusCode;
1110 use actix_web::{test, web, App, HttpResponse};
1111 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1112
1113 let conf = rate_limiter_config(1, 1, ClientIpKeyExtractor::peer_ip());
1114 let app = test::init_service(
1115 App::new()
1116 .wrap(build_cors("0.0.0.0", 9562)) .wrap(Governor::new(&conf)) .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
1119 )
1120 .await;
1121
1122 let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 8)), 9999);
1123 let (get_status, get_has_acao, preflight_status) =
1124 probe_cors_and_preflight!(app, ip, "http://localhost:5173", 2);
1125
1126 assert_eq!(
1127 get_status,
1128 StatusCode::TOO_MANY_REQUESTS,
1129 "still a 429 in the wrong order..."
1130 );
1131 assert!(
1132 !get_has_acao,
1133 "...but WITHOUT CORS headers — the browser-opaque failure #169 part 2 fixes"
1134 );
1135 assert_eq!(
1136 preflight_status,
1137 StatusCode::TOO_MANY_REQUESTS,
1138 "and the preflight IS throttled in the wrong order (counted against the bucket)"
1139 );
1140 }
1141
1142 #[test]
1145 fn require_limiter_rejects_nonloopback_without_limiter() {
1146 for b in ["0.0.0.0", "192.168.1.10", "::"] {
1148 assert!(
1149 require_limiter_for_nonloopback(b, false).is_err(),
1150 "{b} without a limiter must be rejected (#169 part 3)"
1151 );
1152 }
1153 }
1154
1155 #[test]
1156 fn require_limiter_allows_loopback_and_limited_binds() {
1157 for b in ["127.0.0.1", "localhost", "::1"] {
1160 assert!(
1161 require_limiter_for_nonloopback(b, false).is_ok(),
1162 "{b} loopback must stay allowed without a limiter (desktop behavior)"
1163 );
1164 }
1165 for b in ["0.0.0.0", "192.168.1.10"] {
1167 assert!(
1168 require_limiter_for_nonloopback(b, true).is_ok(),
1169 "{b} with a limiter applied must be allowed"
1170 );
1171 }
1172 }
1173
1174 #[test]
1175 fn default_csp_keeps_scripts_strict_but_allows_inline_styles() {
1176 assert!(DEFAULT_CSP.contains("script-src 'self'"));
1177 assert!(DEFAULT_CSP.contains("style-src 'self' 'unsafe-inline'"));
1178 assert!(!DEFAULT_CSP.contains("unsafe-eval"));
1179 }
1180
1181 #[test]
1182 fn connect_src_append_normalizes_explicit_origins() {
1183 let sources = parse_csp_connect_src_append(
1184 "https://bodhi.bigduu.com:9562, http://bodhi.bigduu.com:9562/",
1185 );
1186 assert_eq!(
1187 sources,
1188 vec![
1189 "https://bodhi.bigduu.com:9562".to_string(),
1190 "http://bodhi.bigduu.com:9562".to_string(),
1191 ]
1192 );
1193 }
1194
1195 #[test]
1196 fn append_connect_src_sources_extends_default_csp() {
1197 let csp = append_connect_src_sources(
1198 DEFAULT_CSP,
1199 &[
1200 "https://bodhi.bigduu.com:9562".to_string(),
1201 "http://bodhi.bigduu.com:9562".to_string(),
1202 ],
1203 );
1204
1205 assert!(csp.contains("connect-src 'self' ws: wss:"));
1206 assert!(csp.contains("https://bodhi.bigduu.com:9562"));
1207 assert!(csp.contains("http://bodhi.bigduu.com:9562"));
1208 }
1209
1210 #[test]
1211 fn invalid_override_falls_back_to_default() {
1212 let v = resolve_csp_header_value(Some("default-src 'self'\nscript-src 'self'"));
1214 let rendered = v.to_str().expect("header should be valid utf-8");
1215 assert!(rendered.contains("connect-src 'self' ws: wss:"));
1216 assert!(rendered.contains("http://127.0.0.1:*"));
1217 assert!(rendered.contains("http://localhost:*"));
1218 assert!(rendered.contains("http://bodhi.bigduu.com:9562"));
1219 assert!(rendered.contains("https://bodhi.bigduu.com:9562"));
1220 assert!(rendered.contains("style-src 'self' 'unsafe-inline'"));
1221 }
1222
1223 #[test]
1224 fn cors_allowlist_parses_hosts_and_origins() {
1225 let allow = parse_cors_allowlist(
1226 "https://app.example.com/, app.example2.com, *.example.net , http://localhost:5173",
1227 );
1228 assert!(allow.exact_origins.contains("https://app.example.com"));
1229 assert!(allow.exact_origins.contains("http://localhost:5173"));
1230 assert!(allow
1231 .hosts
1232 .contains(&HostPattern::Exact("app.example2.com".to_string())));
1233 assert!(allow
1234 .hosts
1235 .contains(&HostPattern::Suffix(".example.net".to_string())));
1236 }
1237
1238 #[test]
1239 fn cors_allowlist_matches_exact_and_wildcard_hosts() {
1240 let mut allow = CorsAllowlist::default();
1241 allow
1242 .exact_origins
1243 .insert("https://app.example.com".to_string());
1244 allow
1245 .hosts
1246 .push(HostPattern::Exact("app2.example.com".to_string()));
1247 allow
1248 .hosts
1249 .push(HostPattern::Suffix(".example.net".to_string()));
1250
1251 assert!(is_allowed_by_allowlist("https://app.example.com", &allow));
1252 assert!(is_allowed_by_allowlist(
1253 "https://app.example.com:443",
1254 &allow
1255 ));
1256 assert!(is_allowed_by_allowlist(
1257 "http://app2.example.com:5173",
1258 &allow
1259 ));
1260 assert!(is_allowed_by_allowlist("https://x.example.net", &allow));
1261 assert!(!is_allowed_by_allowlist("https://example.net", &allow));
1262 assert!(!is_allowed_by_allowlist("https://evil.com", &allow));
1263 }
1264
1265 #[test]
1266 fn local_dev_origin_allows_mac_local_and_bodhi_domain() {
1267 assert!(is_local_dev_origin("http://mac.local:1420"));
1268 assert!(is_local_dev_origin("https://mac.local:1420"));
1269 assert!(is_local_dev_origin("http://bodhi.bigduu.com:9562"));
1270 assert!(is_local_dev_origin("https://bodhi.bigduu.com:9562"));
1271 assert!(!is_local_dev_origin("http://evil.com:1420"));
1272 }
1273}