Skip to main content

omni_dev/browser/
auth.rs

1//! Authentication and request-guard primitives for the browser bridge.
2//!
3//! The trust boundary, stated once: **a request is trusted only if it presents
4//! the session token AND is not a cross-origin browser request; everything else
5//! is denied.** A localhost bind is necessary but not sufficient — it stops
6//! off-host access but not other local users/processes, nor web pages the
7//! operator visits. See [ADR-0036](../../docs/adrs/adr-0036.md).
8//!
9//! The functions here are deliberately small and operate on borrowed primitives
10//! rather than framework types so the security checks can be unit-tested in
11//! isolation from axum / tungstenite.
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::Path;
15
16use anyhow::{bail, Context, Result};
17use base64::Engine;
18use rand::Rng;
19
20use crate::utils::env::{EnvSource, SystemEnv};
21
22/// Environment variable an operator may use to pin the session token instead of
23/// letting the bridge generate one. Never read from argv (`ps`/`/proc` expose
24/// it).
25pub const TOKEN_ENV: &str = "OMNI_BRIDGE_TOKEN";
26
27/// Custom header every control-plane request must carry. A custom header forces
28/// a CORS preflight that the server refuses, blocking simple-request CSRF.
29pub const BRIDGE_HEADER: &str = "x-omni-bridge";
30
31/// Required value of [`BRIDGE_HEADER`].
32pub const BRIDGE_HEADER_VALUE: &str = "1";
33
34/// Optional header selecting which connected tab a control-plane request targets.
35///
36/// The value is a connection id, or an `Origin` that uniquely matches one tab. It
37/// takes precedence over a `target` body field, and is stripped before forwarding.
38pub const BRIDGE_TARGET_HEADER: &str = "x-omni-bridge-target";
39
40/// Optional header carrying the originating client's request-log `invocation_id`.
41///
42/// Threaded so the bridge can correlate the HTTP records it writes while serving
43/// a request back to the CLI/MCP invocation that issued it, rather than the
44/// bridge's own (#1198). Non-secret, server-side only — never forwarded to the
45/// browser.
46pub const BRIDGE_ORIGIN_HEADER: &str = "x-omni-bridge-origin";
47
48/// Number of random bytes behind a generated token (URL-safe base64, no pad).
49const TOKEN_BYTES: usize = 32;
50
51/// Generates a fresh random session token (256 bits, URL-safe base64).
52#[must_use]
53pub fn generate_token() -> String {
54    let mut bytes = [0u8; TOKEN_BYTES];
55    rand::rng().fill_bytes(&mut bytes);
56    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
57}
58
59/// Resolves the session token, in priority order:
60///
61/// 1. `--token-file` — read its trimmed contents. On Unix the file must be
62///    `0600` (owner-only) or resolution fails closed.
63/// 2. `OMNI_BRIDGE_TOKEN` — read from the environment.
64/// 3. Otherwise a fresh token is generated.
65///
66/// The token is **never** accepted from argv.
67pub fn resolve_token(token_file: Option<&Path>) -> Result<String> {
68    resolve_token_with(&SystemEnv, token_file)
69}
70
71/// [`resolve_token`] over an injected [`EnvSource`], so the `OMNI_BRIDGE_TOKEN`
72/// branch is tested without mutating the process environment (issue #1030).
73pub(crate) fn resolve_token_with(
74    env: &impl EnvSource,
75    token_file: Option<&Path>,
76) -> Result<String> {
77    if let Some(path) = token_file {
78        return read_token_file(path);
79    }
80    if let Some(value) = env.var(TOKEN_ENV) {
81        let trimmed = value.trim();
82        if !trimmed.is_empty() {
83            return Ok(trimmed.to_string());
84        }
85    }
86    Ok(generate_token())
87}
88
89/// Resolves an *existing* session token for a client.
90///
91/// Used by the `request` subcommand: `--token-file` then `OMNI_BRIDGE_TOKEN`.
92/// Unlike [`resolve_token`], it never generates one — a client must use the
93/// token the running bridge printed.
94pub fn resolve_existing_token(token_file: Option<&Path>) -> Result<String> {
95    resolve_existing_token_with(&SystemEnv, token_file)
96}
97
98/// [`resolve_existing_token`] over an injected [`EnvSource`]; never generates a
99/// token. Tests pass a `MapEnv` rather than mutating the process environment.
100pub(crate) fn resolve_existing_token_with(
101    env: &impl EnvSource,
102    token_file: Option<&Path>,
103) -> Result<String> {
104    if let Some(path) = token_file {
105        return read_token_file(path);
106    }
107    match env.var(TOKEN_ENV) {
108        Some(value) if !value.trim().is_empty() => Ok(value.trim().to_string()),
109        _ => bail!(
110            "No session token found. Set {TOKEN_ENV} or pass --token-file with the token the \
111             running bridge printed."
112        ),
113    }
114}
115
116fn read_token_file(path: &Path) -> Result<String> {
117    #[cfg(unix)]
118    {
119        use std::os::unix::fs::PermissionsExt;
120        let meta = std::fs::metadata(path)
121            .with_context(|| format!("Failed to stat token file {}", path.display()))?;
122        let mode = meta.permissions().mode() & 0o777;
123        if mode & 0o077 != 0 {
124            bail!(
125                "Token file {} must be 0600 (owner-only); found {:o}",
126                path.display(),
127                mode
128            );
129        }
130    }
131    let contents = std::fs::read_to_string(path)
132        .with_context(|| format!("Failed to read token file {}", path.display()))?;
133    let trimmed = contents.trim();
134    if trimmed.is_empty() {
135        bail!("Token file {} is empty", path.display());
136    }
137    Ok(trimmed.to_string())
138}
139
140/// Constant-time string comparison, to avoid leaking the token via timing.
141#[must_use]
142pub fn constant_time_eq(a: &str, b: &str) -> bool {
143    let (a, b) = (a.as_bytes(), b.as_bytes());
144    if a.len() != b.len() {
145        return false;
146    }
147    let mut diff = 0u8;
148    for (x, y) in a.iter().zip(b.iter()) {
149        diff |= x ^ y;
150    }
151    diff == 0
152}
153
154/// Checks an `Authorization` header value against the expected token.
155///
156/// Accepts only `Bearer <token>` with a constant-time token comparison.
157#[must_use]
158pub fn bearer_matches(authorization: Option<&str>, token: &str) -> bool {
159    let Some(value) = authorization else {
160        return false;
161    };
162    let Some(presented) = value.strip_prefix("Bearer ") else {
163        return false;
164    };
165    constant_time_eq(presented.trim(), token)
166}
167
168/// Whether the request carries the mandatory `X-Omni-Bridge: 1` header.
169#[must_use]
170pub fn has_bridge_header(value: Option<&str>) -> bool {
171    value.is_some_and(|v| v.trim() == BRIDGE_HEADER_VALUE)
172}
173
174/// Whether `host` is an allowed loopback authority for `control_port`.
175///
176/// Blocks DNS rebinding: only the exact `localhost:<port>` / `127.0.0.1:<port>`
177/// (and the IPv6 loopback) authorities are accepted.
178#[must_use]
179pub fn host_allowed(host: &str, control_port: u16) -> bool {
180    let allowed = [
181        format!("localhost:{control_port}"),
182        format!("127.0.0.1:{control_port}"),
183        format!("[::1]:{control_port}"),
184    ];
185    allowed.iter().any(|a| a == host)
186}
187
188/// Whether a request looks browser-originated and must therefore be denied.
189///
190/// A legitimate CLI client sends neither an `Origin` header nor
191/// `Sec-Fetch-Site: cross-site`/`same-site`. Any such header marks a request a
192/// web page made, which the control plane refuses.
193#[must_use]
194pub fn is_browser_originated(origin: Option<&str>, sec_fetch_site: Option<&str>) -> bool {
195    if origin.is_some() {
196        return true;
197    }
198    matches!(
199        sec_fetch_site.map(str::trim),
200        Some("cross-site" | "same-site" | "same-origin")
201    )
202}
203
204/// Rejects header names/values that could smuggle a second header or request
205/// line via CR/LF (or other control characters).
206#[must_use]
207pub fn header_is_safe(name: &str, value: &str) -> bool {
208    let bad = |s: &str| s.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0);
209    !name.is_empty() && !bad(name) && !bad(value)
210}
211
212/// Normalises a decoded request path, rejecting traversal and control
213/// characters. Run **before** the `/__bridge/` routing/auth split so a
214/// percent-encoded segment cannot bypass the prefix check.
215///
216/// Returns the percent-decoded path, or `None` if it is unsafe.
217#[must_use]
218pub fn normalize_request_path(raw: &str) -> Option<String> {
219    let decoded = percent_decode(raw)?;
220    if decoded.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
221        return None;
222    }
223    // Reject any `..` path segment (traversal).
224    if decoded
225        .split('/')
226        .any(|seg| seg == ".." || seg == "..%2f" || seg == "..%2F")
227    {
228        return None;
229    }
230    Some(decoded)
231}
232
233/// Minimal percent-decoder for request paths. Returns `None` on malformed
234/// escapes or non-UTF-8 results.
235fn percent_decode(raw: &str) -> Option<String> {
236    let bytes = raw.as_bytes();
237    let mut out = Vec::with_capacity(bytes.len());
238    let mut i = 0;
239    while i < bytes.len() {
240        match bytes[i] {
241            b'%' => {
242                let hi = bytes.get(i + 1).copied().and_then(hex_val)?;
243                let lo = bytes.get(i + 2).copied().and_then(hex_val)?;
244                out.push(hi << 4 | lo);
245                i += 3;
246            }
247            b => {
248                out.push(b);
249                i += 1;
250            }
251        }
252    }
253    String::from_utf8(out).ok()
254}
255
256fn hex_val(b: u8) -> Option<u8> {
257    match b {
258        b'0'..=b'9' => Some(b - b'0'),
259        b'a'..=b'f' => Some(b - b'a' + 10),
260        b'A'..=b'F' => Some(b - b'A' + 10),
261        _ => None,
262    }
263}
264
265/// Reason an outbound URL was rejected by [`validate_outbound_url`].
266#[derive(Debug, PartialEq, Eq)]
267pub enum ScopeError {
268    /// The URL is absolute/cross-origin and no `--allow-origin` permits it.
269    CrossOriginDenied,
270    /// The URL could not be parsed or is otherwise malformed.
271    Malformed,
272}
273
274/// Enforces the default-closed outbound scope.
275///
276/// Relative URLs (page-origin) are always allowed. Absolute or
277/// protocol-relative URLs are rejected unless their origin matches one of the
278/// `allowed` origins. An empty `allowed` slice permits relative URLs only.
279///
280/// The caller resolves `allowed`: the per-request override (a single origin) or
281/// the per-origin allowlist entry for the tab a request is routed to (see
282/// [`OriginAllowlist::outbound_for`]).
283pub fn validate_outbound_url(url: &str, allowed: &[&str]) -> Result<(), ScopeError> {
284    // Protocol-relative (`//host/...`) is cross-origin.
285    let is_relative = url.starts_with('/') && !url.starts_with("//");
286    if is_relative {
287        if url.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
288            return Err(ScopeError::Malformed);
289        }
290        return Ok(());
291    }
292
293    // No grant covers an absolute URL — reject before parsing (a malformed
294    // absolute URL with no allowance is still simply cross-origin-denied).
295    if allowed.is_empty() {
296        return Err(ScopeError::CrossOriginDenied);
297    }
298    let target = url::Url::parse(url).map_err(|_| ScopeError::Malformed)?;
299    let permitted = allowed
300        .iter()
301        .filter_map(|a| url::Url::parse(a).ok())
302        .any(|allow| origins_match(&target, &allow));
303    if permitted {
304        Ok(())
305    } else {
306        Err(ScopeError::CrossOriginDenied)
307    }
308}
309
310fn origins_match(a: &url::Url, b: &url::Url) -> bool {
311    a.scheme() == b.scheme()
312        && a.host_str() == b.host_str()
313        && a.port_or_known_default() == b.port_or_known_default()
314}
315
316/// Canonical `scheme://host[:port]` serialisation of a URL's origin, with the
317/// scheme's default port dropped (so `https://h` and `https://h:443` collapse to
318/// one key). Returns `None` for a URL that does not parse or whose origin is
319/// opaque (e.g. `data:`), which are never valid allowlist entries.
320fn canonical_origin(s: &str) -> Option<String> {
321    let origin = url::Url::parse(s.trim()).ok()?.origin();
322    origin.is_tuple().then(|| origin.ascii_serialization())
323}
324
325/// A per-connecting-origin outbound allowlist for the bridge.
326///
327/// Maps each **connecting tab origin** (the `Origin` presented at the WebSocket
328/// upgrade) to the set of **outbound origins** a request routed to that tab may
329/// reach. This is the mechanism behind per-origin scoping: a Grafana tab and a
330/// Facebook tab each carry only their own grant, so neither can borrow the
331/// other's outbound scope.
332///
333/// Built from repeatable `--allow-origin` values, each either a bare `ORIGIN`
334/// (shorthand: the tab may reach its own origin) or an explicit
335/// `CONNECT=OUTBOUND` mapping; repeats accumulate outbound origins under the same
336/// connecting key. An empty allowlist is the unconfigured default — the WS gate
337/// admits any origin (the session token is the gate) and outbound scope is
338/// default-closed (relative URLs only).
339#[derive(Debug, Clone, Default, PartialEq, Eq)]
340pub struct OriginAllowlist {
341    /// Canonical connecting-origin → canonical permitted outbound origins.
342    map: BTreeMap<String, BTreeSet<String>>,
343}
344
345impl OriginAllowlist {
346    /// Parses repeatable `--allow-origin` CLI values into an allowlist.
347    ///
348    /// Each value is `ORIGIN` (shorthand for `ORIGIN=ORIGIN`) or
349    /// `CONNECT=OUTBOUND`. Both sides must be valid, non-opaque origins; a
350    /// malformed or empty side is a hard error naming the offending value.
351    pub fn parse<S: AsRef<str>>(values: &[S]) -> Result<Self, String> {
352        let mut map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
353        for raw in values {
354            let raw = raw.as_ref().trim();
355            if raw.is_empty() {
356                return Err("empty --allow-origin value".to_string());
357            }
358            let (connect, outbound) = match raw.split_once('=') {
359                Some((c, o)) => (c.trim(), o.trim()),
360                None => (raw, raw),
361            };
362            let connect = canonical_origin(connect).ok_or_else(|| {
363                format!("invalid --allow-origin connecting origin: {connect:?} (in {raw:?})")
364            })?;
365            let outbound = canonical_origin(outbound).ok_or_else(|| {
366                format!("invalid --allow-origin outbound origin: {outbound:?} (in {raw:?})")
367            })?;
368            map.entry(connect).or_default().insert(outbound);
369        }
370        Ok(Self { map })
371    }
372
373    /// Whether no `--allow-origin` was configured (the default-open WS gate,
374    /// default-closed outbound case).
375    #[must_use]
376    pub fn is_empty(&self) -> bool {
377        self.map.is_empty()
378    }
379
380    /// Whether a WebSocket upgrade from `origin` is permitted.
381    ///
382    /// An empty allowlist admits any origin (the token in the subprotocol is the
383    /// gate). Otherwise the connecting origin must be a configured key — an
384    /// origin-less upgrade is rejected once any entry exists.
385    #[must_use]
386    pub fn permits_connection(&self, origin: Option<&str>) -> bool {
387        if self.map.is_empty() {
388            return true;
389        }
390        origin
391            .and_then(canonical_origin)
392            .is_some_and(|o| self.map.contains_key(&o))
393    }
394
395    /// The outbound origins granted to a request routed to a tab on `origin`.
396    ///
397    /// Empty when the tab's origin is unknown or carries no grant — leaving only
398    /// relative URLs permitted (see [`validate_outbound_url`]).
399    #[must_use]
400    pub fn outbound_for(&self, origin: Option<&str>) -> Vec<&str> {
401        origin
402            .and_then(canonical_origin)
403            .and_then(|o| self.map.get(&o))
404            .map(|set| set.iter().map(String::as_str).collect())
405            .unwrap_or_default()
406    }
407
408    /// Renders the configured entries as `CONNECT → OUTBOUND[, OUTBOUND…]` lines
409    /// (connecting-origin sorted) for the startup banner. Empty when unconfigured.
410    #[must_use]
411    pub fn describe(&self) -> Vec<String> {
412        self.map
413            .iter()
414            .map(|(connect, outbound)| {
415                let outbound = outbound.iter().cloned().collect::<Vec<_>>().join(", ");
416                format!("{connect} → {outbound}")
417            })
418            .collect()
419    }
420}
421
422/// Extracts and verifies the bridge token from WebSocket subprotocols.
423///
424/// Returns the matching subprotocol to echo back in the handshake response, or
425/// `None` if no presented protocol matches the expected token.
426#[must_use]
427pub fn ws_subprotocol_token<'a, I>(subprotocols: I, token: &str) -> Option<&'a str>
428where
429    I: IntoIterator<Item = &'a str>,
430{
431    subprotocols
432        .into_iter()
433        .map(str::trim)
434        .find(|p| constant_time_eq(p, token))
435}
436
437#[cfg(test)]
438#[allow(clippy::unwrap_used, clippy::expect_used)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn generated_tokens_are_unique_and_urlsafe() {
444        let a = generate_token();
445        let b = generate_token();
446        assert_ne!(a, b);
447        assert!(a
448            .chars()
449            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
450        assert!(a.len() >= 40);
451    }
452
453    #[test]
454    fn constant_time_eq_matches_str_eq() {
455        assert!(constant_time_eq("abc", "abc"));
456        assert!(!constant_time_eq("abc", "abd"));
457        assert!(!constant_time_eq("abc", "abcd"));
458    }
459
460    #[test]
461    fn bearer_accepts_only_correct_token() {
462        assert!(bearer_matches(Some("Bearer tok"), "tok"));
463        assert!(!bearer_matches(Some("Bearer wrong"), "tok"));
464        assert!(!bearer_matches(Some("tok"), "tok"));
465        assert!(!bearer_matches(None, "tok"));
466    }
467
468    #[test]
469    fn bridge_header_must_be_one() {
470        assert!(has_bridge_header(Some("1")));
471        assert!(has_bridge_header(Some(" 1 ")));
472        assert!(!has_bridge_header(Some("0")));
473        assert!(!has_bridge_header(None));
474    }
475
476    #[test]
477    fn host_allowlist_blocks_rebinding() {
478        assert!(host_allowed("localhost:9998", 9998));
479        assert!(host_allowed("127.0.0.1:9998", 9998));
480        assert!(host_allowed("[::1]:9998", 9998));
481        assert!(!host_allowed("evil.example.com:9998", 9998));
482        assert!(!host_allowed("localhost:9999", 9998));
483        assert!(!host_allowed("localhost", 9998));
484    }
485
486    #[test]
487    fn browser_origin_is_rejected() {
488        assert!(is_browser_originated(Some("https://evil.test"), None));
489        assert!(is_browser_originated(None, Some("cross-site")));
490        assert!(is_browser_originated(None, Some("same-site")));
491        assert!(!is_browser_originated(None, None));
492        assert!(!is_browser_originated(None, Some("none")));
493    }
494
495    #[test]
496    fn header_crlf_is_rejected() {
497        assert!(header_is_safe("Accept", "application/json"));
498        assert!(!header_is_safe("X\r\nEvil", "v"));
499        assert!(!header_is_safe("X", "a\r\nSet-Cookie: y"));
500        assert!(!header_is_safe("", "v"));
501    }
502
503    #[test]
504    fn path_normalization_rejects_traversal() {
505        assert_eq!(
506            normalize_request_path("/loki/api/v1/labels").as_deref(),
507            Some("/loki/api/v1/labels")
508        );
509        assert_eq!(
510            normalize_request_path("/a/%2e%2e/b"),
511            Some("/a/../b".to_string()).filter(|_| false).or(None)
512        );
513        assert!(normalize_request_path("/a/../b").is_none());
514        assert!(normalize_request_path("/a/%2e%2e/b").is_none());
515        assert!(normalize_request_path("/a/%00/b").is_none());
516        assert!(normalize_request_path("/bad%2").is_none());
517    }
518
519    #[test]
520    fn outbound_scope_is_default_closed() {
521        assert_eq!(validate_outbound_url("/api/foo", &[]), Ok(()));
522        assert_eq!(
523            validate_outbound_url("https://evil.test/x", &[]),
524            Err(ScopeError::CrossOriginDenied)
525        );
526        assert_eq!(
527            validate_outbound_url("//evil.test/x", &[]),
528            Err(ScopeError::CrossOriginDenied)
529        );
530    }
531
532    #[test]
533    fn outbound_scope_honors_allowed_origins() {
534        assert_eq!(
535            validate_outbound_url("https://ok.test/x", &["https://ok.test"]),
536            Ok(())
537        );
538        assert_eq!(
539            validate_outbound_url("https://evil.test/x", &["https://ok.test"]),
540            Err(ScopeError::CrossOriginDenied)
541        );
542        // Any origin in the slice permits the URL.
543        assert_eq!(
544            validate_outbound_url("https://b.test/x", &["https://a.test", "https://b.test"]),
545            Ok(())
546        );
547        // Relative always allowed regardless of the outbound grant.
548        assert_eq!(validate_outbound_url("/x", &["https://ok.test"]), Ok(()));
549    }
550
551    #[test]
552    fn ws_subprotocol_token_selects_match() {
553        assert_eq!(ws_subprotocol_token(["a", "tok", "b"], "tok"), Some("tok"));
554        assert_eq!(ws_subprotocol_token(["a", "b"], "tok"), None);
555        assert_eq!(ws_subprotocol_token([], "tok"), None);
556    }
557
558    #[test]
559    fn empty_allowlist_opens_the_ws_gate() {
560        let empty = OriginAllowlist::default();
561        assert!(empty.is_empty());
562        // Token is the gate: any origin (or none) connects.
563        assert!(empty.permits_connection(Some("https://anything.test")));
564        assert!(empty.permits_connection(None));
565        // ...and outbound is default-closed (no origins granted).
566        assert!(empty.outbound_for(Some("https://anything.test")).is_empty());
567    }
568
569    #[test]
570    fn allowlist_gates_connection_by_configured_key() {
571        let list = OriginAllowlist::parse(&["https://ok.test"]).unwrap();
572        assert!(list.permits_connection(Some("https://ok.test")));
573        // Port normalisation: the default https port collapses onto the key.
574        assert!(list.permits_connection(Some("https://ok.test:443")));
575        assert!(!list.permits_connection(Some("https://evil.test")));
576        // An origin-less upgrade is rejected once any entry exists.
577        assert!(!list.permits_connection(None));
578    }
579
580    #[test]
581    fn shorthand_grants_a_tab_its_own_origin() {
582        let list = OriginAllowlist::parse(&["https://grafana.internal"]).unwrap();
583        assert_eq!(
584            list.outbound_for(Some("https://grafana.internal")),
585            vec!["https://grafana.internal"]
586        );
587        // A tab with no matching key carries no outbound grant.
588        assert!(list.outbound_for(Some("https://other.test")).is_empty());
589        assert!(list.outbound_for(None).is_empty());
590    }
591
592    #[test]
593    fn mapping_scopes_outbound_per_connecting_origin() {
594        // A Grafana tab and a Facebook tab each carry only their own grant.
595        let list = OriginAllowlist::parse(&[
596            "https://grafana.internal",
597            "https://www.facebook.com=https://static.xx.fbcdn.net",
598        ])
599        .unwrap();
600        assert_eq!(
601            list.outbound_for(Some("https://grafana.internal")),
602            vec!["https://grafana.internal"]
603        );
604        assert_eq!(
605            list.outbound_for(Some("https://www.facebook.com")),
606            vec!["https://static.xx.fbcdn.net"]
607        );
608        // The Grafana tab cannot borrow Facebook's outbound scope.
609        assert_eq!(
610            validate_outbound_url(
611                "https://static.xx.fbcdn.net/x",
612                &list.outbound_for(Some("https://grafana.internal"))
613            ),
614            Err(ScopeError::CrossOriginDenied)
615        );
616    }
617
618    #[test]
619    fn repeated_keys_accumulate_outbound_origins() {
620        let list = OriginAllowlist::parse(&[
621            "https://app.test=https://a.cdn.test",
622            "https://app.test=https://b.cdn.test",
623        ])
624        .unwrap();
625        assert_eq!(
626            list.outbound_for(Some("https://app.test")),
627            vec!["https://a.cdn.test", "https://b.cdn.test"]
628        );
629    }
630
631    #[test]
632    fn parse_rejects_malformed_and_empty_values() {
633        assert!(OriginAllowlist::parse(&["not a url"]).is_err());
634        assert!(OriginAllowlist::parse(&["https://ok.test=nonsense"]).is_err());
635        assert!(OriginAllowlist::parse(&[""]).is_err());
636        assert!(OriginAllowlist::parse(&["=https://ok.test"]).is_err());
637    }
638
639    #[cfg(unix)]
640    #[test]
641    fn token_file_requires_0600() {
642        use std::io::Write;
643        use std::os::unix::fs::PermissionsExt;
644        let dir = tempfile::tempdir().unwrap();
645        let path = dir.path().join("tok");
646        let mut f = std::fs::File::create(&path).unwrap();
647        writeln!(f, "secret-token").unwrap();
648        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
649        assert!(resolve_token(Some(&path)).is_err());
650        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
651        assert_eq!(resolve_token(Some(&path)).unwrap(), "secret-token");
652    }
653
654    // ── token resolution from env / file ─────────────────────────────
655    //
656    // `OMNI_BRIDGE_TOKEN` is read through an injected `EnvSource`, so these
657    // tests pass a pure `MapEnv` and never touch the process environment —
658    // no lock, fully parallel (issue #1030).
659
660    use crate::test_support::env::MapEnv;
661
662    #[test]
663    fn resolve_token_reads_trimmed_env_var() {
664        let env = MapEnv::new().with(TOKEN_ENV, "  env-token  ");
665        assert_eq!(resolve_token_with(&env, None).unwrap(), "env-token");
666    }
667
668    #[test]
669    fn resolve_token_generates_when_env_empty_or_absent() {
670        // Absent → freshly generated (long, URL-safe).
671        let a = resolve_token_with(&MapEnv::new(), None).unwrap();
672        assert!(a.len() >= 40);
673        // Blank/whitespace env is ignored and also generates.
674        let env = MapEnv::new().with(TOKEN_ENV, "   ");
675        let b = resolve_token_with(&env, None).unwrap();
676        assert!(b.len() >= 40);
677        assert_ne!(a, b);
678    }
679
680    #[test]
681    fn resolve_existing_token_reads_env_var() {
682        let env = MapEnv::new().with(TOKEN_ENV, "client-token");
683        assert_eq!(
684            resolve_existing_token_with(&env, None).unwrap(),
685            "client-token"
686        );
687    }
688
689    #[test]
690    fn resolve_existing_token_errors_without_source() {
691        let err = resolve_existing_token_with(&MapEnv::new(), None).unwrap_err();
692        assert!(err.to_string().contains(TOKEN_ENV));
693    }
694
695    #[test]
696    fn resolve_existing_token_reads_file() {
697        let dir = tempfile::tempdir().unwrap();
698        let path = dir.path().join("tok");
699        std::fs::write(&path, "  file-token\n").unwrap();
700        #[cfg(unix)]
701        {
702            use std::os::unix::fs::PermissionsExt;
703            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
704        }
705        assert_eq!(resolve_existing_token(Some(&path)).unwrap(), "file-token");
706    }
707
708    #[test]
709    fn token_file_missing_errors() {
710        let dir = tempfile::tempdir().unwrap();
711        let path = dir.path().join("does-not-exist");
712        assert!(resolve_token(Some(&path)).is_err());
713    }
714
715    #[test]
716    fn token_file_empty_errors() {
717        let dir = tempfile::tempdir().unwrap();
718        let path = dir.path().join("empty");
719        std::fs::write(&path, "   \n").unwrap();
720        #[cfg(unix)]
721        {
722            use std::os::unix::fs::PermissionsExt;
723            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
724        }
725        let err = resolve_token(Some(&path)).unwrap_err();
726        assert!(err.to_string().contains("empty"));
727    }
728}