Skip to main content

bamboo_server/
config.rs

1//! Server configuration utilities
2//!
3//! This module provides functions to configure security headers and CORS policies
4//! for the Actix-web server based on the deployment environment.
5//!
6//! # Security Headers
7//!
8//! The server applies production-ready security headers:
9//! - X-Frame-Options: DENY
10//! - X-Content-Type-Options: nosniff
11//! - X-XSS-Protection: 1; mode=block
12//! - Referrer-Policy: strict-origin-when-cross-origin
13//! - Content-Security-Policy: Customizable CSP
14//!
15//! # CORS Configuration
16//!
17//! CORS policies are automatically adjusted based on bind address:
18//! - **localhost**: Development mode with permissive CORS
19//! - **0.0.0.0**: Docker production mode (localhost only via reverse proxy)
20//! - **Custom**: Restrictive CORS for specific addresses
21
22use 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
36/// Default sustained per-IP request rate (requests/second) for the production
37/// (network-exposed) server. Overridable via `BAMBOO_RATE_LIMIT_PER_SECOND`.
38const DEFAULT_RATE_LIMIT_PER_SECOND: u64 = 10;
39/// Default per-IP burst allowance. Overridable via `BAMBOO_RATE_LIMIT_BURST`.
40const DEFAULT_RATE_LIMIT_BURST: u32 = 20;
41
42/// Rate-limiter key extractor. Defaults to the TCP peer IP (non-spoofable), but
43/// can be switched to an OPT-IN `X-Forwarded-For` mode for reverse-proxy
44/// deployments where the peer IP is always the proxy (which would otherwise
45/// collapse the per-IP limit to global). #169.
46///
47/// SECURITY: XFF mode is only safe behind a trusted proxy — a directly-reachable
48/// server trusting XFF lets any client spoof its key and bypass the limiter. It
49/// is therefore off unless `BAMBOO_RATE_LIMIT_TRUST_XFF` is set, and it fails
50/// CLOSED to the peer IP whenever the header is absent, unparseable, or shorter
51/// than the configured trusted-hop count (so a rogue/short XFF can't inject a key).
52#[derive(Clone, Debug)]
53pub struct ClientIpKeyExtractor {
54    trust_xff: bool,
55    /// Number of trusted proxies between us and the client. The real client is
56    /// the `trusted_hops`-th entry from the RIGHT of `X-Forwarded-For` (each proxy
57    /// appends the peer it saw as the request travels outward-to-inward).
58    trusted_hops: usize,
59}
60
61impl ClientIpKeyExtractor {
62    /// The default, non-spoofable peer-IP extractor.
63    #[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        // Consider EVERY `X-Forwarded-For` header line, in order, not just the
74        // first: some proxies append a second header line rather than extending
75        // the comma-joined value, and reading only the first could let an
76        // attacker-supplied line win. Flatten all lines into one ordered list of
77        // entries (client-first ... nearest-proxy-last).
78        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        // Fail closed: a header with fewer entries than the trusted hop count is
87        // not the shape a trusted proxy chain produces, so don't trust it.
88        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            // else: fall through to the peer IP (fail closed).
105        }
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
113/// Rate-limit IPv6 clients per /56 prefix rather than per address (customers are
114/// often handed a whole prefix), mirroring `PeerIpKeyExtractor`. IPv4 is unchanged.
115fn 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
126/// Parse one `X-Forwarded-For` entry into an IP, tolerating a `host:port` or
127/// bracketed-IPv6 form some proxies emit.
128fn 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    // Bracketed IPv6 without a port, e.g. "[::1]".
137    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    // One cell replenishes every `1000 / per_second` ms (>=1), allowing `per_second`
147    // sustained req/s with a `burst` bucket. Clamp to >=1 so a bad env value can't
148    // produce a zero period/burst (which finish() would reject).
149    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
158/// Build the per-IP rate-limiter config applied to the PRODUCTION (network-bound)
159/// server via the `actix-governor` middleware. Throttles each client IP to
160/// `BAMBOO_RATE_LIMIT_PER_SECOND` (default 10) req/s with a `BAMBOO_RATE_LIMIT_BURST`
161/// (default 20) burst, returning 429 Too Many Requests when exceeded. Desktop
162/// (localhost) mode does not apply it. #13.
163///
164/// Keys on the TCP PEER IP by default (non-spoofable). Behind a reverse proxy
165/// every client shares the proxy's IP, collapsing the per-IP limit to global; set
166/// `BAMBOO_RATE_LIMIT_TRUST_XFF=1` to key on `X-Forwarded-For` instead (with
167/// `BAMBOO_RATE_LIMIT_TRUSTED_HOPS`, default one hop). #169. XFF mode is OPT-IN
168/// because trusting the header when NOT behind a trusted proxy lets any client
169/// spoof its rate-limit key; see [`ClientIpKeyExtractor`].
170pub 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
211/// True when `bind` is a loopback/desktop address, for which the per-IP DoS
212/// rate limiter ([`build_rate_limiter`], #13) is intentionally SKIPPED. The
213/// desktop sidecar serves the local frontend, which legitimately bursts ~45
214/// hashed `/assets/*` requests on load and would otherwise trip the 429 limit
215/// (`burst` default 20). Mirrors the loopback special-casing already used for
216/// CORS; network binds (`0.0.0.0`) are still throttled.
217pub fn is_loopback_bind(bind: &str) -> bool {
218    matches!(bind, "127.0.0.1" | "localhost" | "::1")
219}
220
221// Keep the default CSP reasonably strict while remaining compatible with the Lotus UI runtime.
222// Lotus + Ant Design inject runtime styles, so `style-src 'unsafe-inline'` is required for the
223// current frontend bundle. Keep scripts strict (no `unsafe-eval`) and allow operators to override
224// via `BAMBOO_CSP` when needed.
225const 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            // Avoid failing to start due to a malformed override; fall back to the safe default.
321            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/// CORS allowlist sourced from env vars.
332///
333/// Supported entries:
334/// - Exact origins: `https://app.example.com`, `http://localhost:5173`
335/// - Hosts (any scheme/port): `app.example.com`, `127.0.0.1`
336/// - Wildcard subdomains (any scheme/port): `*.example.com`
337#[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), // stored with leading dot, e.g. ".example.com"
347}
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            // Exact origin match. Normalize to an origin-like form so common inputs
388            // (trailing slashes, explicit :443, etc.) still match real Origin headers.
389            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        // Host-based match.
404        let host = token.to_ascii_lowercase();
405        if let Some(rest) = host.strip_prefix("*.") {
406            // Wildcard subdomains.
407            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    // Comma-separated list. Examples:
420    //   BAMBOO_CORS_ALLOW_ORIGINS="https://app.example.com,http://localhost:5173,*.example.com"
421    //   BAMBOO_CORS_ALLOW_ORIGINS="app.example.com,127.0.0.1"
422    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    // Keep a strict string match fallback (covers unusual schemes like tauri://).
450    if allow.exact_origins.contains(origin) {
451        return true;
452    }
453
454    // Try to parse a host from the origin. Origin header values are serialized origins like:
455    // - https://app.example.com
456    // - http://127.0.0.1:5173
457    // - http://[::1]:5173
458    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                    // Ensure we only match subdomains, not the apex itself when suffix is ".example.com".
478                    // (host == "example.com" should not match ".example.com".)
479                    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
501/// Build security headers middleware for production deployments
502///
503/// Applies standard security headers to all HTTP responses:
504/// - Prevents clickjacking (X-Frame-Options)
505/// - Prevents MIME type sniffing (X-Content-Type-Options)
506/// - Enables XSS protection (X-XSS-Protection)
507/// - Controls referrer information (Referrer-Policy)
508/// - Restricts resource loading (Content-Security-Policy)
509///
510/// # Example
511///
512/// ```rust,ignore
513/// use actix_web::App;
514/// use bamboo_agent::server::config::build_security_headers;
515///
516/// let app = App::new()
517///     .wrap(build_security_headers());
518/// ```
519pub 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        // Note: customize at runtime via `BAMBOO_CSP` if your frontend requires a relaxed policy.
529        .add((header::CONTENT_SECURITY_POLICY, csp_value))
530}
531
532/// Long-cache content-hashed frontend assets at the proxy/CDN edge.
533///
534/// Vite emits hashed filenames under `/assets/` (e.g. `main-B6snAd4S.css`), so
535/// they are inherently immutable — any content change yields a NEW filename.
536/// Tagging them `immutable, max-age=1y` lets Cloudflare and browsers cache them
537/// at the edge instead of round-tripping every chunk through the tunnel to
538/// origin. Besides being faster, this removes the transient per-asset failures
539/// (an occasional reset of one of many parallel preload requests over a
540/// cloudflared tunnel) that surface in the browser as Vite's
541/// "Unable to preload CSS for …" / "Failed to fetch dynamically imported module".
542///
543/// Only `/assets/*` is affected; `index.html` and API routes are left untouched
544/// so they always serve fresh (a new deploy must be picked up immediately).
545pub 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
560/// Build CORS middleware based on bind address and port
561///
562/// Automatically configures CORS policy based on deployment environment:
563///
564/// # Development Mode (localhost)
565///
566/// When binding to `127.0.0.1`, `localhost`, or `::1`:
567/// - Allows all origins, methods, and headers
568/// - Suitable for local development
569/// - Safe because server is only accessible locally
570///
571/// # Docker Production Mode (0.0.0.0)
572///
573/// When binding to `0.0.0.0`:
574/// - Only allows `http://localhost:{port}`
575/// - Requires reverse proxy for external access
576/// - Restrictive CORS for security
577///
578/// # Custom Address
579///
580/// For any other bind address:
581/// - Only allows that specific address
582/// - Most restrictive configuration
583///
584/// # Arguments
585///
586/// * `bind_addr` - The address the server binds to
587/// * `port` - The port number the server listens on
588///
589/// # Example
590///
591/// ```rust,ignore
592/// use actix_web::HttpServer;
593/// use bambooagent::server::config::build_cors;
594///
595/// let cors = build_cors("127.0.0.1", 9562);
596/// // Use cors middleware in HttpServer
597/// ```
598pub 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        // Development/Desktop mode. Keep origins permissive for local/Tauri callers, but do not
603        // combine wildcard `Access-Control-Allow-Origin: *` with credentialed requests. The Lotus
604        // client sends `credentials: "include"` so browsers require a concrete echoed Origin.
605        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        // Docker/sidecar mode.
631        //
632        // We still want to restrict origins to "local" callers, but ports and schemes
633        // can differ between:
634        // - Vite dev server (http://127.0.0.1:5173, http://localhost:5173)
635        // - Tauri webview (tauri://localhost, https://tauri.localhost)
636        // - Reverse proxy setups (http://localhost:{port})
637        //
638        // Accept any localhost/loopback origin (any port) and common Tauri origins.
639        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                // Explicit allowlist (for remote UI domains, etc).
648                if is_allowed_by_allowlist(o, &allowlist) {
649                    return true;
650                }
651
652                // Common local HTTP(S) dev origins (any port).
653                if is_local_dev_origin(o) {
654                    return true;
655                }
656
657                // Tauri webview origins (vary by version/config).
658                if o == "tauri://localhost"
659                    || o == "https://tauri.localhost"
660                    || o == "http://tauri.localhost"
661                {
662                    return true;
663                }
664
665                // Some setups might load the UI from the same port as the backend.
666                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            // This server is commonly used as a local relay for multiple upstream clients
675            // (OpenAI/Anthropic/Gemini). Avoid CORS preflight failures by not restricting methods.
676            .allow_any_method()
677            // OpenAI's official JS client sends additional `x-stainless-*` headers which would
678            // otherwise fail preflight; keep headers permissive while origin stays locked down.
679            .allow_any_header()
680            .supports_credentials()
681            .max_age(3600)
682    } else {
683        // Custom bind address - restrictive by default, but allow explicit env allowlist.
684        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                // Allow same-host origins (any scheme/port) for the bind address itself.
702                // This keeps the default "tight" without requiring users to enumerate ports.
703                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        // 0 per_second / 0 burst would make finish() reject; the clamps keep it
728        // valid (no panic).
729        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        // Desktop sidecar binds must be exempt (frontend bursts asset requests);
736        // network-exposed binds must stay throttled.
737        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        // A hashed `/assets/*` file gets the immutable long-cache header.
768        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        // `index.html` (and anything outside `/assets/`) must stay fresh so a new
780        // deploy is picked up immediately — no long-cache header added.
781        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        // burst=2: the first two requests from an IP pass, the rest are throttled.
797        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        // A DIFFERENT client IP has its OWN bucket — proving per-IP keying (a
819        // global bucket would 429 this too); guards against a regression to a
820        // global key extractor.
821        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        // Default (trust_xff = false): a client-supplied XFF must be ignored so it
839        // can't spoof its rate-limit key on a directly-exposed server.
840        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        // trusted_hops = 1: only the entry OUR proxy appended (rightmost) is
858        // trusted; a client prepending a fake IP can't change the key.
859        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); // proxy
864        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        // Fewer entries than trusted hops → not a trusted-proxy shape → peer IP.
906        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        // No XFF at all → peer IP.
916        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        // A proxy chain that appends a SECOND header line rather than extending
929        // the comma-joined value: the entries must be treated as one ordered list
930        // (client-first ... proxy-last), so 1-hop still selects the true rightmost
931        // entry authored by the nearest proxy — not the first line's value.
932        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        // /56: first 7 bytes preserved, remaining 9 zeroed.
980        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        // Header values cannot contain newlines.
1025        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}