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_web::body::MessageBody;
24use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse};
25use actix_web::http::header;
26use actix_web::middleware::{Condition, DefaultHeaders, Next};
27use actix_web::App;
28use std::collections::HashSet;
29use std::net::IpAddr;
30use tracing::info;
31use tracing::warn;
32
33use crate::rate_limit::{KeyExtractor, RateLimit, RateLimiterConfig, SimpleKeyExtractionError};
34
35/// Default sustained per-IP request rate (requests/second) for the production
36/// (network-exposed) server. Overridable via `BAMBOO_RATE_LIMIT_PER_SECOND`.
37const DEFAULT_RATE_LIMIT_PER_SECOND: u64 = 10;
38/// Default per-IP burst allowance. Overridable via `BAMBOO_RATE_LIMIT_BURST`.
39const DEFAULT_RATE_LIMIT_BURST: u32 = 20;
40
41/// Rate-limiter key extractor. Defaults to the TCP peer IP (non-spoofable), but
42/// can be switched to an OPT-IN `X-Forwarded-For` mode for reverse-proxy
43/// deployments where the peer IP is always the proxy (which would otherwise
44/// collapse the per-IP limit to global). #169.
45///
46/// SECURITY: XFF mode is only safe behind a trusted proxy — a directly-reachable
47/// server trusting XFF lets any client spoof its key and bypass the limiter. It
48/// is therefore off unless `BAMBOO_RATE_LIMIT_TRUST_XFF` is set, and it fails
49/// CLOSED to the peer IP whenever the header is absent, unparseable, or shorter
50/// than the configured trusted-hop count (so a rogue/short XFF can't inject a key).
51#[derive(Clone, Debug)]
52pub struct ClientIpKeyExtractor {
53    trust_xff: bool,
54    /// Number of trusted proxies between us and the client. The real client is
55    /// the `trusted_hops`-th entry from the RIGHT of `X-Forwarded-For` (each proxy
56    /// appends the peer it saw as the request travels outward-to-inward).
57    trusted_hops: usize,
58}
59
60impl ClientIpKeyExtractor {
61    /// The default, non-spoofable peer-IP extractor.
62    #[cfg(test)]
63    fn peer_ip() -> Self {
64        Self {
65            trust_xff: false,
66            trusted_hops: 1,
67        }
68    }
69
70    fn client_ip_from_xff(&self, req: &ServiceRequest) -> Option<IpAddr> {
71        let hops = self.trusted_hops.max(1);
72        // Consider EVERY `X-Forwarded-For` header line, in order, not just the
73        // first: some proxies append a second header line rather than extending
74        // the comma-joined value, and reading only the first could let an
75        // attacker-supplied line win. Flatten all lines into one ordered list of
76        // entries (client-first ... nearest-proxy-last).
77        let entries: Vec<&str> = req
78            .headers()
79            .get_all("x-forwarded-for")
80            .filter_map(|v| v.to_str().ok())
81            .flat_map(|line| line.split(','))
82            .map(|s| s.trim())
83            .filter(|s| !s.is_empty())
84            .collect();
85        // Fail closed: a header with fewer entries than the trusted hop count is
86        // not the shape a trusted proxy chain produces, so don't trust it.
87        if entries.len() < hops {
88            return None;
89        }
90        parse_forwarded_ip(entries[entries.len() - hops])
91    }
92}
93
94impl KeyExtractor for ClientIpKeyExtractor {
95    type Key = IpAddr;
96    type KeyExtractionError = SimpleKeyExtractionError;
97
98    fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError> {
99        if self.trust_xff {
100            if let Some(client) = self.client_ip_from_xff(req) {
101                return Ok(mask_ipv6_prefix(client));
102            }
103            // else: fall through to the peer IP (fail closed).
104        }
105        let ip = req.peer_addr().map(|socket| socket.ip()).ok_or_else(|| {
106            SimpleKeyExtractionError::new("Could not extract peer IP address from request")
107        })?;
108        Ok(mask_ipv6_prefix(ip))
109    }
110}
111
112/// Rate-limit IPv6 clients per /56 prefix rather than per address (customers are
113/// often handed a whole prefix), mirroring `PeerIpKeyExtractor`. IPv4 is unchanged.
114fn mask_ipv6_prefix(ip: IpAddr) -> IpAddr {
115    match ip {
116        IpAddr::V6(v6) => {
117            let mut octets = v6.octets();
118            octets[7..16].fill(0);
119            IpAddr::V6(octets.into())
120        }
121        v4 => v4,
122    }
123}
124
125/// Parse one `X-Forwarded-For` entry into an IP, tolerating a `host:port` or
126/// bracketed-IPv6 form some proxies emit.
127fn parse_forwarded_ip(s: &str) -> Option<IpAddr> {
128    let s = s.trim();
129    if let Ok(ip) = s.parse::<IpAddr>() {
130        return Some(ip);
131    }
132    if let Ok(sa) = s.parse::<std::net::SocketAddr>() {
133        return Some(sa.ip());
134    }
135    // Bracketed IPv6 without a port, e.g. "[::1]".
136    let unbracketed = s.strip_prefix('[').and_then(|x| x.strip_suffix(']'))?;
137    unbracketed.parse::<IpAddr>().ok()
138}
139
140fn rate_limiter_config(
141    per_second: u64,
142    burst: u32,
143    key_extractor: ClientIpKeyExtractor,
144) -> RateLimiterConfig<ClientIpKeyExtractor> {
145    // One cell replenishes every `1000 / per_second` ms (>=1), allowing `per_second`
146    // sustained req/s with a `burst` bucket. Clamp to >=1 so a bad env value can't
147    // produce a zero period/burst (which `RateLimiterConfig::new` would panic on).
148    let ms_per_request = (1000 / per_second.max(1)).max(1);
149    RateLimiterConfig::new(
150        std::time::Duration::from_millis(ms_per_request),
151        burst.max(1),
152        key_extractor,
153    )
154}
155
156/// Build the per-IP rate-limiter config applied to the PRODUCTION (network-bound)
157/// server via the [`crate::rate_limit`] middleware. Throttles each client IP to
158/// `BAMBOO_RATE_LIMIT_PER_SECOND` (default 10) req/s with a `BAMBOO_RATE_LIMIT_BURST`
159/// (default 20) burst, returning 429 Too Many Requests when exceeded. Desktop
160/// (localhost) mode does not apply it. #13.
161///
162/// Keys on the TCP PEER IP by default (non-spoofable). Behind a reverse proxy
163/// every client shares the proxy's IP, collapsing the per-IP limit to global; set
164/// `BAMBOO_RATE_LIMIT_TRUST_XFF=1` to key on `X-Forwarded-For` instead (with
165/// `BAMBOO_RATE_LIMIT_TRUSTED_HOPS`, default one hop). #169. XFF mode is OPT-IN
166/// because trusting the header when NOT behind a trusted proxy lets any client
167/// spoof its rate-limit key; see [`ClientIpKeyExtractor`].
168pub fn build_rate_limiter() -> RateLimiterConfig<ClientIpKeyExtractor> {
169    let per_second = std::env::var("BAMBOO_RATE_LIMIT_PER_SECOND")
170        .ok()
171        .and_then(|v| v.trim().parse::<u64>().ok())
172        .unwrap_or(DEFAULT_RATE_LIMIT_PER_SECOND);
173    let burst = std::env::var("BAMBOO_RATE_LIMIT_BURST")
174        .ok()
175        .and_then(|v| v.trim().parse::<u32>().ok())
176        .unwrap_or(DEFAULT_RATE_LIMIT_BURST);
177
178    let trust_xff = std::env::var("BAMBOO_RATE_LIMIT_TRUST_XFF")
179        .ok()
180        .map(|v| {
181            let t = v.trim();
182            t == "1" || t.eq_ignore_ascii_case("true")
183        })
184        .unwrap_or(false);
185    let trusted_hops = std::env::var("BAMBOO_RATE_LIMIT_TRUSTED_HOPS")
186        .ok()
187        .and_then(|v| v.trim().parse::<usize>().ok())
188        .filter(|n| *n >= 1)
189        .unwrap_or(1);
190
191    if trust_xff {
192        warn!(
193            "Rate limiter is trusting X-Forwarded-For (trusted_hops={trusted_hops}). \
194             Only enable this when the server is reachable exclusively through a trusted \
195             reverse proxy — otherwise clients can spoof their rate-limit key."
196        );
197    }
198
199    rate_limiter_config(
200        per_second,
201        burst,
202        ClientIpKeyExtractor {
203            trust_xff,
204            trusted_hops,
205        },
206    )
207}
208
209/// True when `bind` is a loopback/desktop address, for which the per-IP DoS
210/// rate limiter ([`build_rate_limiter`], #13) is intentionally SKIPPED. The
211/// desktop sidecar serves the local frontend, which legitimately bursts ~45
212/// hashed `/assets/*` requests on load and would otherwise trip the 429 limit
213/// (`burst` default 20). Mirrors the loopback special-casing already used for
214/// CORS; network binds (`0.0.0.0`) are still throttled.
215///
216/// Classification is via [`IpAddr::is_loopback`] (so the whole `127.0.0.0/8`
217/// range, not just `127.0.0.1`, and a bracketed IPv6 literal like `[::1]` are
218/// correctly recognized) plus a literal `"localhost"` match, since that's a
219/// hostname rather than an address `IpAddr` can parse. #428: previously this
220/// was a strict string allowlist (`127.0.0.1` / `localhost` / `::1`), so e.g.
221/// `127.0.0.2` or `[::1]` were misclassified as non-loopback — which failed
222/// SAFE (the limiter was applied) but was still wrong.
223pub fn is_loopback_bind(bind: &str) -> bool {
224    let candidate = bind.trim();
225    let unbracketed = candidate
226        .strip_prefix('[')
227        .and_then(|s| s.strip_suffix(']'))
228        .unwrap_or(candidate);
229
230    if unbracketed.eq_ignore_ascii_case("localhost") {
231        return true;
232    }
233
234    unbracketed
235        .parse::<IpAddr>()
236        .map(|ip| ip.is_loopback())
237        .unwrap_or(false)
238}
239
240/// Bind-aware limiter guard (#169 part 3).
241///
242/// The per-IP rate limiter (#13) is what protects a network-exposed bind from a
243/// DoS flood. Some serve paths (notably [`crate::server::WebService::start_with_bind`])
244/// never install the limiter, and every bind-accepting path takes an arbitrary
245/// `bind` string — so a caller COULD start an unthrottled server on `0.0.0.0`
246/// (or another routable interface) and silently re-open the surface #13 closed.
247///
248/// This guard rejects exactly that combination: a NON-loopback bind with NO
249/// limiter applied. Loopback binds (see [`is_loopback_bind`]) are exempt because
250/// the desktop sidecar intentionally runs un-throttled to serve its local
251/// frontend — so this never weakens the established localhost behavior. Paths
252/// that DO apply the limiter pass `limiter_applied = true` and are always
253/// accepted, regardless of bind.
254pub fn require_limiter_for_nonloopback(bind: &str, limiter_applied: bool) -> Result<(), String> {
255    if !limiter_applied && !is_loopback_bind(bind) {
256        return Err(format!(
257            "refusing to serve on non-loopback bind '{bind}' without a rate limiter: it would \
258             run unthrottled and re-open the per-IP DoS surface closed by #13. Use a \
259             limiter-applying serve path (e.g. start_with_bind_and_static / run_with_bind) or \
260             bind to loopback (127.0.0.1 / localhost / ::1)."
261        ));
262    }
263    Ok(())
264}
265
266// Keep the default CSP reasonably strict while remaining compatible with the Lotus UI runtime.
267// Lotus + Ant Design inject runtime styles, so `style-src 'unsafe-inline'` is required for the
268// current frontend bundle. Keep scripts strict (no `unsafe-eval`) and allow operators to override
269// via `BAMBOO_CSP` when needed.
270const DEFAULT_CSP: &str = concat!(
271    "default-src 'self'; ",
272    "base-uri 'self'; ",
273    "object-src 'none'; ",
274    "frame-ancestors 'none'; ",
275    "script-src 'self'; ",
276    "style-src 'self' 'unsafe-inline'; ",
277    "img-src 'self' data: https:; ",
278    "font-src 'self' data:; ",
279    "connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* http://bodhi.bigduu.com:9562 https://bodhi.bigduu.com:9562; ",
280    "form-action 'self';"
281);
282
283fn normalize_csp_source_token(token: &str) -> Option<String> {
284    let trimmed = token.trim();
285    if trimmed.is_empty() {
286        return None;
287    }
288
289    if trimmed.starts_with("'") {
290        return Some(trimmed.to_string());
291    }
292
293    normalize_origin(trimmed).or_else(|| Some(trimmed.to_string()))
294}
295
296fn parse_csp_connect_src_append(raw: &str) -> Vec<String> {
297    raw.split(|c: char| c == ',' || c.is_ascii_whitespace())
298        .filter_map(normalize_csp_source_token)
299        .collect()
300}
301
302fn append_connect_src_sources(base_csp: &str, extra_sources: &[String]) -> String {
303    if extra_sources.is_empty() {
304        return base_csp.to_string();
305    }
306
307    let connect_src_marker = "connect-src ";
308    if let Some(start) = base_csp.find(connect_src_marker) {
309        let value_start = start + connect_src_marker.len();
310        if let Some(relative_end) = base_csp[value_start..].find(';') {
311            let value_end = value_start + relative_end;
312            let existing_value = base_csp[value_start..value_end].trim();
313            let mut merged = if existing_value.is_empty() {
314                String::new()
315            } else {
316                existing_value.to_string()
317            };
318
319            for source in extra_sources {
320                if merged.split_whitespace().any(|token| token == source) {
321                    continue;
322                }
323                if !merged.is_empty() {
324                    merged.push(' ');
325                }
326                merged.push_str(source);
327            }
328
329            let mut result = String::with_capacity(base_csp.len() + merged.len() + 1);
330            result.push_str(&base_csp[..value_start]);
331            result.push_str(&merged);
332            result.push_str(&base_csp[value_end..]);
333            return result;
334        }
335    }
336
337    base_csp.to_string()
338}
339
340fn resolve_default_csp() -> String {
341    const ENV_KEY: &str = "BAMBOO_CSP_CONNECT_SRC";
342
343    let extra_sources = match std::env::var(ENV_KEY) {
344        Ok(raw) => parse_csp_connect_src_append(&raw),
345        Err(_) => Vec::new(),
346    };
347
348    if !extra_sources.is_empty() {
349        info!(
350            "Extending CSP connect-src via {} with {} source(s)",
351            ENV_KEY,
352            extra_sources.len()
353        );
354    }
355
356    append_connect_src_sources(DEFAULT_CSP, &extra_sources)
357}
358
359fn resolve_csp_header_value(override_value: Option<&str>) -> header::HeaderValue {
360    let default_csp = resolve_default_csp();
361    let csp = override_value.unwrap_or(default_csp.as_str());
362    match header::HeaderValue::from_str(csp) {
363        Ok(v) => v,
364        Err(e) => {
365            // Avoid failing to start due to a malformed override; fall back to the safe default.
366            warn!(
367                "Invalid BAMBOO_CSP value ({}); falling back to DEFAULT_CSP",
368                e
369            );
370            header::HeaderValue::from_str(default_csp.as_str())
371                .unwrap_or_else(|_| header::HeaderValue::from_static(DEFAULT_CSP))
372        }
373    }
374}
375
376/// CORS allowlist sourced from env vars.
377///
378/// Supported entries:
379/// - Exact origins: `https://app.example.com`, `http://localhost:5173`
380/// - Hosts (any scheme/port): `app.example.com`, `127.0.0.1`
381/// - Wildcard subdomains (any scheme/port): `*.example.com`
382#[derive(Debug, Clone, Default)]
383struct CorsAllowlist {
384    exact_origins: HashSet<String>,
385    hosts: Vec<HostPattern>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
389enum HostPattern {
390    Exact(String),
391    Suffix(String), // stored with leading dot, e.g. ".example.com"
392}
393
394fn normalize_origin(origin: &str) -> Option<String> {
395    let url = url::Url::parse(origin).ok()?;
396
397    let scheme = url.scheme().to_ascii_lowercase();
398    let host = url.host()?;
399    let host_str = match host {
400        url::Host::Domain(d) => d.to_ascii_lowercase(),
401        url::Host::Ipv4(v4) => v4.to_string(),
402        url::Host::Ipv6(v6) => format!("[{v6}]"),
403    };
404
405    let port = url.port();
406    let default_port = match scheme.as_str() {
407        "http" => Some(80),
408        "https" => Some(443),
409        _ => None,
410    };
411    let port = match (port, default_port) {
412        (Some(p), Some(d)) if p == d => None,
413        (p, _) => p,
414    };
415
416    Some(match port {
417        Some(p) => format!("{scheme}://{host_str}:{p}"),
418        None => format!("{scheme}://{host_str}"),
419    })
420}
421
422fn parse_cors_allowlist(raw: &str) -> CorsAllowlist {
423    let mut allow = CorsAllowlist::default();
424
425    for item in raw.split(',') {
426        let token = item.trim();
427        if token.is_empty() {
428            continue;
429        }
430
431        if token.contains("://") {
432            // Exact origin match. Normalize to an origin-like form so common inputs
433            // (trailing slashes, explicit :443, etc.) still match real Origin headers.
434            match normalize_origin(token) {
435                Some(origin) => {
436                    allow.exact_origins.insert(origin);
437                }
438                None => {
439                    warn!(
440                        "Invalid CORS origin entry '{}'; expected an origin like https://app.example.com",
441                        token
442                    );
443                }
444            }
445            continue;
446        }
447
448        // Host-based match.
449        let host = token.to_ascii_lowercase();
450        if let Some(rest) = host.strip_prefix("*.") {
451            // Wildcard subdomains.
452            if !rest.is_empty() {
453                allow.hosts.push(HostPattern::Suffix(format!(".{rest}")));
454            }
455        } else {
456            allow.hosts.push(HostPattern::Exact(host));
457        }
458    }
459
460    allow
461}
462
463fn parse_cors_allowlist_env() -> CorsAllowlist {
464    // Comma-separated list. Examples:
465    //   BAMBOO_CORS_ALLOW_ORIGINS="https://app.example.com,http://localhost:5173,*.example.com"
466    //   BAMBOO_CORS_ALLOW_ORIGINS="app.example.com,127.0.0.1"
467    const ENV_KEY: &str = "BAMBOO_CORS_ALLOW_ORIGINS";
468
469    let raw = match std::env::var(ENV_KEY) {
470        Ok(v) => v,
471        Err(_) => return CorsAllowlist::default(),
472    };
473
474    let allow = parse_cors_allowlist(&raw);
475
476    if !allow.exact_origins.is_empty() || !allow.hosts.is_empty() {
477        info!(
478            "CORS allowlist enabled via BAMBOO_CORS_ALLOW_ORIGINS ({} exact origin(s), {} host pattern(s))",
479            allow.exact_origins.len(),
480            allow.hosts.len()
481        );
482    }
483
484    allow
485}
486
487fn is_allowed_by_allowlist(origin: &str, allow: &CorsAllowlist) -> bool {
488    if let Some(normalized) = normalize_origin(origin) {
489        if allow.exact_origins.contains(&normalized) {
490            return true;
491        }
492    }
493
494    // Keep a strict string match fallback (covers unusual schemes like tauri://).
495    if allow.exact_origins.contains(origin) {
496        return true;
497    }
498
499    // Try to parse a host from the origin. Origin header values are serialized origins like:
500    // - https://app.example.com
501    // - http://127.0.0.1:5173
502    // - http://[::1]:5173
503    let url = match url::Url::parse(origin) {
504        Ok(u) => u,
505        Err(_) => return false,
506    };
507
508    let host = match url.host_str() {
509        Some(h) => h.to_ascii_lowercase(),
510        None => return false,
511    };
512
513    for pat in &allow.hosts {
514        match pat {
515            HostPattern::Exact(h) => {
516                if &host == h {
517                    return true;
518                }
519            }
520            HostPattern::Suffix(suffix) => {
521                if host.ends_with(suffix) {
522                    // Ensure we only match subdomains, not the apex itself when suffix is ".example.com".
523                    // (host == "example.com" should not match ".example.com".)
524                    return true;
525                }
526            }
527        }
528    }
529
530    false
531}
532
533fn is_local_dev_origin(o: &str) -> bool {
534    o.starts_with("http://localhost:")
535        || o.starts_with("http://127.0.0.1:")
536        || o.starts_with("https://localhost:")
537        || o.starts_with("https://127.0.0.1:")
538        || o.starts_with("http://mac.local:")
539        || o.starts_with("https://mac.local:")
540        || o.starts_with("http://bodhi.bigduu.com:")
541        || o.starts_with("https://bodhi.bigduu.com:")
542        || o.starts_with("http://[::1]:")
543        || o.starts_with("https://[::1]:")
544}
545
546/// Build security headers middleware for production deployments
547///
548/// Applies standard security headers to all HTTP responses:
549/// - Prevents clickjacking (X-Frame-Options)
550/// - Prevents MIME type sniffing (X-Content-Type-Options)
551/// - Enables XSS protection (X-XSS-Protection)
552/// - Controls referrer information (Referrer-Policy)
553/// - Restricts resource loading (Content-Security-Policy)
554///
555/// # Example
556///
557/// ```rust,ignore
558/// use actix_web::App;
559/// use bamboo_agent::server::config::build_security_headers;
560///
561/// let app = App::new()
562///     .wrap(build_security_headers());
563/// ```
564pub fn build_security_headers() -> DefaultHeaders {
565    let csp_override = std::env::var("BAMBOO_CSP").ok();
566    let csp_value = resolve_csp_header_value(csp_override.as_deref());
567
568    DefaultHeaders::new()
569        .add(("X-Frame-Options", "DENY"))
570        .add(("X-Content-Type-Options", "nosniff"))
571        .add(("X-XSS-Protection", "1; mode=block"))
572        .add(("Referrer-Policy", "strict-origin-when-cross-origin"))
573        // Note: customize at runtime via `BAMBOO_CSP` if your frontend requires a relaxed policy.
574        .add((header::CONTENT_SECURITY_POLICY, csp_value))
575}
576
577/// Long-cache content-hashed frontend assets at the proxy/CDN edge.
578///
579/// Vite emits hashed filenames under `/assets/` (e.g. `main-B6snAd4S.css`), so
580/// they are inherently immutable — any content change yields a NEW filename.
581/// Tagging them `immutable, max-age=1y` lets Cloudflare and browsers cache them
582/// at the edge instead of round-tripping every chunk through the tunnel to
583/// origin. Besides being faster, this removes the transient per-asset failures
584/// (an occasional reset of one of many parallel preload requests over a
585/// cloudflared tunnel) that surface in the browser as Vite's
586/// "Unable to preload CSS for …" / "Failed to fetch dynamically imported module".
587///
588/// Only `/assets/*` is affected; `index.html` and API routes are left untouched
589/// so they always serve fresh (a new deploy must be picked up immediately).
590pub async fn add_asset_cache_headers<B: MessageBody + 'static>(
591    req: ServiceRequest,
592    next: Next<B>,
593) -> Result<ServiceResponse<B>, actix_web::Error> {
594    let is_asset = req.path().starts_with("/assets/");
595    let mut res = next.call(req).await?;
596    if is_asset {
597        res.headers_mut().insert(
598            header::CACHE_CONTROL,
599            header::HeaderValue::from_static("public, max-age=31536000, immutable"),
600        );
601    }
602    Ok(res)
603}
604
605/// Build CORS middleware based on bind address and port
606///
607/// Automatically configures CORS policy based on deployment environment:
608///
609/// # Development Mode (localhost)
610///
611/// When binding to `127.0.0.1`, `localhost`, or `::1`:
612/// - Allows all origins, methods, and headers
613/// - Suitable for local development
614/// - Safe because server is only accessible locally
615///
616/// # Docker Production Mode (0.0.0.0)
617///
618/// When binding to `0.0.0.0`:
619/// - Only allows `http://localhost:{port}`
620/// - Requires reverse proxy for external access
621/// - Restrictive CORS for security
622///
623/// # Custom Address
624///
625/// For any other bind address:
626/// - Only allows that specific address
627/// - Most restrictive configuration
628///
629/// # Arguments
630///
631/// * `bind_addr` - The address the server binds to
632/// * `port` - The port number the server listens on
633///
634/// # Example
635///
636/// ```rust,ignore
637/// use actix_web::HttpServer;
638/// use bambooagent::server::config::build_cors;
639///
640/// let cors = build_cors("127.0.0.1", 9562);
641/// // Use cors middleware in HttpServer
642/// ```
643pub fn build_cors(bind_addr: &str, port: u16) -> Cors {
644    let allowlist = parse_cors_allowlist_env();
645
646    let cors = if bind_addr == "127.0.0.1" || bind_addr == "localhost" || bind_addr == "::1" {
647        // Development/Desktop mode. Keep origins permissive for local/Tauri callers, but do not
648        // combine wildcard `Access-Control-Allow-Origin: *` with credentialed requests. The Lotus
649        // client sends `credentials: "include"` so browsers require a concrete echoed Origin.
650        info!("CORS configured for development mode: allowing local/Tauri origins (+ optional allowlist)");
651        Cors::default()
652            .allowed_origin_fn(move |origin, _req_head| {
653                let o = match origin.to_str() {
654                    Ok(v) => v,
655                    Err(_) => return false,
656                };
657
658                if is_allowed_by_allowlist(o, &allowlist) {
659                    return true;
660                }
661
662                if is_local_dev_origin(o) {
663                    return true;
664                }
665
666                o == "tauri://localhost"
667                    || o == "https://tauri.localhost"
668                    || o == "http://tauri.localhost"
669            })
670            .allow_any_method()
671            .allow_any_header()
672            .supports_credentials()
673            .max_age(3600)
674    } else if bind_addr == "0.0.0.0" {
675        // Docker/sidecar mode.
676        //
677        // We still want to restrict origins to "local" callers, but ports and schemes
678        // can differ between:
679        // - Vite dev server (http://127.0.0.1:5173, http://localhost:5173)
680        // - Tauri webview (tauri://localhost, https://tauri.localhost)
681        // - Reverse proxy setups (http://localhost:{port})
682        //
683        // Accept any localhost/loopback origin (any port) and common Tauri origins.
684        info!("CORS configured for 0.0.0.0 bind: allowing localhost/loopback origins (+ optional allowlist)");
685        Cors::default()
686            .allowed_origin_fn(move |origin, _req_head| {
687                let o = match origin.to_str() {
688                    Ok(v) => v,
689                    Err(_) => return false,
690                };
691
692                // Explicit allowlist (for remote UI domains, etc).
693                if is_allowed_by_allowlist(o, &allowlist) {
694                    return true;
695                }
696
697                // Common local HTTP(S) dev origins (any port).
698                if is_local_dev_origin(o) {
699                    return true;
700                }
701
702                // Tauri webview origins (vary by version/config).
703                if o == "tauri://localhost"
704                    || o == "https://tauri.localhost"
705                    || o == "http://tauri.localhost"
706                {
707                    return true;
708                }
709
710                // Some setups might load the UI from the same port as the backend.
711                if o == format!("http://localhost:{port}")
712                    || o == format!("http://127.0.0.1:{port}")
713                {
714                    return true;
715                }
716
717                false
718            })
719            // This server is commonly used as a local relay for multiple upstream clients
720            // (OpenAI/Anthropic/Gemini). Avoid CORS preflight failures by not restricting methods.
721            .allow_any_method()
722            // OpenAI's official JS client sends additional `x-stainless-*` headers which would
723            // otherwise fail preflight; keep headers permissive while origin stays locked down.
724            .allow_any_header()
725            .supports_credentials()
726            .max_age(3600)
727    } else {
728        // Custom bind address - restrictive by default, but allow explicit env allowlist.
729        info!(
730            "CORS configured for custom bind address: {} (+ optional allowlist)",
731            bind_addr
732        );
733        let bind_host = bind_addr.to_ascii_lowercase();
734        let allowlist = allowlist.clone();
735        Cors::default()
736            .allowed_origin_fn(move |origin, _req_head| {
737                let o = match origin.to_str() {
738                    Ok(v) => v,
739                    Err(_) => return false,
740                };
741
742                if is_allowed_by_allowlist(o, &allowlist) {
743                    return true;
744                }
745
746                // Allow same-host origins (any scheme/port) for the bind address itself.
747                // This keeps the default "tight" without requiring users to enumerate ports.
748                let url = match url::Url::parse(o) {
749                    Ok(u) => u,
750                    Err(_) => return false,
751                };
752                let Some(host) = url.host_str() else {
753                    return false;
754                };
755                host.eq_ignore_ascii_case(&bind_host)
756            })
757            .allow_any_method()
758            .allow_any_header()
759            .supports_credentials()
760            .max_age(3600)
761    };
762
763    // Session metadata mutations use the response ETag as their If-Match CAS
764    // token, so cross-origin browser/Tauri clients must be able to read it.
765    cors.expose_headers([header::ETAG])
766}
767
768/// Apply the Governor (rate limiter) + CORS middleware pair to `app`, IN THE
769/// ORDER THAT MATTERS (#169 part 2, #428).
770///
771/// Governor is wrapped first (the INNER layer) and `build_cors` second (the
772/// OUTER layer). This ordering is load-bearing: (1) a genuine CORS preflight
773/// is short-circuited by CORS and never reaches Governor, so it isn't counted
774/// against the rate-limit bucket; and (2) a 429 from Governor propagates back
775/// OUT through CORS, which adds `Access-Control-Allow-Origin` so a browser
776/// receives a readable 429 instead of an opaque network error. Reversing the
777/// two wraps regresses both (see the `governor_inside_cors_*` /
778/// `governor_outside_cors_regression_*` tests below).
779///
780/// This is the ONE place the wrap order is spelled out — every production app
781/// factory (`entrypoints.rs`, `web_service.rs`) AND the ordering-invariant
782/// regression tests call this helper instead of each re-declaring the two
783/// `.wrap()` calls inline. That means a future edit can no longer swap the
784/// order in just one of those call sites without also changing this function,
785/// and changing this function is exactly what the regression tests cover
786/// (#428 — prior to this, the tests built their own hand-rolled `App` with the
787/// order spelled out separately from production, so a production-only swap
788/// would NOT have failed them).
789pub fn wrap_governor_and_cors<T, B>(
790    app: App<T>,
791    rate_limiter: &RateLimiterConfig<ClientIpKeyExtractor>,
792    apply_rate_limit: bool,
793    bind_addr: &str,
794    port: u16,
795) -> App<
796    impl ServiceFactory<
797        ServiceRequest,
798        Config = (),
799        Response = ServiceResponse<impl MessageBody>,
800        Error = actix_web::Error,
801        InitError = (),
802    >,
803>
804where
805    T: ServiceFactory<
806            ServiceRequest,
807            Config = (),
808            Response = ServiceResponse<B>,
809            Error = actix_web::Error,
810            InitError = (),
811        > + 'static,
812    B: MessageBody + 'static,
813{
814    app.wrap(Condition::new(
815        apply_rate_limit,
816        RateLimit::new(rate_limiter),
817    ))
818    .wrap(build_cors(bind_addr, port))
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    #[test]
826    fn rate_limiter_config_clamps_degenerate_values() {
827        // 0 per_second / 0 burst would make finish() reject; the clamps keep it
828        // valid (no panic).
829        let _ = rate_limiter_config(0, 0, ClientIpKeyExtractor::peer_ip());
830        let _ = rate_limiter_config(1000, 1, ClientIpKeyExtractor::peer_ip());
831    }
832
833    #[test]
834    fn loopback_binds_skip_rate_limiter() {
835        // Desktop sidecar binds must be exempt (frontend bursts asset requests);
836        // network-exposed binds must stay throttled.
837        for b in ["127.0.0.1", "localhost", "::1"] {
838            assert!(
839                is_loopback_bind(b),
840                "{b} should be loopback (limiter skipped)"
841            );
842        }
843        for b in ["0.0.0.0", "192.168.1.10", "::"] {
844            assert!(!is_loopback_bind(b), "{b} should be throttled");
845        }
846    }
847
848    #[test]
849    fn loopback_binds_recognizes_full_loopback_range_and_bracketed_ipv6() {
850        // #428: a strict `127.0.0.1`/`localhost`/`::1` string allowlist
851        // misclassifies other loopback forms as non-loopback (failing safe,
852        // but wrong). Classifying via `IpAddr::is_loopback` fixes it.
853        for b in ["127.0.0.2", "127.255.255.255", "[::1]", "LOCALHOST"] {
854            assert!(is_loopback_bind(b), "{b} is a loopback address/host");
855        }
856    }
857
858    #[actix_web::test]
859    async fn asset_cache_headers_only_tag_hashed_assets() {
860        use actix_web::http::header::CACHE_CONTROL;
861        use actix_web::{test, web, App, HttpResponse};
862
863        let app = test::init_service(
864            App::new()
865                .wrap(actix_web::middleware::from_fn(add_asset_cache_headers))
866                .route(
867                    "/assets/main-abc123.css",
868                    web::get().to(|| async { HttpResponse::Ok().finish() }),
869                )
870                .route(
871                    "/index.html",
872                    web::get().to(|| async { HttpResponse::Ok().finish() }),
873                ),
874        )
875        .await;
876
877        // A hashed `/assets/*` file gets the immutable long-cache header.
878        let req = test::TestRequest::get()
879            .uri("/assets/main-abc123.css")
880            .to_request();
881        let res = test::call_service(&app, req).await;
882        assert_eq!(
883            res.headers()
884                .get(CACHE_CONTROL)
885                .and_then(|v| v.to_str().ok()),
886            Some("public, max-age=31536000, immutable"),
887        );
888
889        // `index.html` (and anything outside `/assets/`) must stay fresh so a new
890        // deploy is picked up immediately — no long-cache header added.
891        let req = test::TestRequest::get().uri("/index.html").to_request();
892        let res = test::call_service(&app, req).await;
893        assert!(
894            res.headers().get(CACHE_CONTROL).is_none(),
895            "non-asset routes must not be long-cached"
896        );
897    }
898
899    #[actix_web::test]
900    async fn rate_limiter_throttles_with_429_after_burst() {
901        use crate::rate_limit::RateLimit;
902        use actix_web::http::StatusCode;
903        use actix_web::{test, web, App, HttpResponse};
904        use std::net::{IpAddr, Ipv4Addr, SocketAddr};
905
906        // burst=2: the first two requests from an IP pass, the rest are throttled.
907        let conf = rate_limiter_config(1, 2, ClientIpKeyExtractor::peer_ip());
908        let app = test::init_service(
909            App::new()
910                .wrap(RateLimit::new(&conf))
911                .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
912        )
913        .await;
914
915        let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 9999);
916        let (mut saw_ok, mut saw_429) = (false, false);
917        for _ in 0..6 {
918            let req = test::TestRequest::get().uri("/").peer_addr(ip).to_request();
919            match test::call_service(&app, req).await.status() {
920                StatusCode::OK => saw_ok = true,
921                StatusCode::TOO_MANY_REQUESTS => saw_429 = true,
922                other => panic!("unexpected status {other}"),
923            }
924        }
925        assert!(saw_ok, "requests within the burst must pass");
926        assert!(saw_429, "requests beyond the burst must be 429'd (#13)");
927
928        // A DIFFERENT client IP has its OWN bucket — proving per-IP keying (a
929        // global bucket would 429 this too); guards against a regression to a
930        // global key extractor.
931        let other_ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 8888);
932        let req = test::TestRequest::get()
933            .uri("/")
934            .peer_addr(other_ip)
935            .to_request();
936        assert_eq!(
937            test::call_service(&app, req).await.status(),
938            StatusCode::OK,
939            "a different IP gets its own fresh bucket (per-IP, not global)"
940        );
941    }
942
943    #[actix_web::test]
944    async fn key_extractor_default_ignores_xff_and_uses_peer_ip() {
945        use actix_web::test;
946        use std::net::{Ipv4Addr, SocketAddr};
947
948        // Default (trust_xff = false): a client-supplied XFF must be ignored so it
949        // can't spoof its rate-limit key on a directly-exposed server.
950        let ke = ClientIpKeyExtractor::peer_ip();
951        let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 5000);
952        let req = test::TestRequest::get()
953            .peer_addr(peer)
954            .insert_header(("x-forwarded-for", "1.2.3.4"))
955            .to_srv_request();
956        assert_eq!(
957            ke.extract(&req).unwrap(),
958            IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))
959        );
960    }
961
962    #[actix_web::test]
963    async fn key_extractor_xff_uses_rightmost_at_one_hop_not_client_prefix() {
964        use actix_web::test;
965        use std::net::{Ipv4Addr, SocketAddr};
966
967        // trusted_hops = 1: only the entry OUR proxy appended (rightmost) is
968        // trusted; a client prepending a fake IP can't change the key.
969        let ke = ClientIpKeyExtractor {
970            trust_xff: true,
971            trusted_hops: 1,
972        };
973        let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000); // proxy
974        let req = test::TestRequest::get()
975            .peer_addr(peer)
976            .insert_header(("x-forwarded-for", "1.1.1.1, 2.2.2.2"))
977            .to_srv_request();
978        assert_eq!(
979            ke.extract(&req).unwrap(),
980            IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
981        );
982    }
983
984    #[actix_web::test]
985    async fn key_extractor_xff_two_hops_takes_second_from_right() {
986        use actix_web::test;
987        use std::net::{Ipv4Addr, SocketAddr};
988
989        let ke = ClientIpKeyExtractor {
990            trust_xff: true,
991            trusted_hops: 2,
992        };
993        let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
994        let req = test::TestRequest::get()
995            .peer_addr(peer)
996            .insert_header(("x-forwarded-for", "1.1.1.1, 2.2.2.2, 3.3.3.3"))
997            .to_srv_request();
998        assert_eq!(
999            ke.extract(&req).unwrap(),
1000            IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
1001        );
1002    }
1003
1004    #[actix_web::test]
1005    async fn key_extractor_xff_fails_closed_to_peer_when_header_too_short_or_absent() {
1006        use actix_web::test;
1007        use std::net::{Ipv4Addr, SocketAddr};
1008
1009        let ke = ClientIpKeyExtractor {
1010            trust_xff: true,
1011            trusted_hops: 2,
1012        };
1013        let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
1014
1015        // Fewer entries than trusted hops → not a trusted-proxy shape → peer IP.
1016        let short = test::TestRequest::get()
1017            .peer_addr(peer)
1018            .insert_header(("x-forwarded-for", "9.9.9.9"))
1019            .to_srv_request();
1020        assert_eq!(
1021            ke.extract(&short).unwrap(),
1022            IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
1023        );
1024
1025        // No XFF at all → peer IP.
1026        let none = test::TestRequest::get().peer_addr(peer).to_srv_request();
1027        assert_eq!(
1028            ke.extract(&none).unwrap(),
1029            IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
1030        );
1031    }
1032
1033    #[actix_web::test]
1034    async fn key_extractor_xff_flattens_multiple_header_lines_in_order() {
1035        use actix_web::test;
1036        use std::net::{Ipv4Addr, SocketAddr};
1037
1038        // A proxy chain that appends a SECOND header line rather than extending
1039        // the comma-joined value: the entries must be treated as one ordered list
1040        // (client-first ... proxy-last), so 1-hop still selects the true rightmost
1041        // entry authored by the nearest proxy — not the first line's value.
1042        let ke = ClientIpKeyExtractor {
1043            trust_xff: true,
1044            trusted_hops: 1,
1045        };
1046        let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000);
1047        let req = test::TestRequest::get()
1048            .peer_addr(peer)
1049            .append_header(("x-forwarded-for", "1.1.1.1"))
1050            .append_header(("x-forwarded-for", "2.2.2.2"))
1051            .to_srv_request();
1052        assert_eq!(
1053            ke.extract(&req).unwrap(),
1054            IpAddr::V4(Ipv4Addr::new(2, 2, 2, 2))
1055        );
1056    }
1057
1058    #[test]
1059    fn parse_forwarded_ip_handles_bare_port_and_bracketed_forms() {
1060        use std::net::{Ipv4Addr, Ipv6Addr};
1061
1062        assert_eq!(
1063            parse_forwarded_ip("1.2.3.4"),
1064            Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
1065        );
1066        assert_eq!(
1067            parse_forwarded_ip("1.2.3.4:5678"),
1068            Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
1069        );
1070        assert_eq!(
1071            parse_forwarded_ip("[::1]:9000"),
1072            Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
1073        );
1074        assert_eq!(
1075            parse_forwarded_ip("[::1]"),
1076            Some(IpAddr::V6(Ipv6Addr::LOCALHOST))
1077        );
1078        assert_eq!(parse_forwarded_ip("not-an-ip"), None);
1079    }
1080
1081    #[test]
1082    fn mask_ipv6_prefix_zeroes_lower_bytes_and_leaves_ipv4() {
1083        use std::net::{Ipv4Addr, Ipv6Addr};
1084
1085        let v4 = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
1086        assert_eq!(mask_ipv6_prefix(v4), v4);
1087
1088        let v6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6));
1089        // /56: first 7 bytes preserved, remaining 9 zeroed.
1090        assert_eq!(
1091            mask_ipv6_prefix(v6),
1092            IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0))
1093        );
1094    }
1095
1096    // --- #169 part 2: preflight/CORS-safe 429 -----------------------------------
1097    //
1098    // These build the REAL production wrap order (Governor registered *before*
1099    // CORS, i.e. Governor is the INNER wrap and CORS is OUTER) and a browser
1100    // request, then assert what a browser actually receives. `probe!` drains the
1101    // burst with `gets` GETs (allowed Origin) then sends one CORS preflight,
1102    // yielding `(last_get_status, last_get_has_acao, preflight_status)`.
1103    macro_rules! probe_cors_and_preflight {
1104        ($app:expr, $ip:expr, $origin:expr, $gets:expr) => {{
1105            use actix_web::http::header;
1106            use actix_web::test;
1107
1108            let mut status = actix_web::http::StatusCode::OK;
1109            let mut has_acao = false;
1110            for _ in 0..$gets {
1111                let res = test::call_service(
1112                    &$app,
1113                    test::TestRequest::get()
1114                        .uri("/")
1115                        .peer_addr($ip)
1116                        .insert_header((header::ORIGIN, $origin))
1117                        .to_request(),
1118                )
1119                .await;
1120                status = res.status();
1121                has_acao = res
1122                    .headers()
1123                    .contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN);
1124            }
1125
1126            let pre = test::call_service(
1127                &$app,
1128                test::TestRequest::default()
1129                    .method(actix_web::http::Method::OPTIONS)
1130                    .uri("/")
1131                    .peer_addr($ip)
1132                    .insert_header((header::ORIGIN, $origin))
1133                    .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "GET"))
1134                    .to_request(),
1135            )
1136            .await;
1137
1138            (status, has_acao, pre.status())
1139        }};
1140    }
1141
1142    /// A browser can only read the session CAS token when CORS explicitly
1143    /// exposes `ETag`. Exercise the real session route behind the production
1144    /// CORS middleware for every bind-mode branch, while keeping the existing
1145    /// permissive request-header policy available to `If-Match` preflights.
1146    #[actix_web::test]
1147    async fn cors_exposes_session_etag_for_every_bind_mode() {
1148        use crate::routes::configure_routes;
1149        use crate::AppState;
1150        use actix_web::http::{header, Method, StatusCode};
1151        use actix_web::{test, web, App};
1152        use bamboo_agent_core::Session;
1153        use tempfile::tempdir;
1154
1155        let temp_dir = tempdir().expect("tempdir");
1156        bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
1157        let state = web::Data::new(
1158            AppState::new(temp_dir.path().to_path_buf())
1159                .await
1160                .expect("app state"),
1161        );
1162        let session_id = "cors-etag-session";
1163        let mut session = Session::new(session_id, "model");
1164        state.save_and_cache_session(&mut session).await;
1165
1166        for (bind_addr, origin) in [
1167            ("127.0.0.1", "http://127.0.0.1:1420"),
1168            ("0.0.0.0", "http://127.0.0.1:1420"),
1169            ("192.0.2.10", "http://192.0.2.10:1420"),
1170        ] {
1171            let app = test::init_service(
1172                App::new()
1173                    .app_data(state.clone())
1174                    .wrap(build_cors(bind_addr, 9562))
1175                    .configure(configure_routes),
1176            )
1177            .await;
1178
1179            let response = test::call_service(
1180                &app,
1181                test::TestRequest::get()
1182                    .uri(&format!("/api/v1/sessions/{session_id}"))
1183                    .insert_header((header::ORIGIN, origin))
1184                    .to_request(),
1185            )
1186            .await;
1187            assert_eq!(response.status(), StatusCode::OK, "bind {bind_addr}");
1188            assert_eq!(
1189                response
1190                    .headers()
1191                    .get(header::ETAG)
1192                    .and_then(|value| value.to_str().ok()),
1193                Some("\"0\""),
1194                "the real session response must still carry its CAS token for bind {bind_addr}"
1195            );
1196
1197            let exposed = response
1198                .headers()
1199                .get(header::ACCESS_CONTROL_EXPOSE_HEADERS)
1200                .and_then(|value| value.to_str().ok())
1201                .unwrap_or_default()
1202                .split(',')
1203                .map(str::trim)
1204                .filter(|value| !value.is_empty())
1205                .collect::<Vec<_>>();
1206            assert!(
1207                exposed
1208                    .iter()
1209                    .any(|value| value.eq_ignore_ascii_case("etag")),
1210                "ETag must be browser-readable for bind {bind_addr}; exposed={exposed:?}"
1211            );
1212            assert_eq!(
1213                exposed.len(),
1214                1,
1215                "do not broadly expose unrelated response headers for bind {bind_addr}"
1216            );
1217
1218            let preflight = test::call_service(
1219                &app,
1220                test::TestRequest::default()
1221                    .method(Method::OPTIONS)
1222                    .uri(&format!("/api/v1/sessions/{session_id}"))
1223                    .insert_header((header::ORIGIN, origin))
1224                    .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "PATCH"))
1225                    .insert_header((
1226                        header::ACCESS_CONTROL_REQUEST_HEADERS,
1227                        "content-type, if-match",
1228                    ))
1229                    .to_request(),
1230            )
1231            .await;
1232            assert_eq!(
1233                preflight.status(),
1234                StatusCode::OK,
1235                "existing PATCH preflight behavior must remain intact for bind {bind_addr}"
1236            );
1237            let allowed_headers = preflight
1238                .headers()
1239                .get(header::ACCESS_CONTROL_ALLOW_HEADERS)
1240                .and_then(|value| value.to_str().ok())
1241                .unwrap_or_default();
1242            assert!(
1243                allowed_headers
1244                    .split(',')
1245                    .any(|value| value.trim().eq_ignore_ascii_case("if-match")),
1246                "If-Match must remain allowed for bind {bind_addr}; allowed={allowed_headers}"
1247            );
1248        }
1249
1250        state.shutdown().await;
1251    }
1252
1253    /// The production order — enforced by the shared [`wrap_governor_and_cors`]
1254    /// helper (Governor inner, CORS outer) and used by BOTH the real app
1255    /// factories (`entrypoints.rs`, `web_service.rs`) and this test — must give
1256    /// a browser a READABLE 429: the throttled response carries
1257    /// `Access-Control-Allow-Origin`, and a CORS preflight is NOT counted
1258    /// against the bucket (CORS answers it before it reaches Governor).
1259    ///
1260    /// Because this test calls the SAME helper the production factories call
1261    /// (rather than hand-rolling the wrap order itself, #428), a future edit
1262    /// that swaps the wrap order in `wrap_governor_and_cors` — the only place
1263    /// production spells the order out — fails this test.
1264    #[actix_web::test]
1265    async fn governor_inside_cors_makes_429_cors_readable_and_exempts_preflight() {
1266        use actix_web::http::StatusCode;
1267        use actix_web::{test, web, App, HttpResponse};
1268        use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1269
1270        // burst=1: the 2nd GET from an IP is throttled.
1271        let conf = rate_limiter_config(1, 1, ClientIpKeyExtractor::peer_ip());
1272        let app = test::init_service(
1273            wrap_governor_and_cors(
1274                App::new(),
1275                &conf,
1276                /* apply_rate_limit */ true,
1277                "0.0.0.0",
1278                9562,
1279            )
1280            .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
1281        )
1282        .await;
1283
1284        let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 9999);
1285        let (get_status, get_has_acao, preflight_status) =
1286            probe_cors_and_preflight!(app, ip, "http://localhost:5173", 2);
1287
1288        assert_eq!(
1289            get_status,
1290            StatusCode::TOO_MANY_REQUESTS,
1291            "the 2nd GET past the burst must be throttled (#13 guarantee intact)"
1292        );
1293        assert!(
1294            get_has_acao,
1295            "a 429 must carry Access-Control-Allow-Origin so a browser sees a readable 429, \
1296             not an opaque network error (#169 part 2)"
1297        );
1298        assert_ne!(
1299            preflight_status,
1300            StatusCode::TOO_MANY_REQUESTS,
1301            "a CORS preflight must NOT be throttled — it never reaches Governor (#169 part 2)"
1302        );
1303    }
1304
1305    /// Guards the ordering as load-bearing: the REVERSED order
1306    /// (`.wrap(build_cors).wrap(Governor)` → Governor OUTSIDE CORS) is the pre-fix
1307    /// state that motivated #169 — a 429 escapes without CORS headers and the
1308    /// preflight is throttled. If a refactor ever flips the wrap order back
1309    /// inside [`wrap_governor_and_cors`], the positive test above breaks; this
1310    /// test intentionally does NOT use that helper — it hand-rolls the WRONG
1311    /// order to document *why* the order matters, by asserting the broken
1312    /// behavior that would result.
1313    #[actix_web::test]
1314    async fn governor_outside_cors_regression_drops_cors_and_throttles_preflight() {
1315        use crate::rate_limit::RateLimit;
1316        use actix_web::http::StatusCode;
1317        use actix_web::{test, web, App, HttpResponse};
1318        use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1319
1320        let conf = rate_limiter_config(1, 1, ClientIpKeyExtractor::peer_ip());
1321        let app = test::init_service(
1322            App::new()
1323                .wrap(build_cors("0.0.0.0", 9562)) // inner (WRONG)
1324                .wrap(RateLimit::new(&conf)) // outer (WRONG)
1325                .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
1326        )
1327        .await;
1328
1329        let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 8)), 9999);
1330        let (get_status, get_has_acao, preflight_status) =
1331            probe_cors_and_preflight!(app, ip, "http://localhost:5173", 2);
1332
1333        assert_eq!(
1334            get_status,
1335            StatusCode::TOO_MANY_REQUESTS,
1336            "still a 429 in the wrong order..."
1337        );
1338        assert!(
1339            !get_has_acao,
1340            "...but WITHOUT CORS headers — the browser-opaque failure #169 part 2 fixes"
1341        );
1342        assert_eq!(
1343            preflight_status,
1344            StatusCode::TOO_MANY_REQUESTS,
1345            "and the preflight IS throttled in the wrong order (counted against the bucket)"
1346        );
1347    }
1348
1349    // --- #169 part 3: bind-aware limiter guard ----------------------------------
1350
1351    #[test]
1352    fn require_limiter_rejects_nonloopback_without_limiter() {
1353        // The dangerous combination: a routable bind with no limiter → rejected.
1354        for b in ["0.0.0.0", "192.168.1.10", "::"] {
1355            assert!(
1356                require_limiter_for_nonloopback(b, false).is_err(),
1357                "{b} without a limiter must be rejected (#169 part 3)"
1358            );
1359        }
1360    }
1361
1362    #[test]
1363    fn require_limiter_allows_loopback_and_limited_binds() {
1364        // Loopback with no limiter is preserved (desktop sidecar, intentionally
1365        // un-throttled) — the guard must NOT weaken it.
1366        for b in ["127.0.0.1", "localhost", "::1"] {
1367            assert!(
1368                require_limiter_for_nonloopback(b, false).is_ok(),
1369                "{b} loopback must stay allowed without a limiter (desktop behavior)"
1370            );
1371        }
1372        // A non-loopback bind IS allowed once a limiter is applied.
1373        for b in ["0.0.0.0", "192.168.1.10"] {
1374            assert!(
1375                require_limiter_for_nonloopback(b, true).is_ok(),
1376                "{b} with a limiter applied must be allowed"
1377            );
1378        }
1379    }
1380
1381    #[test]
1382    fn default_csp_keeps_scripts_strict_but_allows_inline_styles() {
1383        assert!(DEFAULT_CSP.contains("script-src 'self'"));
1384        assert!(DEFAULT_CSP.contains("style-src 'self' 'unsafe-inline'"));
1385        assert!(!DEFAULT_CSP.contains("unsafe-eval"));
1386    }
1387
1388    #[test]
1389    fn connect_src_append_normalizes_explicit_origins() {
1390        let sources = parse_csp_connect_src_append(
1391            "https://bodhi.bigduu.com:9562, http://bodhi.bigduu.com:9562/",
1392        );
1393        assert_eq!(
1394            sources,
1395            vec![
1396                "https://bodhi.bigduu.com:9562".to_string(),
1397                "http://bodhi.bigduu.com:9562".to_string(),
1398            ]
1399        );
1400    }
1401
1402    #[test]
1403    fn append_connect_src_sources_extends_default_csp() {
1404        let csp = append_connect_src_sources(
1405            DEFAULT_CSP,
1406            &[
1407                "https://bodhi.bigduu.com:9562".to_string(),
1408                "http://bodhi.bigduu.com:9562".to_string(),
1409            ],
1410        );
1411
1412        assert!(csp.contains("connect-src 'self' ws: wss:"));
1413        assert!(csp.contains("https://bodhi.bigduu.com:9562"));
1414        assert!(csp.contains("http://bodhi.bigduu.com:9562"));
1415    }
1416
1417    #[test]
1418    fn invalid_override_falls_back_to_default() {
1419        // Header values cannot contain newlines.
1420        let v = resolve_csp_header_value(Some("default-src 'self'\nscript-src 'self'"));
1421        let rendered = v.to_str().expect("header should be valid utf-8");
1422        assert!(rendered.contains("connect-src 'self' ws: wss:"));
1423        assert!(rendered.contains("http://127.0.0.1:*"));
1424        assert!(rendered.contains("http://localhost:*"));
1425        assert!(rendered.contains("http://bodhi.bigduu.com:9562"));
1426        assert!(rendered.contains("https://bodhi.bigduu.com:9562"));
1427        assert!(rendered.contains("style-src 'self' 'unsafe-inline'"));
1428    }
1429
1430    #[test]
1431    fn cors_allowlist_parses_hosts_and_origins() {
1432        let allow = parse_cors_allowlist(
1433            "https://app.example.com/, app.example2.com, *.example.net , http://localhost:5173",
1434        );
1435        assert!(allow.exact_origins.contains("https://app.example.com"));
1436        assert!(allow.exact_origins.contains("http://localhost:5173"));
1437        assert!(allow
1438            .hosts
1439            .contains(&HostPattern::Exact("app.example2.com".to_string())));
1440        assert!(allow
1441            .hosts
1442            .contains(&HostPattern::Suffix(".example.net".to_string())));
1443    }
1444
1445    #[test]
1446    fn cors_allowlist_matches_exact_and_wildcard_hosts() {
1447        let mut allow = CorsAllowlist::default();
1448        allow
1449            .exact_origins
1450            .insert("https://app.example.com".to_string());
1451        allow
1452            .hosts
1453            .push(HostPattern::Exact("app2.example.com".to_string()));
1454        allow
1455            .hosts
1456            .push(HostPattern::Suffix(".example.net".to_string()));
1457
1458        assert!(is_allowed_by_allowlist("https://app.example.com", &allow));
1459        assert!(is_allowed_by_allowlist(
1460            "https://app.example.com:443",
1461            &allow
1462        ));
1463        assert!(is_allowed_by_allowlist(
1464            "http://app2.example.com:5173",
1465            &allow
1466        ));
1467        assert!(is_allowed_by_allowlist("https://x.example.net", &allow));
1468        assert!(!is_allowed_by_allowlist("https://example.net", &allow));
1469        assert!(!is_allowed_by_allowlist("https://evil.com", &allow));
1470    }
1471
1472    #[test]
1473    fn local_dev_origin_allows_mac_local_and_bodhi_domain() {
1474        assert!(is_local_dev_origin("http://mac.local:1420"));
1475        assert!(is_local_dev_origin("https://mac.local:1420"));
1476        assert!(is_local_dev_origin("http://bodhi.bigduu.com:9562"));
1477        assert!(is_local_dev_origin("https://bodhi.bigduu.com:9562"));
1478        assert!(!is_local_dev_origin("http://evil.com:1420"));
1479    }
1480}