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