1use std::cell::Cell;
9
10use http::Uri;
11use url::{SyntaxViolation, Url};
12
13use crate::{Result, UtilsError, net::is_loopback_host};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct HttpBaseUrlOptions {
18 pub allow_empty: bool,
20 pub forbid_query_fragment: bool,
22}
23
24impl HttpBaseUrlOptions {
25 #[must_use]
27 pub const fn required_without_query_fragment() -> Self {
28 Self {
29 allow_empty: false,
30 forbid_query_fragment: true,
31 }
32 }
33
34 #[must_use]
36 pub const fn optional_without_query_fragment() -> Self {
37 Self {
38 allow_empty: true,
39 forbid_query_fragment: true,
40 }
41 }
42}
43
44#[must_use]
46pub fn has_http_scheme(url: &Url) -> bool {
47 matches!(url.scheme(), "http" | "https")
48}
49
50pub fn parse_url(value: &str, context: &str) -> Result<Url> {
60 Url::parse(value).map_err(|error| UtilsError::invalid_value(format!("{context}: {error}")))
61}
62
63pub fn is_https_or_loopback_http(url: &Url) -> bool {
68 url.scheme() == "https"
69 || (url.scheme() == "http" && url.host_str().is_some_and(is_loopback_host))
70}
71
72pub fn parse_absolute_url(value: &str, label: &str) -> Result<Url> {
78 Url::parse(value).map_err(|error| {
79 UtilsError::invalid_value(format!("{label} must be an absolute URL: {error}"))
80 })
81}
82
83pub fn url_with_credentials(
98 base_url: &str,
99 username: Option<&str>,
100 password: Option<&str>,
101 context: &str,
102) -> Result<Url> {
103 let has_explicit_userinfo = Cell::new(false);
104 let record_syntax_violation = |violation| {
105 if violation == SyntaxViolation::EmbeddedCredentials {
106 has_explicit_userinfo.set(true);
107 }
108 };
109 let mut url = Url::options()
110 .syntax_violation_callback(Some(&record_syntax_violation))
111 .parse(base_url)
112 .map_err(|error| {
113 UtilsError::invalid_value(format!("{context} must be an absolute URL: {error}"))
114 })?;
115 if url.host_str().is_none_or(str::is_empty) || url.cannot_be_a_base() {
116 return Err(UtilsError::invalid_value(format!(
117 "{context} must include an authority host for credentials"
118 )));
119 }
120
121 if has_explicit_userinfo.get() || !url.username().is_empty() || url.password().is_some() {
122 return Err(UtilsError::invalid_value(format!(
123 "{context} must not already include userinfo"
124 )));
125 }
126
127 if username.is_none() && password.is_none() {
128 return Ok(url);
129 }
130
131 url.set_username(username.unwrap_or_default())
132 .map_err(|()| {
133 UtilsError::invalid_value(format!("{context} does not support username credentials"))
134 })?;
135 url.set_password(password).map_err(|()| {
136 UtilsError::invalid_value(format!("{context} does not support password credentials"))
137 })?;
138 Ok(url)
139}
140
141pub fn parse_http_url(value: &str, label: &str) -> Result<Url> {
151 let normalized = value.trim();
152 if normalized.is_empty() {
153 return Err(UtilsError::invalid_value(format!(
154 "{label} cannot be empty"
155 )));
156 }
157
158 let parsed = parse_absolute_url(normalized, label)?;
159 if !has_http_scheme(&parsed) || parsed.host_str().is_none() {
160 return Err(UtilsError::invalid_value(format!(
161 "{label} must use http or https and include a host"
162 )));
163 }
164 Ok(parsed)
165}
166
167pub fn normalize_http_base_url(
178 value: &str,
179 label: &str,
180 options: HttpBaseUrlOptions,
181) -> Result<Option<String>> {
182 let normalized = value.trim().trim_end_matches('/').to_string();
183 if normalized.is_empty() {
184 if options.allow_empty {
185 return Ok(None);
186 }
187 return Err(UtilsError::invalid_value(format!(
188 "{label} cannot be empty"
189 )));
190 }
191
192 let parsed = Url::parse(&normalized).map_err(|error| {
193 UtilsError::invalid_value(format!(
194 "{label} must be an absolute http/https URL: {error}"
195 ))
196 })?;
197 if !has_http_scheme(&parsed) || parsed.host_str().is_none() {
198 return Err(UtilsError::invalid_value(format!(
199 "{label} must use http or https and include a host"
200 )));
201 }
202 if options.forbid_query_fragment && (parsed.query().is_some() || parsed.fragment().is_some()) {
203 return Err(UtilsError::invalid_value(format!(
204 "{label} cannot include query or fragment"
205 )));
206 }
207
208 Ok(Some(normalized))
209}
210
211pub fn normalize_origin(origin: &str, allow_wildcard: bool) -> Result<String> {
221 normalize_origin_with_additional_schemes(origin, allow_wildcard, &[])
222}
223
224pub fn normalize_origin_with_additional_schemes(
234 origin: &str,
235 allow_wildcard: bool,
236 additional_schemes: &[&str],
237) -> Result<String> {
238 let trimmed = origin.trim();
239 if trimmed.is_empty() {
240 return Err(UtilsError::invalid_value("origin cannot be empty"));
241 }
242
243 if allow_wildcard && trimmed == "*" {
244 return Ok("*".to_string());
245 }
246
247 let uri: Uri = trimmed
248 .parse()
249 .map_err(|_| UtilsError::invalid_value(format!("invalid origin '{trimmed}'")))?;
250
251 let scheme = uri.scheme_str().ok_or_else(|| {
252 UtilsError::invalid_value(format!(
253 "origin must include http:// or https://: '{trimmed}'"
254 ))
255 })?;
256
257 if scheme != "http" && scheme != "https" && !additional_schemes.contains(&scheme) {
258 return Err(UtilsError::invalid_value(format!(
259 "origin scheme is not supported: '{trimmed}'"
260 )));
261 }
262
263 let authority = uri.authority().ok_or_else(|| {
264 UtilsError::invalid_value(format!("origin must include a host: '{trimmed}'"))
265 })?;
266
267 if authority.as_str().contains('@') {
268 return Err(UtilsError::invalid_value(format!(
269 "origin must not include userinfo: '{trimmed}'"
270 )));
271 }
272
273 if uri.path_and_query().and_then(|pq| pq.query()).is_some() {
274 return Err(UtilsError::invalid_value(format!(
275 "origin must not include query parameters: '{trimmed}'"
276 )));
277 }
278
279 let path = uri.path();
280 if !path.is_empty() && path != "/" {
281 return Err(UtilsError::invalid_value(format!(
282 "origin must not include a path: '{trimmed}'"
283 )));
284 }
285
286 Ok(format!(
287 "{}://{}",
288 scheme.to_ascii_lowercase(),
289 authority.as_str().to_ascii_lowercase()
290 ))
291}
292
293pub fn parse_public_site_origin_entries(value: &str) -> Result<Vec<String>> {
303 let trimmed = value.trim();
304 if trimmed.is_empty() {
305 return Err(UtilsError::invalid_value(
306 "public_site_url must be a JSON array of origins",
307 ));
308 }
309
310 let entries = serde_json::from_str::<Vec<String>>(trimmed).map_err(|error| {
311 UtilsError::invalid_value(format!(
312 "public_site_url must be a JSON array of origins: {error}"
313 ))
314 })?;
315
316 Ok(entries
317 .into_iter()
318 .map(|origin| origin.trim().to_string())
319 .filter(|origin| !origin.is_empty())
320 .collect())
321}
322
323pub fn normalize_public_site_origin(origin: &str) -> Result<String> {
332 if origin.trim() == "*" {
333 return Err(UtilsError::invalid_value(
334 "public_site_url does not support wildcard origins",
335 ));
336 }
337
338 normalize_origin(origin, false).map_err(|error| {
339 UtilsError::invalid_value(format!(
340 "invalid public_site_url origin '{origin}': {error}"
341 ))
342 })
343}
344
345pub fn parse_public_site_origins(value: &str) -> Result<Vec<String>> {
352 let mut origins = Vec::new();
353 for origin in parse_public_site_origin_entries(value)? {
354 let normalized = normalize_public_site_origin(&origin)?;
355 if !origins.contains(&normalized) {
356 origins.push(normalized);
357 }
358 }
359
360 Ok(origins)
361}
362
363pub fn normalize_public_site_origins_config_value(value: &str) -> Result<String> {
370 let origins = parse_public_site_origins(value)?;
371 serde_json::to_string(&origins).map_err(|error| {
372 UtilsError::invalid_value(format!(
373 "failed to serialize public_site_url origins: {error}"
374 ))
375 })
376}
377
378pub fn runtime_public_site_origins_with<F>(value: Option<&str>, mut on_invalid: F) -> Vec<String>
383where
384 F: FnMut(Option<&str>, &UtilsError),
385{
386 let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
387 return Vec::new();
388 };
389
390 let entries = match parse_public_site_origin_entries(value) {
391 Ok(entries) => entries,
392 Err(error) => {
393 on_invalid(None, &error);
394 return Vec::new();
395 }
396 };
397
398 let mut origins = Vec::new();
399 for origin in entries {
400 match normalize_public_site_origin(&origin) {
401 Ok(normalized) => {
402 if !origins.contains(&normalized) {
403 origins.push(normalized);
404 }
405 }
406 Err(error) => on_invalid(Some(&origin), &error),
407 }
408 }
409
410 origins
411}
412
413#[must_use]
416pub fn public_site_origin_for_request(
417 origins: &[String],
418 scheme: &str,
419 host: &str,
420) -> Option<String> {
421 if origins.is_empty() {
422 return None;
423 }
424
425 let request_origin = normalize_origin(&format!("{scheme}://{host}"), false).ok();
426 if let Some(request_origin) = request_origin
427 && origins.iter().any(|origin| origin == &request_origin)
428 {
429 return Some(request_origin);
430 }
431
432 origins.first().cloned()
433}
434
435#[must_use]
437pub fn join_origin_and_path(base: &str, path: &str) -> String {
438 let normalized_path = if path.starts_with('/') {
439 path.to_string()
440 } else {
441 format!("/{path}")
442 };
443
444 format!("{base}{normalized_path}")
445}
446
447#[cfg(test)]
448mod tests {
449 use super::{
450 HttpBaseUrlOptions, has_http_scheme, is_https_or_loopback_http, normalize_http_base_url,
451 normalize_origin, normalize_public_site_origins_config_value, parse_absolute_url,
452 parse_public_site_origins, parse_url, public_site_origin_for_request, url_with_credentials,
453 };
454 use crate::UtilsError;
455 use percent_encoding::percent_decode_str;
456 use url::Url;
457
458 fn decode_userinfo(value: &str) -> String {
459 percent_decode_str(value)
460 .decode_utf8()
461 .unwrap()
462 .into_owned()
463 }
464
465 #[test]
466 fn credentials_are_percent_encoded_once_and_round_trip() {
467 let username = "user#[]{}^+=*@:/?%\u{63a7}\u{5236}";
468 let password = "pass#[]{}^+=*@:/?%\u{0001}\u{5bc6}\u{7801}";
469 let url = url_with_credentials(
470 "postgres://db.example:5432/app?sslmode=require#driver",
471 Some(username),
472 Some(password),
473 "database base URL",
474 )
475 .unwrap();
476
477 assert_eq!(url.host_str(), Some("db.example"));
478 assert_eq!(url.path(), "/app");
479 assert_eq!(url.query(), Some("sslmode=require"));
480 assert_eq!(url.fragment(), Some("driver"));
481 assert_eq!(decode_userinfo(url.username()), username);
482 assert_eq!(decode_userinfo(url.password().unwrap()), password);
483 assert!(!url.as_str().contains(username));
484 assert!(!url.as_str().contains(password));
485 assert_eq!(Url::parse(url.as_str()).unwrap(), url);
486 }
487
488 #[test]
489 fn credentials_support_password_only_and_empty_username() {
490 let password_only = url_with_credentials(
491 "redis://cache.example:6379/2?protocol=resp3",
492 None,
493 Some("#secret[]"),
494 "Redis base URL",
495 )
496 .unwrap();
497 assert_eq!(password_only.username(), "");
498 assert_eq!(
499 decode_userinfo(password_only.password().unwrap()),
500 "#secret[]"
501 );
502
503 let empty_username = url_with_credentials(
504 "redis://cache.example:6379/2",
505 Some(""),
506 Some("secret"),
507 "Redis base URL",
508 )
509 .unwrap();
510 assert_eq!(empty_username.username(), "");
511 assert_eq!(empty_username.password(), Some("secret"));
512 }
513
514 #[test]
515 fn credentials_reject_existing_userinfo_without_exposing_raw_password() {
516 for base_url in [
517 "redis://user@cache.example/0",
518 "redis://:password@cache.example/0",
519 "redis://@cache.example/0",
520 ] {
521 let error = url_with_credentials(
522 base_url,
523 Some("replacement"),
524 Some("raw#replacement"),
525 "Redis base URL",
526 )
527 .unwrap_err();
528 assert!(matches!(error, UtilsError::InvalidValue(_)));
529 assert!(
530 error
531 .to_string()
532 .contains("must not already include userinfo")
533 );
534 assert!(!error.to_string().contains("raw#replacement"));
535 }
536 }
537
538 #[test]
539 fn credentials_reject_hostless_and_invalid_base_urls() {
540 for base_url in [
541 "mailto:user@example.com",
542 "data:text/plain,hello",
543 "redis+unix:///tmp/redis.sock",
544 ] {
545 let error =
546 url_with_credentials(base_url, None, Some("raw#secret"), "credential base URL")
547 .unwrap_err();
548 assert!(matches!(error, UtilsError::InvalidValue(_)));
549 assert!(
550 error
551 .to_string()
552 .contains("must include an authority host for credentials")
553 );
554 assert!(!error.to_string().contains("raw#secret"));
555 }
556
557 let error = url_with_credentials(
558 "not a URL",
559 Some("user"),
560 Some("raw#secret"),
561 "credential base URL",
562 )
563 .unwrap_err();
564 assert!(matches!(error, UtilsError::InvalidValue(_)));
565 assert!(!error.to_string().contains("raw#secret"));
566 }
567
568 #[test]
569 fn parse_url_maps_parser_errors_with_context() {
570 let parsed = parse_url("https://example.com/callback", "callback URL").unwrap();
571 assert_eq!(parsed.scheme(), "https");
572 assert_eq!(parsed.host_str(), Some("example.com"));
573
574 let error = parse_url("not a url", "callback URL").unwrap_err();
575 assert!(matches!(error, UtilsError::InvalidValue(_)));
576 assert!(error.to_string().contains("callback URL:"));
577 assert!(error.to_string().contains("relative URL without a base"));
578 }
579
580 #[test]
581 fn http_base_url_normalization_trims_and_removes_trailing_slashes() {
582 assert_eq!(
583 normalize_http_base_url(
584 " https://example.test/root// ",
585 "demo_url",
586 HttpBaseUrlOptions::required_without_query_fragment(),
587 )
588 .unwrap(),
589 Some("https://example.test/root".to_string())
590 );
591 }
592
593 #[test]
594 fn http_base_url_normalization_handles_empty_values() {
595 assert_eq!(
596 normalize_http_base_url(
597 " ",
598 "demo_url",
599 HttpBaseUrlOptions::optional_without_query_fragment(),
600 )
601 .unwrap(),
602 None
603 );
604 assert!(
605 normalize_http_base_url(
606 " ",
607 "demo_url",
608 HttpBaseUrlOptions::required_without_query_fragment(),
609 )
610 .is_err()
611 );
612 }
613
614 #[test]
615 fn http_base_url_normalization_rejects_bad_scheme_and_query_fragment() {
616 assert!(
617 normalize_http_base_url(
618 "ftp://example.test/root",
619 "demo_url",
620 HttpBaseUrlOptions::required_without_query_fragment(),
621 )
622 .is_err()
623 );
624 assert!(
625 normalize_http_base_url(
626 "https://example.test/root?x=1",
627 "demo_url",
628 HttpBaseUrlOptions::required_without_query_fragment(),
629 )
630 .is_err()
631 );
632 assert!(
633 normalize_http_base_url(
634 "https://example.test/root#frag",
635 "demo_url",
636 HttpBaseUrlOptions::required_without_query_fragment(),
637 )
638 .is_err()
639 );
640 }
641
642 #[test]
643 fn normalize_origin_trims_trailing_slash_and_lowercases() {
644 assert_eq!(
645 normalize_origin(" HTTPS://Example.COM:8443/ ", false).unwrap(),
646 "https://example.com:8443"
647 );
648 }
649
650 #[test]
651 fn normalize_origin_accepts_wildcard_only_when_allowed() {
652 assert_eq!(normalize_origin("*", true).unwrap(), "*");
653 assert!(normalize_origin("*", false).is_err());
654 }
655
656 #[test]
657 fn normalize_origin_rejects_invalid_origin_components() {
658 assert!(normalize_origin("https://app.example.com/path", false).is_err());
659 assert!(normalize_origin("https://app.example.com?x=1", false).is_err());
660 assert!(normalize_origin("https://user@app.example.com", false).is_err());
661 assert!(normalize_origin("ftp://app.example.com", false).is_err());
662 assert!(normalize_origin("https:///missing-host", false).is_err());
663 }
664
665 #[test]
666 fn normalize_origin_accepts_only_explicit_additional_schemes() {
667 use super::normalize_origin_with_additional_schemes;
668
669 let extension_origin = "chrome-extension://iikmkjmpaadaobahmlepeloendndfphd";
670 assert!(normalize_origin(extension_origin, false).is_err());
671 assert_eq!(
672 normalize_origin_with_additional_schemes(
673 extension_origin,
674 false,
675 &["chrome-extension"],
676 )
677 .unwrap(),
678 extension_origin
679 );
680 assert!(
681 normalize_origin_with_additional_schemes(
682 "custom-extension://example",
683 false,
684 &["chrome-extension"],
685 )
686 .is_err()
687 );
688 }
689
690 #[test]
691 fn public_site_origins_are_normalized_and_deduplicated() {
692 assert_eq!(
693 normalize_public_site_origins_config_value(
694 r#"[" HTTPS://Forge.EXAMPLE.com/ ","https://Panel.example.com","https://forge.example.com"]"#
695 )
696 .unwrap(),
697 r#"["https://forge.example.com","https://panel.example.com"]"#
698 );
699 assert_eq!(
700 parse_public_site_origins(
701 r#"["https://forge.example.com","","https://api.example.com"]"#
702 )
703 .unwrap(),
704 vec![
705 "https://forge.example.com".to_string(),
706 "https://api.example.com".to_string()
707 ]
708 );
709 }
710
711 #[test]
712 fn public_site_origins_reject_invalid_entries() {
713 assert!(
714 normalize_public_site_origins_config_value(r#"["https://forge.example.com/app"]"#)
715 .is_err()
716 );
717 assert!(
718 normalize_public_site_origins_config_value(r#"["ftp://forge.example.com"]"#).is_err()
719 );
720 assert!(normalize_public_site_origins_config_value(r#"["*"]"#).is_err());
721 assert!(
722 normalize_public_site_origins_config_value(r#""https://forge.example.com""#).is_err()
723 );
724 }
725
726 #[test]
727 fn public_site_origin_for_request_prefers_matching_origin() {
728 let origins = vec![
729 "https://forge.example.com".to_string(),
730 "https://panel.example.com".to_string(),
731 ];
732
733 assert_eq!(
734 public_site_origin_for_request(&origins, "https", "panel.example.com").as_deref(),
735 Some("https://panel.example.com")
736 );
737 assert_eq!(
738 public_site_origin_for_request(&origins, "https", "evil.example.com").as_deref(),
739 Some("https://forge.example.com")
740 );
741 }
742
743 #[test]
744 fn url_scheme_predicates_match_http_and_loopback_rules() {
745 let https = parse_absolute_url("https://example.com/callback", "callback").unwrap();
746 let http_loopback = parse_absolute_url("http://127.0.0.1/callback", "callback").unwrap();
747 let http_public = parse_absolute_url("http://example.com/callback", "callback").unwrap();
748 let ftp = parse_absolute_url("ftp://example.com/file", "file").unwrap();
749
750 assert!(has_http_scheme(&https));
751 assert!(has_http_scheme(&http_loopback));
752 assert!(!has_http_scheme(&ftp));
753 assert!(is_https_or_loopback_http(&https));
754 assert!(is_https_or_loopback_http(&http_loopback));
755 assert!(!is_https_or_loopback_http(&http_public));
756 }
757}