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::path::Path;
14
15use anyhow::{bail, Context, Result};
16use base64::Engine;
17use rand::Rng;
18
19use crate::utils::env::{EnvSource, SystemEnv};
20
21/// Environment variable an operator may use to pin the session token instead of
22/// letting the bridge generate one. Never read from argv (`ps`/`/proc` expose
23/// it).
24pub const TOKEN_ENV: &str = "OMNI_BRIDGE_TOKEN";
25
26/// Custom header every control-plane request must carry. A custom header forces
27/// a CORS preflight that the server refuses, blocking simple-request CSRF.
28pub const BRIDGE_HEADER: &str = "x-omni-bridge";
29
30/// Required value of [`BRIDGE_HEADER`].
31pub const BRIDGE_HEADER_VALUE: &str = "1";
32
33/// Optional header selecting which connected tab a control-plane request targets.
34///
35/// The value is a connection id, or an `Origin` that uniquely matches one tab. It
36/// takes precedence over a `target` body field, and is stripped before forwarding.
37pub const BRIDGE_TARGET_HEADER: &str = "x-omni-bridge-target";
38
39/// Number of random bytes behind a generated token (URL-safe base64, no pad).
40const TOKEN_BYTES: usize = 32;
41
42/// Generates a fresh random session token (256 bits, URL-safe base64).
43#[must_use]
44pub fn generate_token() -> String {
45    let mut bytes = [0u8; TOKEN_BYTES];
46    rand::rng().fill_bytes(&mut bytes);
47    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
48}
49
50/// Resolves the session token, in priority order:
51///
52/// 1. `--token-file` — read its trimmed contents. On Unix the file must be
53///    `0600` (owner-only) or resolution fails closed.
54/// 2. `OMNI_BRIDGE_TOKEN` — read from the environment.
55/// 3. Otherwise a fresh token is generated.
56///
57/// The token is **never** accepted from argv.
58pub fn resolve_token(token_file: Option<&Path>) -> Result<String> {
59    resolve_token_with(&SystemEnv, token_file)
60}
61
62/// [`resolve_token`] over an injected [`EnvSource`], so the `OMNI_BRIDGE_TOKEN`
63/// branch is tested without mutating the process environment (issue #1030).
64pub(crate) fn resolve_token_with(
65    env: &impl EnvSource,
66    token_file: Option<&Path>,
67) -> Result<String> {
68    if let Some(path) = token_file {
69        return read_token_file(path);
70    }
71    if let Some(value) = env.var(TOKEN_ENV) {
72        let trimmed = value.trim();
73        if !trimmed.is_empty() {
74            return Ok(trimmed.to_string());
75        }
76    }
77    Ok(generate_token())
78}
79
80/// Resolves an *existing* session token for a client.
81///
82/// Used by the `request` subcommand: `--token-file` then `OMNI_BRIDGE_TOKEN`.
83/// Unlike [`resolve_token`], it never generates one — a client must use the
84/// token the running bridge printed.
85pub fn resolve_existing_token(token_file: Option<&Path>) -> Result<String> {
86    resolve_existing_token_with(&SystemEnv, token_file)
87}
88
89/// [`resolve_existing_token`] over an injected [`EnvSource`]; never generates a
90/// token. Tests pass a `MapEnv` rather than mutating the process environment.
91pub(crate) fn resolve_existing_token_with(
92    env: &impl EnvSource,
93    token_file: Option<&Path>,
94) -> Result<String> {
95    if let Some(path) = token_file {
96        return read_token_file(path);
97    }
98    match env.var(TOKEN_ENV) {
99        Some(value) if !value.trim().is_empty() => Ok(value.trim().to_string()),
100        _ => bail!(
101            "No session token found. Set {TOKEN_ENV} or pass --token-file with the token the \
102             running bridge printed."
103        ),
104    }
105}
106
107fn read_token_file(path: &Path) -> Result<String> {
108    #[cfg(unix)]
109    {
110        use std::os::unix::fs::PermissionsExt;
111        let meta = std::fs::metadata(path)
112            .with_context(|| format!("Failed to stat token file {}", path.display()))?;
113        let mode = meta.permissions().mode() & 0o777;
114        if mode & 0o077 != 0 {
115            bail!(
116                "Token file {} must be 0600 (owner-only); found {:o}",
117                path.display(),
118                mode
119            );
120        }
121    }
122    let contents = std::fs::read_to_string(path)
123        .with_context(|| format!("Failed to read token file {}", path.display()))?;
124    let trimmed = contents.trim();
125    if trimmed.is_empty() {
126        bail!("Token file {} is empty", path.display());
127    }
128    Ok(trimmed.to_string())
129}
130
131/// Constant-time string comparison, to avoid leaking the token via timing.
132#[must_use]
133pub fn constant_time_eq(a: &str, b: &str) -> bool {
134    let (a, b) = (a.as_bytes(), b.as_bytes());
135    if a.len() != b.len() {
136        return false;
137    }
138    let mut diff = 0u8;
139    for (x, y) in a.iter().zip(b.iter()) {
140        diff |= x ^ y;
141    }
142    diff == 0
143}
144
145/// Checks an `Authorization` header value against the expected token.
146///
147/// Accepts only `Bearer <token>` with a constant-time token comparison.
148#[must_use]
149pub fn bearer_matches(authorization: Option<&str>, token: &str) -> bool {
150    let Some(value) = authorization else {
151        return false;
152    };
153    let Some(presented) = value.strip_prefix("Bearer ") else {
154        return false;
155    };
156    constant_time_eq(presented.trim(), token)
157}
158
159/// Whether the request carries the mandatory `X-Omni-Bridge: 1` header.
160#[must_use]
161pub fn has_bridge_header(value: Option<&str>) -> bool {
162    value.is_some_and(|v| v.trim() == BRIDGE_HEADER_VALUE)
163}
164
165/// Whether `host` is an allowed loopback authority for `control_port`.
166///
167/// Blocks DNS rebinding: only the exact `localhost:<port>` / `127.0.0.1:<port>`
168/// (and the IPv6 loopback) authorities are accepted.
169#[must_use]
170pub fn host_allowed(host: &str, control_port: u16) -> bool {
171    let allowed = [
172        format!("localhost:{control_port}"),
173        format!("127.0.0.1:{control_port}"),
174        format!("[::1]:{control_port}"),
175    ];
176    allowed.iter().any(|a| a == host)
177}
178
179/// Whether a request looks browser-originated and must therefore be denied.
180///
181/// A legitimate CLI client sends neither an `Origin` header nor
182/// `Sec-Fetch-Site: cross-site`/`same-site`. Any such header marks a request a
183/// web page made, which the control plane refuses.
184#[must_use]
185pub fn is_browser_originated(origin: Option<&str>, sec_fetch_site: Option<&str>) -> bool {
186    if origin.is_some() {
187        return true;
188    }
189    matches!(
190        sec_fetch_site.map(str::trim),
191        Some("cross-site" | "same-site" | "same-origin")
192    )
193}
194
195/// Rejects header names/values that could smuggle a second header or request
196/// line via CR/LF (or other control characters).
197#[must_use]
198pub fn header_is_safe(name: &str, value: &str) -> bool {
199    let bad = |s: &str| s.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0);
200    !name.is_empty() && !bad(name) && !bad(value)
201}
202
203/// Normalises a decoded request path, rejecting traversal and control
204/// characters. Run **before** the `/__bridge/` routing/auth split so a
205/// percent-encoded segment cannot bypass the prefix check.
206///
207/// Returns the percent-decoded path, or `None` if it is unsafe.
208#[must_use]
209pub fn normalize_request_path(raw: &str) -> Option<String> {
210    let decoded = percent_decode(raw)?;
211    if decoded.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
212        return None;
213    }
214    // Reject any `..` path segment (traversal).
215    if decoded
216        .split('/')
217        .any(|seg| seg == ".." || seg == "..%2f" || seg == "..%2F")
218    {
219        return None;
220    }
221    Some(decoded)
222}
223
224/// Minimal percent-decoder for request paths. Returns `None` on malformed
225/// escapes or non-UTF-8 results.
226fn percent_decode(raw: &str) -> Option<String> {
227    let bytes = raw.as_bytes();
228    let mut out = Vec::with_capacity(bytes.len());
229    let mut i = 0;
230    while i < bytes.len() {
231        match bytes[i] {
232            b'%' => {
233                let hi = bytes.get(i + 1).copied().and_then(hex_val)?;
234                let lo = bytes.get(i + 2).copied().and_then(hex_val)?;
235                out.push(hi << 4 | lo);
236                i += 3;
237            }
238            b => {
239                out.push(b);
240                i += 1;
241            }
242        }
243    }
244    String::from_utf8(out).ok()
245}
246
247fn hex_val(b: u8) -> Option<u8> {
248    match b {
249        b'0'..=b'9' => Some(b - b'0'),
250        b'a'..=b'f' => Some(b - b'a' + 10),
251        b'A'..=b'F' => Some(b - b'A' + 10),
252        _ => None,
253    }
254}
255
256/// Reason an outbound URL was rejected by [`validate_outbound_url`].
257#[derive(Debug, PartialEq, Eq)]
258pub enum ScopeError {
259    /// The URL is absolute/cross-origin and no `--allow-origin` permits it.
260    CrossOriginDenied,
261    /// The URL could not be parsed or is otherwise malformed.
262    Malformed,
263}
264
265/// Enforces the default-closed outbound scope.
266///
267/// Relative URLs (page-origin) are always allowed. Absolute or
268/// protocol-relative URLs are rejected unless their origin exactly matches
269/// `allow_origin`.
270pub fn validate_outbound_url(url: &str, allow_origin: Option<&str>) -> Result<(), ScopeError> {
271    // Protocol-relative (`//host/...`) is cross-origin.
272    let is_relative = url.starts_with('/') && !url.starts_with("//");
273    if is_relative {
274        if url.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
275            return Err(ScopeError::Malformed);
276        }
277        return Ok(());
278    }
279
280    let allow = allow_origin.ok_or(ScopeError::CrossOriginDenied)?;
281    let target = url::Url::parse(url).map_err(|_| ScopeError::Malformed)?;
282    let allowed = url::Url::parse(allow).map_err(|_| ScopeError::Malformed)?;
283    if origins_match(&target, &allowed) {
284        Ok(())
285    } else {
286        Err(ScopeError::CrossOriginDenied)
287    }
288}
289
290fn origins_match(a: &url::Url, b: &url::Url) -> bool {
291    a.scheme() == b.scheme()
292        && a.host_str() == b.host_str()
293        && a.port_or_known_default() == b.port_or_known_default()
294}
295
296/// Extracts and verifies the bridge token from WebSocket subprotocols.
297///
298/// Returns the matching subprotocol to echo back in the handshake response, or
299/// `None` if no presented protocol matches the expected token.
300#[must_use]
301pub fn ws_subprotocol_token<'a, I>(subprotocols: I, token: &str) -> Option<&'a str>
302where
303    I: IntoIterator<Item = &'a str>,
304{
305    subprotocols
306        .into_iter()
307        .map(str::trim)
308        .find(|p| constant_time_eq(p, token))
309}
310
311/// Whether a WebSocket upgrade's `Origin` is permitted.
312///
313/// With no `--allow-origin` configured, any origin is accepted (the token in
314/// the subprotocol is the gate). With one configured, the origin must match.
315#[must_use]
316pub fn ws_origin_allowed(origin: Option<&str>, allow_origin: Option<&str>) -> bool {
317    match allow_origin {
318        None => true,
319        Some(allowed) => origin.is_some_and(|o| {
320            url::Url::parse(o)
321                .ok()
322                .zip(url::Url::parse(allowed).ok())
323                .is_some_and(|(o, a)| origins_match(&o, &a))
324        }),
325    }
326}
327
328#[cfg(test)]
329#[allow(clippy::unwrap_used, clippy::expect_used)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn generated_tokens_are_unique_and_urlsafe() {
335        let a = generate_token();
336        let b = generate_token();
337        assert_ne!(a, b);
338        assert!(a
339            .chars()
340            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
341        assert!(a.len() >= 40);
342    }
343
344    #[test]
345    fn constant_time_eq_matches_str_eq() {
346        assert!(constant_time_eq("abc", "abc"));
347        assert!(!constant_time_eq("abc", "abd"));
348        assert!(!constant_time_eq("abc", "abcd"));
349    }
350
351    #[test]
352    fn bearer_accepts_only_correct_token() {
353        assert!(bearer_matches(Some("Bearer tok"), "tok"));
354        assert!(!bearer_matches(Some("Bearer wrong"), "tok"));
355        assert!(!bearer_matches(Some("tok"), "tok"));
356        assert!(!bearer_matches(None, "tok"));
357    }
358
359    #[test]
360    fn bridge_header_must_be_one() {
361        assert!(has_bridge_header(Some("1")));
362        assert!(has_bridge_header(Some(" 1 ")));
363        assert!(!has_bridge_header(Some("0")));
364        assert!(!has_bridge_header(None));
365    }
366
367    #[test]
368    fn host_allowlist_blocks_rebinding() {
369        assert!(host_allowed("localhost:9998", 9998));
370        assert!(host_allowed("127.0.0.1:9998", 9998));
371        assert!(host_allowed("[::1]:9998", 9998));
372        assert!(!host_allowed("evil.example.com:9998", 9998));
373        assert!(!host_allowed("localhost:9999", 9998));
374        assert!(!host_allowed("localhost", 9998));
375    }
376
377    #[test]
378    fn browser_origin_is_rejected() {
379        assert!(is_browser_originated(Some("https://evil.test"), None));
380        assert!(is_browser_originated(None, Some("cross-site")));
381        assert!(is_browser_originated(None, Some("same-site")));
382        assert!(!is_browser_originated(None, None));
383        assert!(!is_browser_originated(None, Some("none")));
384    }
385
386    #[test]
387    fn header_crlf_is_rejected() {
388        assert!(header_is_safe("Accept", "application/json"));
389        assert!(!header_is_safe("X\r\nEvil", "v"));
390        assert!(!header_is_safe("X", "a\r\nSet-Cookie: y"));
391        assert!(!header_is_safe("", "v"));
392    }
393
394    #[test]
395    fn path_normalization_rejects_traversal() {
396        assert_eq!(
397            normalize_request_path("/loki/api/v1/labels").as_deref(),
398            Some("/loki/api/v1/labels")
399        );
400        assert_eq!(
401            normalize_request_path("/a/%2e%2e/b"),
402            Some("/a/../b".to_string()).filter(|_| false).or(None)
403        );
404        assert!(normalize_request_path("/a/../b").is_none());
405        assert!(normalize_request_path("/a/%2e%2e/b").is_none());
406        assert!(normalize_request_path("/a/%00/b").is_none());
407        assert!(normalize_request_path("/bad%2").is_none());
408    }
409
410    #[test]
411    fn outbound_scope_is_default_closed() {
412        assert_eq!(validate_outbound_url("/api/foo", None), Ok(()));
413        assert_eq!(
414            validate_outbound_url("https://evil.test/x", None),
415            Err(ScopeError::CrossOriginDenied)
416        );
417        assert_eq!(
418            validate_outbound_url("//evil.test/x", None),
419            Err(ScopeError::CrossOriginDenied)
420        );
421    }
422
423    #[test]
424    fn outbound_scope_honors_allow_origin() {
425        assert_eq!(
426            validate_outbound_url("https://ok.test/x", Some("https://ok.test")),
427            Ok(())
428        );
429        assert_eq!(
430            validate_outbound_url("https://evil.test/x", Some("https://ok.test")),
431            Err(ScopeError::CrossOriginDenied)
432        );
433        // Relative always allowed regardless of allow-origin.
434        assert_eq!(validate_outbound_url("/x", Some("https://ok.test")), Ok(()));
435    }
436
437    #[test]
438    fn ws_subprotocol_token_selects_match() {
439        assert_eq!(ws_subprotocol_token(["a", "tok", "b"], "tok"), Some("tok"));
440        assert_eq!(ws_subprotocol_token(["a", "b"], "tok"), None);
441        assert_eq!(ws_subprotocol_token([], "tok"), None);
442    }
443
444    #[test]
445    fn ws_origin_allowed_logic() {
446        assert!(ws_origin_allowed(Some("https://anything.test"), None));
447        assert!(ws_origin_allowed(None, None));
448        assert!(ws_origin_allowed(
449            Some("https://ok.test"),
450            Some("https://ok.test")
451        ));
452        assert!(!ws_origin_allowed(
453            Some("https://evil.test"),
454            Some("https://ok.test")
455        ));
456        assert!(!ws_origin_allowed(None, Some("https://ok.test")));
457    }
458
459    #[cfg(unix)]
460    #[test]
461    fn token_file_requires_0600() {
462        use std::io::Write;
463        use std::os::unix::fs::PermissionsExt;
464        let dir = tempfile::tempdir().unwrap();
465        let path = dir.path().join("tok");
466        let mut f = std::fs::File::create(&path).unwrap();
467        writeln!(f, "secret-token").unwrap();
468        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
469        assert!(resolve_token(Some(&path)).is_err());
470        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
471        assert_eq!(resolve_token(Some(&path)).unwrap(), "secret-token");
472    }
473
474    // ── token resolution from env / file ─────────────────────────────
475    //
476    // `OMNI_BRIDGE_TOKEN` is read through an injected `EnvSource`, so these
477    // tests pass a pure `MapEnv` and never touch the process environment —
478    // no lock, fully parallel (issue #1030).
479
480    use crate::test_support::env::MapEnv;
481
482    #[test]
483    fn resolve_token_reads_trimmed_env_var() {
484        let env = MapEnv::new().with(TOKEN_ENV, "  env-token  ");
485        assert_eq!(resolve_token_with(&env, None).unwrap(), "env-token");
486    }
487
488    #[test]
489    fn resolve_token_generates_when_env_empty_or_absent() {
490        // Absent → freshly generated (long, URL-safe).
491        let a = resolve_token_with(&MapEnv::new(), None).unwrap();
492        assert!(a.len() >= 40);
493        // Blank/whitespace env is ignored and also generates.
494        let env = MapEnv::new().with(TOKEN_ENV, "   ");
495        let b = resolve_token_with(&env, None).unwrap();
496        assert!(b.len() >= 40);
497        assert_ne!(a, b);
498    }
499
500    #[test]
501    fn resolve_existing_token_reads_env_var() {
502        let env = MapEnv::new().with(TOKEN_ENV, "client-token");
503        assert_eq!(
504            resolve_existing_token_with(&env, None).unwrap(),
505            "client-token"
506        );
507    }
508
509    #[test]
510    fn resolve_existing_token_errors_without_source() {
511        let err = resolve_existing_token_with(&MapEnv::new(), None).unwrap_err();
512        assert!(err.to_string().contains(TOKEN_ENV));
513    }
514
515    #[test]
516    fn resolve_existing_token_reads_file() {
517        let dir = tempfile::tempdir().unwrap();
518        let path = dir.path().join("tok");
519        std::fs::write(&path, "  file-token\n").unwrap();
520        #[cfg(unix)]
521        {
522            use std::os::unix::fs::PermissionsExt;
523            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
524        }
525        assert_eq!(resolve_existing_token(Some(&path)).unwrap(), "file-token");
526    }
527
528    #[test]
529    fn token_file_missing_errors() {
530        let dir = tempfile::tempdir().unwrap();
531        let path = dir.path().join("does-not-exist");
532        assert!(resolve_token(Some(&path)).is_err());
533    }
534
535    #[test]
536    fn token_file_empty_errors() {
537        let dir = tempfile::tempdir().unwrap();
538        let path = dir.path().join("empty");
539        std::fs::write(&path, "   \n").unwrap();
540        #[cfg(unix)]
541        {
542            use std::os::unix::fs::PermissionsExt;
543            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
544        }
545        let err = resolve_token(Some(&path)).unwrap_err();
546        assert!(err.to_string().contains("empty"));
547    }
548}