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};
21use crate::utils::secret_env::{file_var_name, secret_var};
22
23pub const TOKEN_ENV: &str = "OMNI_BRIDGE_TOKEN";
27
28pub const BRIDGE_HEADER: &str = "x-omni-bridge";
31
32pub const BRIDGE_HEADER_VALUE: &str = "1";
34
35pub const BRIDGE_TARGET_HEADER: &str = "x-omni-bridge-target";
40
41pub const BRIDGE_ORIGIN_HEADER: &str = "x-omni-bridge-origin";
48
49const TOKEN_BYTES: usize = 32;
51
52#[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
60pub fn resolve_token(token_file: Option<&Path>) -> Result<String> {
71 resolve_token_with(&SystemEnv, token_file)
72}
73
74pub(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 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
98pub fn resolve_existing_token(token_file: Option<&Path>) -> Result<String> {
104 resolve_existing_token_with(&SystemEnv, token_file)
105}
106
107pub(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#[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#[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#[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#[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#[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#[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#[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 if decoded
236 .split('/')
237 .any(|seg| seg == ".." || seg == "..%2f" || seg == "..%2F")
238 {
239 return None;
240 }
241 Some(decoded)
242}
243
244fn 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#[derive(Debug, PartialEq, Eq)]
278pub enum ScopeError {
279 CrossOriginDenied,
281 Malformed,
283}
284
285pub fn validate_outbound_url(url: &str, allowed: &[&str]) -> Result<(), ScopeError> {
295 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 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
327fn 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
351pub struct OriginAllowlist {
352 map: BTreeMap<String, BTreeSet<String>>,
354}
355
356impl OriginAllowlist {
357 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 #[must_use]
387 pub fn is_empty(&self) -> bool {
388 self.map.is_empty()
389 }
390
391 #[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 #[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 #[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#[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 assert_eq!(
552 validate_outbound_url("https://b.test/x", &["https://a.test", "https://b.test"]),
553 Ok(())
554 );
555 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 assert!(empty.permits_connection(Some("https://anything.test")));
572 assert!(empty.permits_connection(None));
573 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 assert!(list.permits_connection(Some("https://ok.test:443")));
583 assert!(!list.permits_connection(Some("https://evil.test")));
584 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 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 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 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 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 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 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 let a = resolve_token_with(&MapEnv::new(), None).unwrap();
699 assert!(a.len() >= 40);
700 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}