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
40pub const BRIDGE_ORIGIN_HEADER: &str = "x-omni-bridge-origin";
47
48const TOKEN_BYTES: usize = 32;
50
51#[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
59pub fn resolve_token(token_file: Option<&Path>) -> Result<String> {
68 resolve_token_with(&SystemEnv, token_file)
69}
70
71pub(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
89pub fn resolve_existing_token(token_file: Option<&Path>) -> Result<String> {
95 resolve_existing_token_with(&SystemEnv, token_file)
96}
97
98pub(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#[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#[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#[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#[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#[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#[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#[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 if decoded
225 .split('/')
226 .any(|seg| seg == ".." || seg == "..%2f" || seg == "..%2F")
227 {
228 return None;
229 }
230 Some(decoded)
231}
232
233fn 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#[derive(Debug, PartialEq, Eq)]
267pub enum ScopeError {
268 CrossOriginDenied,
270 Malformed,
272}
273
274pub fn validate_outbound_url(url: &str, allowed: &[&str]) -> Result<(), ScopeError> {
284 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 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
316fn 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
340pub struct OriginAllowlist {
341 map: BTreeMap<String, BTreeSet<String>>,
343}
344
345impl OriginAllowlist {
346 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 #[must_use]
376 pub fn is_empty(&self) -> bool {
377 self.map.is_empty()
378 }
379
380 #[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 #[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 #[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#[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 assert_eq!(
544 validate_outbound_url("https://b.test/x", &["https://a.test", "https://b.test"]),
545 Ok(())
546 );
547 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 assert!(empty.permits_connection(Some("https://anything.test")));
564 assert!(empty.permits_connection(None));
565 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 assert!(list.permits_connection(Some("https://ok.test:443")));
575 assert!(!list.permits_connection(Some("https://evil.test")));
576 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 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 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 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 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 let a = resolve_token_with(&MapEnv::new(), None).unwrap();
672 assert!(a.len() >= 40);
673 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}