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
221const DEFAULT_CSP: &str = concat!(
226 "default-src 'self'; ",
227 "base-uri 'self'; ",
228 "object-src 'none'; ",
229 "frame-ancestors 'none'; ",
230 "script-src 'self'; ",
231 "style-src 'self' 'unsafe-inline'; ",
232 "img-src 'self' data: https:; ",
233 "font-src 'self' data:; ",
234 "connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* http://bodhi.bigduu.com:9562 https://bodhi.bigduu.com:9562; ",
235 "form-action 'self';"
236);
237
238fn normalize_csp_source_token(token: &str) -> Option<String> {
239 let trimmed = token.trim();
240 if trimmed.is_empty() {
241 return None;
242 }
243
244 if trimmed.starts_with("'") {
245 return Some(trimmed.to_string());
246 }
247
248 normalize_origin(trimmed).or_else(|| Some(trimmed.to_string()))
249}
250
251fn parse_csp_connect_src_append(raw: &str) -> Vec<String> {
252 raw.split(|c: char| c == ',' || c.is_ascii_whitespace())
253 .filter_map(normalize_csp_source_token)
254 .collect()
255}
256
257fn append_connect_src_sources(base_csp: &str, extra_sources: &[String]) -> String {
258 if extra_sources.is_empty() {
259 return base_csp.to_string();
260 }
261
262 let connect_src_marker = "connect-src ";
263 if let Some(start) = base_csp.find(connect_src_marker) {
264 let value_start = start + connect_src_marker.len();
265 if let Some(relative_end) = base_csp[value_start..].find(';') {
266 let value_end = value_start + relative_end;
267 let existing_value = base_csp[value_start..value_end].trim();
268 let mut merged = if existing_value.is_empty() {
269 String::new()
270 } else {
271 existing_value.to_string()
272 };
273
274 for source in extra_sources {
275 if merged.split_whitespace().any(|token| token == source) {
276 continue;
277 }
278 if !merged.is_empty() {
279 merged.push(' ');
280 }
281 merged.push_str(source);
282 }
283
284 let mut result = String::with_capacity(base_csp.len() + merged.len() + 1);
285 result.push_str(&base_csp[..value_start]);
286 result.push_str(&merged);
287 result.push_str(&base_csp[value_end..]);
288 return result;
289 }
290 }
291
292 base_csp.to_string()
293}
294
295fn resolve_default_csp() -> String {
296 const ENV_KEY: &str = "BAMBOO_CSP_CONNECT_SRC";
297
298 let extra_sources = match std::env::var(ENV_KEY) {
299 Ok(raw) => parse_csp_connect_src_append(&raw),
300 Err(_) => Vec::new(),
301 };
302
303 if !extra_sources.is_empty() {
304 info!(
305 "Extending CSP connect-src via {} with {} source(s)",
306 ENV_KEY,
307 extra_sources.len()
308 );
309 }
310
311 append_connect_src_sources(DEFAULT_CSP, &extra_sources)
312}
313
314fn resolve_csp_header_value(override_value: Option<&str>) -> header::HeaderValue {
315 let default_csp = resolve_default_csp();
316 let csp = override_value.unwrap_or(default_csp.as_str());
317 match header::HeaderValue::from_str(csp) {
318 Ok(v) => v,
319 Err(e) => {
320 warn!(
322 "Invalid BAMBOO_CSP value ({}); falling back to DEFAULT_CSP",
323 e
324 );
325 header::HeaderValue::from_str(default_csp.as_str())
326 .unwrap_or_else(|_| header::HeaderValue::from_static(DEFAULT_CSP))
327 }
328 }
329}
330
331#[derive(Debug, Clone, Default)]
338struct CorsAllowlist {
339 exact_origins: HashSet<String>,
340 hosts: Vec<HostPattern>,
341}
342
343#[derive(Debug, Clone, PartialEq, Eq)]
344enum HostPattern {
345 Exact(String),
346 Suffix(String), }
348
349fn normalize_origin(origin: &str) -> Option<String> {
350 let url = url::Url::parse(origin).ok()?;
351
352 let scheme = url.scheme().to_ascii_lowercase();
353 let host = url.host()?;
354 let host_str = match host {
355 url::Host::Domain(d) => d.to_ascii_lowercase(),
356 url::Host::Ipv4(v4) => v4.to_string(),
357 url::Host::Ipv6(v6) => format!("[{v6}]"),
358 };
359
360 let port = url.port();
361 let default_port = match scheme.as_str() {
362 "http" => Some(80),
363 "https" => Some(443),
364 _ => None,
365 };
366 let port = match (port, default_port) {
367 (Some(p), Some(d)) if p == d => None,
368 (p, _) => p,
369 };
370
371 Some(match port {
372 Some(p) => format!("{scheme}://{host_str}:{p}"),
373 None => format!("{scheme}://{host_str}"),
374 })
375}
376
377fn parse_cors_allowlist(raw: &str) -> CorsAllowlist {
378 let mut allow = CorsAllowlist::default();
379
380 for item in raw.split(',') {
381 let token = item.trim();
382 if token.is_empty() {
383 continue;
384 }
385
386 if token.contains("://") {
387 match normalize_origin(token) {
390 Some(origin) => {
391 allow.exact_origins.insert(origin);
392 }
393 None => {
394 warn!(
395 "Invalid CORS origin entry '{}'; expected an origin like https://app.example.com",
396 token
397 );
398 }
399 }
400 continue;
401 }
402
403 let host = token.to_ascii_lowercase();
405 if let Some(rest) = host.strip_prefix("*.") {
406 if !rest.is_empty() {
408 allow.hosts.push(HostPattern::Suffix(format!(".{rest}")));
409 }
410 } else {
411 allow.hosts.push(HostPattern::Exact(host));
412 }
413 }
414
415 allow
416}
417
418fn parse_cors_allowlist_env() -> CorsAllowlist {
419 const ENV_KEY: &str = "BAMBOO_CORS_ALLOW_ORIGINS";
423
424 let raw = match std::env::var(ENV_KEY) {
425 Ok(v) => v,
426 Err(_) => return CorsAllowlist::default(),
427 };
428
429 let allow = parse_cors_allowlist(&raw);
430
431 if !allow.exact_origins.is_empty() || !allow.hosts.is_empty() {
432 info!(
433 "CORS allowlist enabled via BAMBOO_CORS_ALLOW_ORIGINS ({} exact origin(s), {} host pattern(s))",
434 allow.exact_origins.len(),
435 allow.hosts.len()
436 );
437 }
438
439 allow
440}
441
442fn is_allowed_by_allowlist(origin: &str, allow: &CorsAllowlist) -> bool {
443 if let Some(normalized) = normalize_origin(origin) {
444 if allow.exact_origins.contains(&normalized) {
445 return true;
446 }
447 }
448
449 if allow.exact_origins.contains(origin) {
451 return true;
452 }
453
454 let url = match url::Url::parse(origin) {
459 Ok(u) => u,
460 Err(_) => return false,
461 };
462
463 let host = match url.host_str() {
464 Some(h) => h.to_ascii_lowercase(),
465 None => return false,
466 };
467
468 for pat in &allow.hosts {
469 match pat {
470 HostPattern::Exact(h) => {
471 if &host == h {
472 return true;
473 }
474 }
475 HostPattern::Suffix(suffix) => {
476 if host.ends_with(suffix) {
477 return true;
480 }
481 }
482 }
483 }
484
485 false
486}
487
488fn is_local_dev_origin(o: &str) -> bool {
489 o.starts_with("http://localhost:")
490 || o.starts_with("http://127.0.0.1:")
491 || o.starts_with("https://localhost:")
492 || o.starts_with("https://127.0.0.1:")
493 || o.starts_with("http://mac.local:")
494 || o.starts_with("https://mac.local:")
495 || o.starts_with("http://bodhi.bigduu.com:")
496 || o.starts_with("https://bodhi.bigduu.com:")
497 || o.starts_with("http://[::1]:")
498 || o.starts_with("https://[::1]:")
499}
500
501pub fn build_security_headers() -> DefaultHeaders {
520 let csp_override = std::env::var("BAMBOO_CSP").ok();
521 let csp_value = resolve_csp_header_value(csp_override.as_deref());
522
523 DefaultHeaders::new()
524 .add(("X-Frame-Options", "DENY"))
525 .add(("X-Content-Type-Options", "nosniff"))
526 .add(("X-XSS-Protection", "1; mode=block"))
527 .add(("Referrer-Policy", "strict-origin-when-cross-origin"))
528 .add((header::CONTENT_SECURITY_POLICY, csp_value))
530}
531
532pub async fn add_asset_cache_headers<B: MessageBody + 'static>(
546 req: ServiceRequest,
547 next: Next<B>,
548) -> Result<ServiceResponse<B>, actix_web::Error> {
549 let is_asset = req.path().starts_with("/assets/");
550 let mut res = next.call(req).await?;
551 if is_asset {
552 res.headers_mut().insert(
553 header::CACHE_CONTROL,
554 header::HeaderValue::from_static("public, max-age=31536000, immutable"),
555 );
556 }
557 Ok(res)
558}
559
560pub fn build_cors(bind_addr: &str, port: u16) -> Cors {
599 let allowlist = parse_cors_allowlist_env();
600
601 let cors = if bind_addr == "127.0.0.1" || bind_addr == "localhost" || bind_addr == "::1" {
602 info!("CORS configured for development mode: allowing local/Tauri origins (+ optional allowlist)");
606 Cors::default()
607 .allowed_origin_fn(move |origin, _req_head| {
608 let o = match origin.to_str() {
609 Ok(v) => v,
610 Err(_) => return false,
611 };
612
613 if is_allowed_by_allowlist(o, &allowlist) {
614 return true;
615 }
616
617 if is_local_dev_origin(o) {
618 return true;
619 }
620
621 o == "tauri://localhost"
622 || o == "https://tauri.localhost"
623 || o == "http://tauri.localhost"
624 })
625 .allow_any_method()
626 .allow_any_header()
627 .supports_credentials()
628 .max_age(3600)
629 } else if bind_addr == "0.0.0.0" {
630 info!("CORS configured for 0.0.0.0 bind: allowing localhost/loopback origins (+ optional allowlist)");
640 Cors::default()
641 .allowed_origin_fn(move |origin, _req_head| {
642 let o = match origin.to_str() {
643 Ok(v) => v,
644 Err(_) => return false,
645 };
646
647 if is_allowed_by_allowlist(o, &allowlist) {
649 return true;
650 }
651
652 if is_local_dev_origin(o) {
654 return true;
655 }
656
657 if o == "tauri://localhost"
659 || o == "https://tauri.localhost"
660 || o == "http://tauri.localhost"
661 {
662 return true;
663 }
664
665 if o == format!("http://localhost:{port}")
667 || o == format!("http://127.0.0.1:{port}")
668 {
669 return true;
670 }
671
672 false
673 })
674 .allow_any_method()
677 .allow_any_header()
680 .supports_credentials()
681 .max_age(3600)
682 } else {
683 info!(
685 "CORS configured for custom bind address: {} (+ optional allowlist)",
686 bind_addr
687 );
688 let bind_host = bind_addr.to_ascii_lowercase();
689 let allowlist = allowlist.clone();
690 Cors::default()
691 .allowed_origin_fn(move |origin, _req_head| {
692 let o = match origin.to_str() {
693 Ok(v) => v,
694 Err(_) => return false,
695 };
696
697 if is_allowed_by_allowlist(o, &allowlist) {
698 return true;
699 }
700
701 let url = match url::Url::parse(o) {
704 Ok(u) => u,
705 Err(_) => return false,
706 };
707 let Some(host) = url.host_str() else {
708 return false;
709 };
710 host.eq_ignore_ascii_case(&bind_host)
711 })
712 .allow_any_method()
713 .allow_any_header()
714 .supports_credentials()
715 .max_age(3600)
716 };
717
718 cors
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724
725 #[test]
726 fn rate_limiter_config_clamps_degenerate_values() {
727 let _ = rate_limiter_config(0, 0, ClientIpKeyExtractor::peer_ip());
730 let _ = rate_limiter_config(1000, 1, ClientIpKeyExtractor::peer_ip());
731 }
732
733 #[test]
734 fn loopback_binds_skip_rate_limiter() {
735 for b in ["127.0.0.1", "localhost", "::1"] {
738 assert!(
739 is_loopback_bind(b),
740 "{b} should be loopback (limiter skipped)"
741 );
742 }
743 for b in ["0.0.0.0", "192.168.1.10", "::"] {
744 assert!(!is_loopback_bind(b), "{b} should be throttled");
745 }
746 }
747
748 #[actix_web::test]
749 async fn asset_cache_headers_only_tag_hashed_assets() {
750 use actix_web::http::header::CACHE_CONTROL;
751 use actix_web::{test, web, App, HttpResponse};
752
753 let app = test::init_service(
754 App::new()
755 .wrap(actix_web::middleware::from_fn(add_asset_cache_headers))
756 .route(
757 "/assets/main-abc123.css",
758 web::get().to(|| async { HttpResponse::Ok().finish() }),
759 )
760 .route(
761 "/index.html",
762 web::get().to(|| async { HttpResponse::Ok().finish() }),
763 ),
764 )
765 .await;
766
767 let req = test::TestRequest::get()
769 .uri("/assets/main-abc123.css")
770 .to_request();
771 let res = test::call_service(&app, req).await;
772 assert_eq!(
773 res.headers()
774 .get(CACHE_CONTROL)
775 .and_then(|v| v.to_str().ok()),
776 Some("public, max-age=31536000, immutable"),
777 );
778
779 let req = test::TestRequest::get().uri("/index.html").to_request();
782 let res = test::call_service(&app, req).await;
783 assert!(
784 res.headers().get(CACHE_CONTROL).is_none(),
785 "non-asset routes must not be long-cached"
786 );
787 }
788
789 #[actix_web::test]
790 async fn rate_limiter_throttles_with_429_after_burst() {
791 use actix_governor::Governor;
792 use actix_web::http::StatusCode;
793 use actix_web::{test, web, App, HttpResponse};
794 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
795
796 let conf = rate_limiter_config(1, 2, ClientIpKeyExtractor::peer_ip());
798 let app = test::init_service(
799 App::new()
800 .wrap(Governor::new(&conf))
801 .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
802 )
803 .await;
804
805 let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 9999);
806 let (mut saw_ok, mut saw_429) = (false, false);
807 for _ in 0..6 {
808 let req = test::TestRequest::get().uri("/").peer_addr(ip).to_request();
809 match test::call_service(&app, req).await.status() {
810 StatusCode::OK => saw_ok = true,
811 StatusCode::TOO_MANY_REQUESTS => saw_429 = true,
812 other => panic!("unexpected status {other}"),
813 }
814 }
815 assert!(saw_ok, "requests within the burst must pass");
816 assert!(saw_429, "requests beyond the burst must be 429'd (#13)");
817
818 let other_ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 8888);
822 let req = test::TestRequest::get()
823 .uri("/")
824 .peer_addr(other_ip)
825 .to_request();
826 assert_eq!(
827 test::call_service(&app, req).await.status(),
828 StatusCode::OK,
829 "a different IP gets its own fresh bucket (per-IP, not global)"
830 );
831 }
832
833 #[actix_web::test]
834 async fn key_extractor_default_ignores_xff_and_uses_peer_ip() {
835 use actix_web::test;
836 use std::net::{Ipv4Addr, SocketAddr};
837
838 let ke = ClientIpKeyExtractor::peer_ip();
841 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 5000);
842 let req = test::TestRequest::get()
843 .peer_addr(peer)
844 .insert_header(("x-forwarded-for", "1.2.3.4"))
845 .to_srv_request();
846 assert_eq!(
847 ke.extract(&req).unwrap(),
848 IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))
849 );
850 }
851
852 #[actix_web::test]
853 async fn key_extractor_xff_uses_rightmost_at_one_hop_not_client_prefix() {
854 use actix_web::test;
855 use std::net::{Ipv4Addr, SocketAddr};
856
857 let ke = ClientIpKeyExtractor {
860 trust_xff: true,
861 trusted_hops: 1,
862 };
863 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000); let req = test::TestRequest::get()
865 .peer_addr(peer)
866 .insert_header(("x-forwarded-for", "1.1.1.1, 2.2.2.2"))
867 .to_srv_request();
868 assert_eq!(
869 ke.extract(&req).unwrap(),
870 IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
871 );
872 }
873
874 #[actix_web::test]
875 async fn key_extractor_xff_two_hops_takes_second_from_right() {
876 use actix_web::test;
877 use std::net::{Ipv4Addr, SocketAddr};
878
879 let ke = ClientIpKeyExtractor {
880 trust_xff: true,
881 trusted_hops: 2,
882 };
883 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
884 let req = test::TestRequest::get()
885 .peer_addr(peer)
886 .insert_header(("x-forwarded-for", "1.1.1.1, 2.2.2.2, 3.3.3.3"))
887 .to_srv_request();
888 assert_eq!(
889 ke.extract(&req).unwrap(),
890 IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
891 );
892 }
893
894 #[actix_web::test]
895 async fn key_extractor_xff_fails_closed_to_peer_when_header_too_short_or_absent() {
896 use actix_web::test;
897 use std::net::{Ipv4Addr, SocketAddr};
898
899 let ke = ClientIpKeyExtractor {
900 trust_xff: true,
901 trusted_hops: 2,
902 };
903 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
904
905 let short = test::TestRequest::get()
907 .peer_addr(peer)
908 .insert_header(("x-forwarded-for", "9.9.9.9"))
909 .to_srv_request();
910 assert_eq!(
911 ke.extract(&short).unwrap(),
912 IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
913 );
914
915 let none = test::TestRequest::get().peer_addr(peer).to_srv_request();
917 assert_eq!(
918 ke.extract(&none).unwrap(),
919 IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
920 );
921 }
922
923 #[actix_web::test]
924 async fn key_extractor_xff_flattens_multiple_header_lines_in_order() {
925 use actix_web::test;
926 use std::net::{Ipv4Addr, SocketAddr};
927
928 let ke = ClientIpKeyExtractor {
933 trust_xff: true,
934 trusted_hops: 1,
935 };
936 let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
937 let req = test::TestRequest::get()
938 .peer_addr(peer)
939 .append_header(("x-forwarded-for", "1.1.1.1"))
940 .append_header(("x-forwarded-for", "2.2.2.2"))
941 .to_srv_request();
942 assert_eq!(
943 ke.extract(&req).unwrap(),
944 IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
945 );
946 }
947
948 #[test]
949 fn parse_forwarded_ip_handles_bare_port_and_bracketed_forms() {
950 use std::net::{Ipv4Addr, Ipv6Addr};
951
952 assert_eq!(
953 parse_forwarded_ip("1.2.3.4"),
954 Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
955 );
956 assert_eq!(
957 parse_forwarded_ip("1.2.3.4:5678"),
958 Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
959 );
960 assert_eq!(
961 parse_forwarded_ip("[::1]:9000"),
962 Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
963 );
964 assert_eq!(
965 parse_forwarded_ip("[::1]"),
966 Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
967 );
968 assert_eq!(parse_forwarded_ip("not-an-ip"), None);
969 }
970
971 #[test]
972 fn mask_ipv6_prefix_zeroes_lower_bytes_and_leaves_ipv4() {
973 use std::net::{Ipv4Addr, Ipv6Addr};
974
975 let v4 = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
976 assert_eq!(mask_ipv6_prefix(v4), v4);
977
978 let v6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6));
979 assert_eq!(
981 mask_ipv6_prefix(v6),
982 IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0))
983 );
984 }
985
986 #[test]
987 fn default_csp_keeps_scripts_strict_but_allows_inline_styles() {
988 assert!(DEFAULT_CSP.contains("script-src 'self'"));
989 assert!(DEFAULT_CSP.contains("style-src 'self' 'unsafe-inline'"));
990 assert!(!DEFAULT_CSP.contains("unsafe-eval"));
991 }
992
993 #[test]
994 fn connect_src_append_normalizes_explicit_origins() {
995 let sources = parse_csp_connect_src_append(
996 "https://bodhi.bigduu.com:9562, http://bodhi.bigduu.com:9562/",
997 );
998 assert_eq!(
999 sources,
1000 vec![
1001 "https://bodhi.bigduu.com:9562".to_string(),
1002 "http://bodhi.bigduu.com:9562".to_string(),
1003 ]
1004 );
1005 }
1006
1007 #[test]
1008 fn append_connect_src_sources_extends_default_csp() {
1009 let csp = append_connect_src_sources(
1010 DEFAULT_CSP,
1011 &[
1012 "https://bodhi.bigduu.com:9562".to_string(),
1013 "http://bodhi.bigduu.com:9562".to_string(),
1014 ],
1015 );
1016
1017 assert!(csp.contains("connect-src 'self' ws: wss:"));
1018 assert!(csp.contains("https://bodhi.bigduu.com:9562"));
1019 assert!(csp.contains("http://bodhi.bigduu.com:9562"));
1020 }
1021
1022 #[test]
1023 fn invalid_override_falls_back_to_default() {
1024 let v = resolve_csp_header_value(Some("default-src 'self'\nscript-src 'self'"));
1026 let rendered = v.to_str().expect("header should be valid utf-8");
1027 assert!(rendered.contains("connect-src 'self' ws: wss:"));
1028 assert!(rendered.contains("http://127.0.0.1:*"));
1029 assert!(rendered.contains("http://localhost:*"));
1030 assert!(rendered.contains("http://bodhi.bigduu.com:9562"));
1031 assert!(rendered.contains("https://bodhi.bigduu.com:9562"));
1032 assert!(rendered.contains("style-src 'self' 'unsafe-inline'"));
1033 }
1034
1035 #[test]
1036 fn cors_allowlist_parses_hosts_and_origins() {
1037 let allow = parse_cors_allowlist(
1038 "https://app.example.com/, app.example2.com, *.example.net , http://localhost:5173",
1039 );
1040 assert!(allow.exact_origins.contains("https://app.example.com"));
1041 assert!(allow.exact_origins.contains("http://localhost:5173"));
1042 assert!(allow
1043 .hosts
1044 .contains(&HostPattern::Exact("app.example2.com".to_string())));
1045 assert!(allow
1046 .hosts
1047 .contains(&HostPattern::Suffix(".example.net".to_string())));
1048 }
1049
1050 #[test]
1051 fn cors_allowlist_matches_exact_and_wildcard_hosts() {
1052 let mut allow = CorsAllowlist::default();
1053 allow
1054 .exact_origins
1055 .insert("https://app.example.com".to_string());
1056 allow
1057 .hosts
1058 .push(HostPattern::Exact("app2.example.com".to_string()));
1059 allow
1060 .hosts
1061 .push(HostPattern::Suffix(".example.net".to_string()));
1062
1063 assert!(is_allowed_by_allowlist("https://app.example.com", &allow));
1064 assert!(is_allowed_by_allowlist(
1065 "https://app.example.com:443",
1066 &allow
1067 ));
1068 assert!(is_allowed_by_allowlist(
1069 "http://app2.example.com:5173",
1070 &allow
1071 ));
1072 assert!(is_allowed_by_allowlist("https://x.example.net", &allow));
1073 assert!(!is_allowed_by_allowlist("https://example.net", &allow));
1074 assert!(!is_allowed_by_allowlist("https://evil.com", &allow));
1075 }
1076
1077 #[test]
1078 fn local_dev_origin_allows_mac_local_and_bodhi_domain() {
1079 assert!(is_local_dev_origin("http://mac.local:1420"));
1080 assert!(is_local_dev_origin("https://mac.local:1420"));
1081 assert!(is_local_dev_origin("http://bodhi.bigduu.com:9562"));
1082 assert!(is_local_dev_origin("https://bodhi.bigduu.com:9562"));
1083 assert!(!is_local_dev_origin("http://evil.com:1420"));
1084 }
1085}