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