1use std::path::Path;
14
15use anyhow::{bail, Context, Result};
16use base64::Engine;
17use rand::Rng;
18
19use crate::utils::env::{EnvSource, SystemEnv};
20
21pub const TOKEN_ENV: &str = "OMNI_BRIDGE_TOKEN";
25
26pub const BRIDGE_HEADER: &str = "x-omni-bridge";
29
30pub const BRIDGE_HEADER_VALUE: &str = "1";
32
33pub const BRIDGE_TARGET_HEADER: &str = "x-omni-bridge-target";
38
39const TOKEN_BYTES: usize = 32;
41
42#[must_use]
44pub fn generate_token() -> String {
45 let mut bytes = [0u8; TOKEN_BYTES];
46 rand::rng().fill_bytes(&mut bytes);
47 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
48}
49
50pub fn resolve_token(token_file: Option<&Path>) -> Result<String> {
59 resolve_token_with(&SystemEnv, token_file)
60}
61
62pub(crate) fn resolve_token_with(
65 env: &impl EnvSource,
66 token_file: Option<&Path>,
67) -> Result<String> {
68 if let Some(path) = token_file {
69 return read_token_file(path);
70 }
71 if let Some(value) = env.var(TOKEN_ENV) {
72 let trimmed = value.trim();
73 if !trimmed.is_empty() {
74 return Ok(trimmed.to_string());
75 }
76 }
77 Ok(generate_token())
78}
79
80pub fn resolve_existing_token(token_file: Option<&Path>) -> Result<String> {
86 resolve_existing_token_with(&SystemEnv, token_file)
87}
88
89pub(crate) fn resolve_existing_token_with(
92 env: &impl EnvSource,
93 token_file: Option<&Path>,
94) -> Result<String> {
95 if let Some(path) = token_file {
96 return read_token_file(path);
97 }
98 match env.var(TOKEN_ENV) {
99 Some(value) if !value.trim().is_empty() => Ok(value.trim().to_string()),
100 _ => bail!(
101 "No session token found. Set {TOKEN_ENV} or pass --token-file with the token the \
102 running bridge printed."
103 ),
104 }
105}
106
107fn read_token_file(path: &Path) -> Result<String> {
108 #[cfg(unix)]
109 {
110 use std::os::unix::fs::PermissionsExt;
111 let meta = std::fs::metadata(path)
112 .with_context(|| format!("Failed to stat token file {}", path.display()))?;
113 let mode = meta.permissions().mode() & 0o777;
114 if mode & 0o077 != 0 {
115 bail!(
116 "Token file {} must be 0600 (owner-only); found {:o}",
117 path.display(),
118 mode
119 );
120 }
121 }
122 let contents = std::fs::read_to_string(path)
123 .with_context(|| format!("Failed to read token file {}", path.display()))?;
124 let trimmed = contents.trim();
125 if trimmed.is_empty() {
126 bail!("Token file {} is empty", path.display());
127 }
128 Ok(trimmed.to_string())
129}
130
131#[must_use]
133pub fn constant_time_eq(a: &str, b: &str) -> bool {
134 let (a, b) = (a.as_bytes(), b.as_bytes());
135 if a.len() != b.len() {
136 return false;
137 }
138 let mut diff = 0u8;
139 for (x, y) in a.iter().zip(b.iter()) {
140 diff |= x ^ y;
141 }
142 diff == 0
143}
144
145#[must_use]
149pub fn bearer_matches(authorization: Option<&str>, token: &str) -> bool {
150 let Some(value) = authorization else {
151 return false;
152 };
153 let Some(presented) = value.strip_prefix("Bearer ") else {
154 return false;
155 };
156 constant_time_eq(presented.trim(), token)
157}
158
159#[must_use]
161pub fn has_bridge_header(value: Option<&str>) -> bool {
162 value.is_some_and(|v| v.trim() == BRIDGE_HEADER_VALUE)
163}
164
165#[must_use]
170pub fn host_allowed(host: &str, control_port: u16) -> bool {
171 let allowed = [
172 format!("localhost:{control_port}"),
173 format!("127.0.0.1:{control_port}"),
174 format!("[::1]:{control_port}"),
175 ];
176 allowed.iter().any(|a| a == host)
177}
178
179#[must_use]
185pub fn is_browser_originated(origin: Option<&str>, sec_fetch_site: Option<&str>) -> bool {
186 if origin.is_some() {
187 return true;
188 }
189 matches!(
190 sec_fetch_site.map(str::trim),
191 Some("cross-site" | "same-site" | "same-origin")
192 )
193}
194
195#[must_use]
198pub fn header_is_safe(name: &str, value: &str) -> bool {
199 let bad = |s: &str| s.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0);
200 !name.is_empty() && !bad(name) && !bad(value)
201}
202
203#[must_use]
209pub fn normalize_request_path(raw: &str) -> Option<String> {
210 let decoded = percent_decode(raw)?;
211 if decoded.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
212 return None;
213 }
214 if decoded
216 .split('/')
217 .any(|seg| seg == ".." || seg == "..%2f" || seg == "..%2F")
218 {
219 return None;
220 }
221 Some(decoded)
222}
223
224fn percent_decode(raw: &str) -> Option<String> {
227 let bytes = raw.as_bytes();
228 let mut out = Vec::with_capacity(bytes.len());
229 let mut i = 0;
230 while i < bytes.len() {
231 match bytes[i] {
232 b'%' => {
233 let hi = bytes.get(i + 1).copied().and_then(hex_val)?;
234 let lo = bytes.get(i + 2).copied().and_then(hex_val)?;
235 out.push(hi << 4 | lo);
236 i += 3;
237 }
238 b => {
239 out.push(b);
240 i += 1;
241 }
242 }
243 }
244 String::from_utf8(out).ok()
245}
246
247fn hex_val(b: u8) -> Option<u8> {
248 match b {
249 b'0'..=b'9' => Some(b - b'0'),
250 b'a'..=b'f' => Some(b - b'a' + 10),
251 b'A'..=b'F' => Some(b - b'A' + 10),
252 _ => None,
253 }
254}
255
256#[derive(Debug, PartialEq, Eq)]
258pub enum ScopeError {
259 CrossOriginDenied,
261 Malformed,
263}
264
265pub fn validate_outbound_url(url: &str, allow_origin: Option<&str>) -> Result<(), ScopeError> {
271 let is_relative = url.starts_with('/') && !url.starts_with("//");
273 if is_relative {
274 if url.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
275 return Err(ScopeError::Malformed);
276 }
277 return Ok(());
278 }
279
280 let allow = allow_origin.ok_or(ScopeError::CrossOriginDenied)?;
281 let target = url::Url::parse(url).map_err(|_| ScopeError::Malformed)?;
282 let allowed = url::Url::parse(allow).map_err(|_| ScopeError::Malformed)?;
283 if origins_match(&target, &allowed) {
284 Ok(())
285 } else {
286 Err(ScopeError::CrossOriginDenied)
287 }
288}
289
290fn origins_match(a: &url::Url, b: &url::Url) -> bool {
291 a.scheme() == b.scheme()
292 && a.host_str() == b.host_str()
293 && a.port_or_known_default() == b.port_or_known_default()
294}
295
296#[must_use]
301pub fn ws_subprotocol_token<'a, I>(subprotocols: I, token: &str) -> Option<&'a str>
302where
303 I: IntoIterator<Item = &'a str>,
304{
305 subprotocols
306 .into_iter()
307 .map(str::trim)
308 .find(|p| constant_time_eq(p, token))
309}
310
311#[must_use]
316pub fn ws_origin_allowed(origin: Option<&str>, allow_origin: Option<&str>) -> bool {
317 match allow_origin {
318 None => true,
319 Some(allowed) => origin.is_some_and(|o| {
320 url::Url::parse(o)
321 .ok()
322 .zip(url::Url::parse(allowed).ok())
323 .is_some_and(|(o, a)| origins_match(&o, &a))
324 }),
325 }
326}
327
328#[cfg(test)]
329#[allow(clippy::unwrap_used, clippy::expect_used)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn generated_tokens_are_unique_and_urlsafe() {
335 let a = generate_token();
336 let b = generate_token();
337 assert_ne!(a, b);
338 assert!(a
339 .chars()
340 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
341 assert!(a.len() >= 40);
342 }
343
344 #[test]
345 fn constant_time_eq_matches_str_eq() {
346 assert!(constant_time_eq("abc", "abc"));
347 assert!(!constant_time_eq("abc", "abd"));
348 assert!(!constant_time_eq("abc", "abcd"));
349 }
350
351 #[test]
352 fn bearer_accepts_only_correct_token() {
353 assert!(bearer_matches(Some("Bearer tok"), "tok"));
354 assert!(!bearer_matches(Some("Bearer wrong"), "tok"));
355 assert!(!bearer_matches(Some("tok"), "tok"));
356 assert!(!bearer_matches(None, "tok"));
357 }
358
359 #[test]
360 fn bridge_header_must_be_one() {
361 assert!(has_bridge_header(Some("1")));
362 assert!(has_bridge_header(Some(" 1 ")));
363 assert!(!has_bridge_header(Some("0")));
364 assert!(!has_bridge_header(None));
365 }
366
367 #[test]
368 fn host_allowlist_blocks_rebinding() {
369 assert!(host_allowed("localhost:9998", 9998));
370 assert!(host_allowed("127.0.0.1:9998", 9998));
371 assert!(host_allowed("[::1]:9998", 9998));
372 assert!(!host_allowed("evil.example.com:9998", 9998));
373 assert!(!host_allowed("localhost:9999", 9998));
374 assert!(!host_allowed("localhost", 9998));
375 }
376
377 #[test]
378 fn browser_origin_is_rejected() {
379 assert!(is_browser_originated(Some("https://evil.test"), None));
380 assert!(is_browser_originated(None, Some("cross-site")));
381 assert!(is_browser_originated(None, Some("same-site")));
382 assert!(!is_browser_originated(None, None));
383 assert!(!is_browser_originated(None, Some("none")));
384 }
385
386 #[test]
387 fn header_crlf_is_rejected() {
388 assert!(header_is_safe("Accept", "application/json"));
389 assert!(!header_is_safe("X\r\nEvil", "v"));
390 assert!(!header_is_safe("X", "a\r\nSet-Cookie: y"));
391 assert!(!header_is_safe("", "v"));
392 }
393
394 #[test]
395 fn path_normalization_rejects_traversal() {
396 assert_eq!(
397 normalize_request_path("/loki/api/v1/labels").as_deref(),
398 Some("/loki/api/v1/labels")
399 );
400 assert_eq!(
401 normalize_request_path("/a/%2e%2e/b"),
402 Some("/a/../b".to_string()).filter(|_| false).or(None)
403 );
404 assert!(normalize_request_path("/a/../b").is_none());
405 assert!(normalize_request_path("/a/%2e%2e/b").is_none());
406 assert!(normalize_request_path("/a/%00/b").is_none());
407 assert!(normalize_request_path("/bad%2").is_none());
408 }
409
410 #[test]
411 fn outbound_scope_is_default_closed() {
412 assert_eq!(validate_outbound_url("/api/foo", None), Ok(()));
413 assert_eq!(
414 validate_outbound_url("https://evil.test/x", None),
415 Err(ScopeError::CrossOriginDenied)
416 );
417 assert_eq!(
418 validate_outbound_url("//evil.test/x", None),
419 Err(ScopeError::CrossOriginDenied)
420 );
421 }
422
423 #[test]
424 fn outbound_scope_honors_allow_origin() {
425 assert_eq!(
426 validate_outbound_url("https://ok.test/x", Some("https://ok.test")),
427 Ok(())
428 );
429 assert_eq!(
430 validate_outbound_url("https://evil.test/x", Some("https://ok.test")),
431 Err(ScopeError::CrossOriginDenied)
432 );
433 assert_eq!(validate_outbound_url("/x", Some("https://ok.test")), Ok(()));
435 }
436
437 #[test]
438 fn ws_subprotocol_token_selects_match() {
439 assert_eq!(ws_subprotocol_token(["a", "tok", "b"], "tok"), Some("tok"));
440 assert_eq!(ws_subprotocol_token(["a", "b"], "tok"), None);
441 assert_eq!(ws_subprotocol_token([], "tok"), None);
442 }
443
444 #[test]
445 fn ws_origin_allowed_logic() {
446 assert!(ws_origin_allowed(Some("https://anything.test"), None));
447 assert!(ws_origin_allowed(None, None));
448 assert!(ws_origin_allowed(
449 Some("https://ok.test"),
450 Some("https://ok.test")
451 ));
452 assert!(!ws_origin_allowed(
453 Some("https://evil.test"),
454 Some("https://ok.test")
455 ));
456 assert!(!ws_origin_allowed(None, Some("https://ok.test")));
457 }
458
459 #[cfg(unix)]
460 #[test]
461 fn token_file_requires_0600() {
462 use std::io::Write;
463 use std::os::unix::fs::PermissionsExt;
464 let dir = tempfile::tempdir().unwrap();
465 let path = dir.path().join("tok");
466 let mut f = std::fs::File::create(&path).unwrap();
467 writeln!(f, "secret-token").unwrap();
468 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
469 assert!(resolve_token(Some(&path)).is_err());
470 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
471 assert_eq!(resolve_token(Some(&path)).unwrap(), "secret-token");
472 }
473
474 use crate::test_support::env::MapEnv;
481
482 #[test]
483 fn resolve_token_reads_trimmed_env_var() {
484 let env = MapEnv::new().with(TOKEN_ENV, " env-token ");
485 assert_eq!(resolve_token_with(&env, None).unwrap(), "env-token");
486 }
487
488 #[test]
489 fn resolve_token_generates_when_env_empty_or_absent() {
490 let a = resolve_token_with(&MapEnv::new(), None).unwrap();
492 assert!(a.len() >= 40);
493 let env = MapEnv::new().with(TOKEN_ENV, " ");
495 let b = resolve_token_with(&env, None).unwrap();
496 assert!(b.len() >= 40);
497 assert_ne!(a, b);
498 }
499
500 #[test]
501 fn resolve_existing_token_reads_env_var() {
502 let env = MapEnv::new().with(TOKEN_ENV, "client-token");
503 assert_eq!(
504 resolve_existing_token_with(&env, None).unwrap(),
505 "client-token"
506 );
507 }
508
509 #[test]
510 fn resolve_existing_token_errors_without_source() {
511 let err = resolve_existing_token_with(&MapEnv::new(), None).unwrap_err();
512 assert!(err.to_string().contains(TOKEN_ENV));
513 }
514
515 #[test]
516 fn resolve_existing_token_reads_file() {
517 let dir = tempfile::tempdir().unwrap();
518 let path = dir.path().join("tok");
519 std::fs::write(&path, " file-token\n").unwrap();
520 #[cfg(unix)]
521 {
522 use std::os::unix::fs::PermissionsExt;
523 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
524 }
525 assert_eq!(resolve_existing_token(Some(&path)).unwrap(), "file-token");
526 }
527
528 #[test]
529 fn token_file_missing_errors() {
530 let dir = tempfile::tempdir().unwrap();
531 let path = dir.path().join("does-not-exist");
532 assert!(resolve_token(Some(&path)).is_err());
533 }
534
535 #[test]
536 fn token_file_empty_errors() {
537 let dir = tempfile::tempdir().unwrap();
538 let path = dir.path().join("empty");
539 std::fs::write(&path, " \n").unwrap();
540 #[cfg(unix)]
541 {
542 use std::os::unix::fs::PermissionsExt;
543 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
544 }
545 let err = resolve_token(Some(&path)).unwrap_err();
546 assert!(err.to_string().contains("empty"));
547 }
548}