1use std::path::Path;
14
15use anyhow::{bail, Context, Result};
16use base64::Engine;
17use rand::RngCore;
18
19pub const TOKEN_ENV: &str = "OMNI_BRIDGE_TOKEN";
23
24pub const BRIDGE_HEADER: &str = "x-omni-bridge";
27
28pub const BRIDGE_HEADER_VALUE: &str = "1";
30
31pub const BRIDGE_TARGET_HEADER: &str = "x-omni-bridge-target";
36
37const TOKEN_BYTES: usize = 32;
39
40#[must_use]
42pub fn generate_token() -> String {
43 let mut bytes = [0u8; TOKEN_BYTES];
44 rand::rng().fill_bytes(&mut bytes);
45 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
46}
47
48pub 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
69pub 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#[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#[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#[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#[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#[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#[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#[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 if decoded
196 .split('/')
197 .any(|seg| seg == ".." || seg == "..%2f" || seg == "..%2F")
198 {
199 return None;
200 }
201 Some(decoded)
202}
203
204fn 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#[derive(Debug, PartialEq, Eq)]
238pub enum ScopeError {
239 CrossOriginDenied,
241 Malformed,
243}
244
245pub fn validate_outbound_url(url: &str, allow_origin: Option<&str>) -> Result<(), ScopeError> {
251 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#[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#[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 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 static TOKEN_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
460
461 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 let a = resolve_token(None).unwrap();
499 assert!(a.len() >= 40);
500 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}