1use 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
22pub const TOKEN_ENV: &str = "OMNI_BRIDGE_TOKEN";
26
27pub const BRIDGE_HEADER: &str = "x-omni-bridge";
30
31pub const BRIDGE_HEADER_VALUE: &str = "1";
33
34pub const BRIDGE_TARGET_HEADER: &str = "x-omni-bridge-target";
39
40const TOKEN_BYTES: usize = 32;
42
43#[must_use]
45pub fn generate_token() -> String {
46 let mut bytes = [0u8; TOKEN_BYTES];
47 rand::rng().fill_bytes(&mut bytes);
48 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
49}
50
51pub fn resolve_token(token_file: Option<&Path>) -> Result<String> {
60 resolve_token_with(&SystemEnv, token_file)
61}
62
63pub(crate) fn resolve_token_with(
66 env: &impl EnvSource,
67 token_file: Option<&Path>,
68) -> Result<String> {
69 if let Some(path) = token_file {
70 return read_token_file(path);
71 }
72 if let Some(value) = env.var(TOKEN_ENV) {
73 let trimmed = value.trim();
74 if !trimmed.is_empty() {
75 return Ok(trimmed.to_string());
76 }
77 }
78 Ok(generate_token())
79}
80
81pub fn resolve_existing_token(token_file: Option<&Path>) -> Result<String> {
87 resolve_existing_token_with(&SystemEnv, token_file)
88}
89
90pub(crate) fn resolve_existing_token_with(
93 env: &impl EnvSource,
94 token_file: Option<&Path>,
95) -> Result<String> {
96 if let Some(path) = token_file {
97 return read_token_file(path);
98 }
99 match env.var(TOKEN_ENV) {
100 Some(value) if !value.trim().is_empty() => Ok(value.trim().to_string()),
101 _ => bail!(
102 "No session token found. Set {TOKEN_ENV} or pass --token-file with the token the \
103 running bridge printed."
104 ),
105 }
106}
107
108fn read_token_file(path: &Path) -> Result<String> {
109 #[cfg(unix)]
110 {
111 use std::os::unix::fs::PermissionsExt;
112 let meta = std::fs::metadata(path)
113 .with_context(|| format!("Failed to stat token file {}", path.display()))?;
114 let mode = meta.permissions().mode() & 0o777;
115 if mode & 0o077 != 0 {
116 bail!(
117 "Token file {} must be 0600 (owner-only); found {:o}",
118 path.display(),
119 mode
120 );
121 }
122 }
123 let contents = std::fs::read_to_string(path)
124 .with_context(|| format!("Failed to read token file {}", path.display()))?;
125 let trimmed = contents.trim();
126 if trimmed.is_empty() {
127 bail!("Token file {} is empty", path.display());
128 }
129 Ok(trimmed.to_string())
130}
131
132#[must_use]
134pub fn constant_time_eq(a: &str, b: &str) -> bool {
135 let (a, b) = (a.as_bytes(), b.as_bytes());
136 if a.len() != b.len() {
137 return false;
138 }
139 let mut diff = 0u8;
140 for (x, y) in a.iter().zip(b.iter()) {
141 diff |= x ^ y;
142 }
143 diff == 0
144}
145
146#[must_use]
150pub fn bearer_matches(authorization: Option<&str>, token: &str) -> bool {
151 let Some(value) = authorization else {
152 return false;
153 };
154 let Some(presented) = value.strip_prefix("Bearer ") else {
155 return false;
156 };
157 constant_time_eq(presented.trim(), token)
158}
159
160#[must_use]
162pub fn has_bridge_header(value: Option<&str>) -> bool {
163 value.is_some_and(|v| v.trim() == BRIDGE_HEADER_VALUE)
164}
165
166#[must_use]
171pub fn host_allowed(host: &str, control_port: u16) -> bool {
172 let allowed = [
173 format!("localhost:{control_port}"),
174 format!("127.0.0.1:{control_port}"),
175 format!("[::1]:{control_port}"),
176 ];
177 allowed.iter().any(|a| a == host)
178}
179
180#[must_use]
186pub fn is_browser_originated(origin: Option<&str>, sec_fetch_site: Option<&str>) -> bool {
187 if origin.is_some() {
188 return true;
189 }
190 matches!(
191 sec_fetch_site.map(str::trim),
192 Some("cross-site" | "same-site" | "same-origin")
193 )
194}
195
196#[must_use]
199pub fn header_is_safe(name: &str, value: &str) -> bool {
200 let bad = |s: &str| s.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0);
201 !name.is_empty() && !bad(name) && !bad(value)
202}
203
204#[must_use]
210pub fn normalize_request_path(raw: &str) -> Option<String> {
211 let decoded = percent_decode(raw)?;
212 if decoded.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
213 return None;
214 }
215 if decoded
217 .split('/')
218 .any(|seg| seg == ".." || seg == "..%2f" || seg == "..%2F")
219 {
220 return None;
221 }
222 Some(decoded)
223}
224
225fn percent_decode(raw: &str) -> Option<String> {
228 let bytes = raw.as_bytes();
229 let mut out = Vec::with_capacity(bytes.len());
230 let mut i = 0;
231 while i < bytes.len() {
232 match bytes[i] {
233 b'%' => {
234 let hi = bytes.get(i + 1).copied().and_then(hex_val)?;
235 let lo = bytes.get(i + 2).copied().and_then(hex_val)?;
236 out.push(hi << 4 | lo);
237 i += 3;
238 }
239 b => {
240 out.push(b);
241 i += 1;
242 }
243 }
244 }
245 String::from_utf8(out).ok()
246}
247
248fn hex_val(b: u8) -> Option<u8> {
249 match b {
250 b'0'..=b'9' => Some(b - b'0'),
251 b'a'..=b'f' => Some(b - b'a' + 10),
252 b'A'..=b'F' => Some(b - b'A' + 10),
253 _ => None,
254 }
255}
256
257#[derive(Debug, PartialEq, Eq)]
259pub enum ScopeError {
260 CrossOriginDenied,
262 Malformed,
264}
265
266pub fn validate_outbound_url(url: &str, allowed: &[&str]) -> Result<(), ScopeError> {
276 let is_relative = url.starts_with('/') && !url.starts_with("//");
278 if is_relative {
279 if url.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
280 return Err(ScopeError::Malformed);
281 }
282 return Ok(());
283 }
284
285 if allowed.is_empty() {
288 return Err(ScopeError::CrossOriginDenied);
289 }
290 let target = url::Url::parse(url).map_err(|_| ScopeError::Malformed)?;
291 let permitted = allowed
292 .iter()
293 .filter_map(|a| url::Url::parse(a).ok())
294 .any(|allow| origins_match(&target, &allow));
295 if permitted {
296 Ok(())
297 } else {
298 Err(ScopeError::CrossOriginDenied)
299 }
300}
301
302fn origins_match(a: &url::Url, b: &url::Url) -> bool {
303 a.scheme() == b.scheme()
304 && a.host_str() == b.host_str()
305 && a.port_or_known_default() == b.port_or_known_default()
306}
307
308fn canonical_origin(s: &str) -> Option<String> {
313 let origin = url::Url::parse(s.trim()).ok()?.origin();
314 origin.is_tuple().then(|| origin.ascii_serialization())
315}
316
317#[derive(Debug, Clone, Default, PartialEq, Eq)]
332pub struct OriginAllowlist {
333 map: BTreeMap<String, BTreeSet<String>>,
335}
336
337impl OriginAllowlist {
338 pub fn parse<S: AsRef<str>>(values: &[S]) -> Result<Self, String> {
344 let mut map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
345 for raw in values {
346 let raw = raw.as_ref().trim();
347 if raw.is_empty() {
348 return Err("empty --allow-origin value".to_string());
349 }
350 let (connect, outbound) = match raw.split_once('=') {
351 Some((c, o)) => (c.trim(), o.trim()),
352 None => (raw, raw),
353 };
354 let connect = canonical_origin(connect).ok_or_else(|| {
355 format!("invalid --allow-origin connecting origin: {connect:?} (in {raw:?})")
356 })?;
357 let outbound = canonical_origin(outbound).ok_or_else(|| {
358 format!("invalid --allow-origin outbound origin: {outbound:?} (in {raw:?})")
359 })?;
360 map.entry(connect).or_default().insert(outbound);
361 }
362 Ok(Self { map })
363 }
364
365 #[must_use]
368 pub fn is_empty(&self) -> bool {
369 self.map.is_empty()
370 }
371
372 #[must_use]
378 pub fn permits_connection(&self, origin: Option<&str>) -> bool {
379 if self.map.is_empty() {
380 return true;
381 }
382 origin
383 .and_then(canonical_origin)
384 .is_some_and(|o| self.map.contains_key(&o))
385 }
386
387 #[must_use]
392 pub fn outbound_for(&self, origin: Option<&str>) -> Vec<&str> {
393 origin
394 .and_then(canonical_origin)
395 .and_then(|o| self.map.get(&o))
396 .map(|set| set.iter().map(String::as_str).collect())
397 .unwrap_or_default()
398 }
399
400 #[must_use]
403 pub fn describe(&self) -> Vec<String> {
404 self.map
405 .iter()
406 .map(|(connect, outbound)| {
407 let outbound = outbound.iter().cloned().collect::<Vec<_>>().join(", ");
408 format!("{connect} → {outbound}")
409 })
410 .collect()
411 }
412}
413
414#[must_use]
419pub fn ws_subprotocol_token<'a, I>(subprotocols: I, token: &str) -> Option<&'a str>
420where
421 I: IntoIterator<Item = &'a str>,
422{
423 subprotocols
424 .into_iter()
425 .map(str::trim)
426 .find(|p| constant_time_eq(p, token))
427}
428
429#[cfg(test)]
430#[allow(clippy::unwrap_used, clippy::expect_used)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn generated_tokens_are_unique_and_urlsafe() {
436 let a = generate_token();
437 let b = generate_token();
438 assert_ne!(a, b);
439 assert!(a
440 .chars()
441 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
442 assert!(a.len() >= 40);
443 }
444
445 #[test]
446 fn constant_time_eq_matches_str_eq() {
447 assert!(constant_time_eq("abc", "abc"));
448 assert!(!constant_time_eq("abc", "abd"));
449 assert!(!constant_time_eq("abc", "abcd"));
450 }
451
452 #[test]
453 fn bearer_accepts_only_correct_token() {
454 assert!(bearer_matches(Some("Bearer tok"), "tok"));
455 assert!(!bearer_matches(Some("Bearer wrong"), "tok"));
456 assert!(!bearer_matches(Some("tok"), "tok"));
457 assert!(!bearer_matches(None, "tok"));
458 }
459
460 #[test]
461 fn bridge_header_must_be_one() {
462 assert!(has_bridge_header(Some("1")));
463 assert!(has_bridge_header(Some(" 1 ")));
464 assert!(!has_bridge_header(Some("0")));
465 assert!(!has_bridge_header(None));
466 }
467
468 #[test]
469 fn host_allowlist_blocks_rebinding() {
470 assert!(host_allowed("localhost:9998", 9998));
471 assert!(host_allowed("127.0.0.1:9998", 9998));
472 assert!(host_allowed("[::1]:9998", 9998));
473 assert!(!host_allowed("evil.example.com:9998", 9998));
474 assert!(!host_allowed("localhost:9999", 9998));
475 assert!(!host_allowed("localhost", 9998));
476 }
477
478 #[test]
479 fn browser_origin_is_rejected() {
480 assert!(is_browser_originated(Some("https://evil.test"), None));
481 assert!(is_browser_originated(None, Some("cross-site")));
482 assert!(is_browser_originated(None, Some("same-site")));
483 assert!(!is_browser_originated(None, None));
484 assert!(!is_browser_originated(None, Some("none")));
485 }
486
487 #[test]
488 fn header_crlf_is_rejected() {
489 assert!(header_is_safe("Accept", "application/json"));
490 assert!(!header_is_safe("X\r\nEvil", "v"));
491 assert!(!header_is_safe("X", "a\r\nSet-Cookie: y"));
492 assert!(!header_is_safe("", "v"));
493 }
494
495 #[test]
496 fn path_normalization_rejects_traversal() {
497 assert_eq!(
498 normalize_request_path("/loki/api/v1/labels").as_deref(),
499 Some("/loki/api/v1/labels")
500 );
501 assert_eq!(
502 normalize_request_path("/a/%2e%2e/b"),
503 Some("/a/../b".to_string()).filter(|_| false).or(None)
504 );
505 assert!(normalize_request_path("/a/../b").is_none());
506 assert!(normalize_request_path("/a/%2e%2e/b").is_none());
507 assert!(normalize_request_path("/a/%00/b").is_none());
508 assert!(normalize_request_path("/bad%2").is_none());
509 }
510
511 #[test]
512 fn outbound_scope_is_default_closed() {
513 assert_eq!(validate_outbound_url("/api/foo", &[]), Ok(()));
514 assert_eq!(
515 validate_outbound_url("https://evil.test/x", &[]),
516 Err(ScopeError::CrossOriginDenied)
517 );
518 assert_eq!(
519 validate_outbound_url("//evil.test/x", &[]),
520 Err(ScopeError::CrossOriginDenied)
521 );
522 }
523
524 #[test]
525 fn outbound_scope_honors_allowed_origins() {
526 assert_eq!(
527 validate_outbound_url("https://ok.test/x", &["https://ok.test"]),
528 Ok(())
529 );
530 assert_eq!(
531 validate_outbound_url("https://evil.test/x", &["https://ok.test"]),
532 Err(ScopeError::CrossOriginDenied)
533 );
534 assert_eq!(
536 validate_outbound_url("https://b.test/x", &["https://a.test", "https://b.test"]),
537 Ok(())
538 );
539 assert_eq!(validate_outbound_url("/x", &["https://ok.test"]), Ok(()));
541 }
542
543 #[test]
544 fn ws_subprotocol_token_selects_match() {
545 assert_eq!(ws_subprotocol_token(["a", "tok", "b"], "tok"), Some("tok"));
546 assert_eq!(ws_subprotocol_token(["a", "b"], "tok"), None);
547 assert_eq!(ws_subprotocol_token([], "tok"), None);
548 }
549
550 #[test]
551 fn empty_allowlist_opens_the_ws_gate() {
552 let empty = OriginAllowlist::default();
553 assert!(empty.is_empty());
554 assert!(empty.permits_connection(Some("https://anything.test")));
556 assert!(empty.permits_connection(None));
557 assert!(empty.outbound_for(Some("https://anything.test")).is_empty());
559 }
560
561 #[test]
562 fn allowlist_gates_connection_by_configured_key() {
563 let list = OriginAllowlist::parse(&["https://ok.test"]).unwrap();
564 assert!(list.permits_connection(Some("https://ok.test")));
565 assert!(list.permits_connection(Some("https://ok.test:443")));
567 assert!(!list.permits_connection(Some("https://evil.test")));
568 assert!(!list.permits_connection(None));
570 }
571
572 #[test]
573 fn shorthand_grants_a_tab_its_own_origin() {
574 let list = OriginAllowlist::parse(&["https://grafana.internal"]).unwrap();
575 assert_eq!(
576 list.outbound_for(Some("https://grafana.internal")),
577 vec!["https://grafana.internal"]
578 );
579 assert!(list.outbound_for(Some("https://other.test")).is_empty());
581 assert!(list.outbound_for(None).is_empty());
582 }
583
584 #[test]
585 fn mapping_scopes_outbound_per_connecting_origin() {
586 let list = OriginAllowlist::parse(&[
588 "https://grafana.internal",
589 "https://www.facebook.com=https://static.xx.fbcdn.net",
590 ])
591 .unwrap();
592 assert_eq!(
593 list.outbound_for(Some("https://grafana.internal")),
594 vec!["https://grafana.internal"]
595 );
596 assert_eq!(
597 list.outbound_for(Some("https://www.facebook.com")),
598 vec!["https://static.xx.fbcdn.net"]
599 );
600 assert_eq!(
602 validate_outbound_url(
603 "https://static.xx.fbcdn.net/x",
604 &list.outbound_for(Some("https://grafana.internal"))
605 ),
606 Err(ScopeError::CrossOriginDenied)
607 );
608 }
609
610 #[test]
611 fn repeated_keys_accumulate_outbound_origins() {
612 let list = OriginAllowlist::parse(&[
613 "https://app.test=https://a.cdn.test",
614 "https://app.test=https://b.cdn.test",
615 ])
616 .unwrap();
617 assert_eq!(
618 list.outbound_for(Some("https://app.test")),
619 vec!["https://a.cdn.test", "https://b.cdn.test"]
620 );
621 }
622
623 #[test]
624 fn parse_rejects_malformed_and_empty_values() {
625 assert!(OriginAllowlist::parse(&["not a url"]).is_err());
626 assert!(OriginAllowlist::parse(&["https://ok.test=nonsense"]).is_err());
627 assert!(OriginAllowlist::parse(&[""]).is_err());
628 assert!(OriginAllowlist::parse(&["=https://ok.test"]).is_err());
629 }
630
631 #[cfg(unix)]
632 #[test]
633 fn token_file_requires_0600() {
634 use std::io::Write;
635 use std::os::unix::fs::PermissionsExt;
636 let dir = tempfile::tempdir().unwrap();
637 let path = dir.path().join("tok");
638 let mut f = std::fs::File::create(&path).unwrap();
639 writeln!(f, "secret-token").unwrap();
640 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
641 assert!(resolve_token(Some(&path)).is_err());
642 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
643 assert_eq!(resolve_token(Some(&path)).unwrap(), "secret-token");
644 }
645
646 use crate::test_support::env::MapEnv;
653
654 #[test]
655 fn resolve_token_reads_trimmed_env_var() {
656 let env = MapEnv::new().with(TOKEN_ENV, " env-token ");
657 assert_eq!(resolve_token_with(&env, None).unwrap(), "env-token");
658 }
659
660 #[test]
661 fn resolve_token_generates_when_env_empty_or_absent() {
662 let a = resolve_token_with(&MapEnv::new(), None).unwrap();
664 assert!(a.len() >= 40);
665 let env = MapEnv::new().with(TOKEN_ENV, " ");
667 let b = resolve_token_with(&env, None).unwrap();
668 assert!(b.len() >= 40);
669 assert_ne!(a, b);
670 }
671
672 #[test]
673 fn resolve_existing_token_reads_env_var() {
674 let env = MapEnv::new().with(TOKEN_ENV, "client-token");
675 assert_eq!(
676 resolve_existing_token_with(&env, None).unwrap(),
677 "client-token"
678 );
679 }
680
681 #[test]
682 fn resolve_existing_token_errors_without_source() {
683 let err = resolve_existing_token_with(&MapEnv::new(), None).unwrap_err();
684 assert!(err.to_string().contains(TOKEN_ENV));
685 }
686
687 #[test]
688 fn resolve_existing_token_reads_file() {
689 let dir = tempfile::tempdir().unwrap();
690 let path = dir.path().join("tok");
691 std::fs::write(&path, " file-token\n").unwrap();
692 #[cfg(unix)]
693 {
694 use std::os::unix::fs::PermissionsExt;
695 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
696 }
697 assert_eq!(resolve_existing_token(Some(&path)).unwrap(), "file-token");
698 }
699
700 #[test]
701 fn token_file_missing_errors() {
702 let dir = tempfile::tempdir().unwrap();
703 let path = dir.path().join("does-not-exist");
704 assert!(resolve_token(Some(&path)).is_err());
705 }
706
707 #[test]
708 fn token_file_empty_errors() {
709 let dir = tempfile::tempdir().unwrap();
710 let path = dir.path().join("empty");
711 std::fs::write(&path, " \n").unwrap();
712 #[cfg(unix)]
713 {
714 use std::os::unix::fs::PermissionsExt;
715 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
716 }
717 let err = resolve_token(Some(&path)).unwrap_err();
718 assert!(err.to_string().contains("empty"));
719 }
720}