1use http::Uri;
9use url::Url;
10
11use crate::{Result, UtilsError, net::is_loopback_host};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct HttpBaseUrlOptions {
16 pub allow_empty: bool,
18 pub forbid_query_fragment: bool,
20}
21
22impl HttpBaseUrlOptions {
23 pub const fn required_without_query_fragment() -> Self {
25 Self {
26 allow_empty: false,
27 forbid_query_fragment: true,
28 }
29 }
30
31 pub const fn optional_without_query_fragment() -> Self {
33 Self {
34 allow_empty: true,
35 forbid_query_fragment: true,
36 }
37 }
38}
39
40pub fn has_http_scheme(url: &Url) -> bool {
42 matches!(url.scheme(), "http" | "https")
43}
44
45pub fn parse_url(value: &str, context: &str) -> Result<Url> {
51 Url::parse(value).map_err(|error| UtilsError::invalid_value(format!("{context}: {error}")))
52}
53
54pub fn is_https_or_loopback_http(url: &Url) -> bool {
59 url.scheme() == "https"
60 || (url.scheme() == "http" && url.host_str().is_some_and(is_loopback_host))
61}
62
63pub fn parse_absolute_url(value: &str, label: &str) -> Result<Url> {
65 Url::parse(value).map_err(|error| {
66 UtilsError::invalid_value(format!("{label} must be an absolute URL: {error}"))
67 })
68}
69
70pub fn parse_http_url(value: &str, label: &str) -> Result<Url> {
75 let normalized = value.trim();
76 if normalized.is_empty() {
77 return Err(UtilsError::invalid_value(format!(
78 "{label} cannot be empty"
79 )));
80 }
81
82 let parsed = parse_absolute_url(normalized, label)?;
83 if !has_http_scheme(&parsed) || parsed.host_str().is_none() {
84 return Err(UtilsError::invalid_value(format!(
85 "{label} must use http or https and include a host"
86 )));
87 }
88 Ok(parsed)
89}
90
91pub fn normalize_http_base_url(
97 value: &str,
98 label: &str,
99 options: HttpBaseUrlOptions,
100) -> Result<Option<String>> {
101 let normalized = value.trim().trim_end_matches('/').to_string();
102 if normalized.is_empty() {
103 if options.allow_empty {
104 return Ok(None);
105 }
106 return Err(UtilsError::invalid_value(format!(
107 "{label} cannot be empty"
108 )));
109 }
110
111 let parsed = Url::parse(&normalized).map_err(|error| {
112 UtilsError::invalid_value(format!(
113 "{label} must be an absolute http/https URL: {error}"
114 ))
115 })?;
116 if !has_http_scheme(&parsed) || parsed.host_str().is_none() {
117 return Err(UtilsError::invalid_value(format!(
118 "{label} must use http or https and include a host"
119 )));
120 }
121 if options.forbid_query_fragment && (parsed.query().is_some() || parsed.fragment().is_some()) {
122 return Err(UtilsError::invalid_value(format!(
123 "{label} cannot include query or fragment"
124 )));
125 }
126
127 Ok(Some(normalized))
128}
129
130pub fn normalize_origin(origin: &str, allow_wildcard: bool) -> Result<String> {
135 normalize_origin_with_additional_schemes(origin, allow_wildcard, &[])
136}
137
138pub fn normalize_origin_with_additional_schemes(
143 origin: &str,
144 allow_wildcard: bool,
145 additional_schemes: &[&str],
146) -> Result<String> {
147 let trimmed = origin.trim();
148 if trimmed.is_empty() {
149 return Err(UtilsError::invalid_value("origin cannot be empty"));
150 }
151
152 if allow_wildcard && trimmed == "*" {
153 return Ok("*".to_string());
154 }
155
156 let uri: Uri = trimmed
157 .parse()
158 .map_err(|_| UtilsError::invalid_value(format!("invalid origin '{trimmed}'")))?;
159
160 let scheme = uri.scheme_str().ok_or_else(|| {
161 UtilsError::invalid_value(format!(
162 "origin must include http:// or https://: '{trimmed}'"
163 ))
164 })?;
165
166 if scheme != "http" && scheme != "https" && !additional_schemes.contains(&scheme) {
167 return Err(UtilsError::invalid_value(format!(
168 "origin scheme is not supported: '{trimmed}'"
169 )));
170 }
171
172 let authority = uri.authority().ok_or_else(|| {
173 UtilsError::invalid_value(format!("origin must include a host: '{trimmed}'"))
174 })?;
175
176 if authority.as_str().contains('@') {
177 return Err(UtilsError::invalid_value(format!(
178 "origin must not include userinfo: '{trimmed}'"
179 )));
180 }
181
182 if uri.path_and_query().and_then(|pq| pq.query()).is_some() {
183 return Err(UtilsError::invalid_value(format!(
184 "origin must not include query parameters: '{trimmed}'"
185 )));
186 }
187
188 let path = uri.path();
189 if !path.is_empty() && path != "/" {
190 return Err(UtilsError::invalid_value(format!(
191 "origin must not include a path: '{trimmed}'"
192 )));
193 }
194
195 Ok(format!(
196 "{}://{}",
197 scheme.to_ascii_lowercase(),
198 authority.as_str().to_ascii_lowercase()
199 ))
200}
201
202pub fn parse_public_site_origin_entries(value: &str) -> Result<Vec<String>> {
208 let trimmed = value.trim();
209 if trimmed.is_empty() {
210 return Err(UtilsError::invalid_value(
211 "public_site_url must be a JSON array of origins",
212 ));
213 }
214
215 let entries = serde_json::from_str::<Vec<String>>(trimmed).map_err(|error| {
216 UtilsError::invalid_value(format!(
217 "public_site_url must be a JSON array of origins: {error}"
218 ))
219 })?;
220
221 Ok(entries
222 .into_iter()
223 .map(|origin| origin.trim().to_string())
224 .filter(|origin| !origin.is_empty())
225 .collect())
226}
227
228pub fn normalize_public_site_origin(origin: &str) -> Result<String> {
233 if origin.trim() == "*" {
234 return Err(UtilsError::invalid_value(
235 "public_site_url does not support wildcard origins",
236 ));
237 }
238
239 normalize_origin(origin, false).map_err(|error| {
240 UtilsError::invalid_value(format!(
241 "invalid public_site_url origin '{origin}': {error}"
242 ))
243 })
244}
245
246pub fn parse_public_site_origins(value: &str) -> Result<Vec<String>> {
248 let mut origins = Vec::new();
249 for origin in parse_public_site_origin_entries(value)? {
250 let normalized = normalize_public_site_origin(&origin)?;
251 if !origins.contains(&normalized) {
252 origins.push(normalized);
253 }
254 }
255
256 Ok(origins)
257}
258
259pub fn normalize_public_site_origins_config_value(value: &str) -> Result<String> {
261 let origins = parse_public_site_origins(value)?;
262 serde_json::to_string(&origins).map_err(|error| {
263 UtilsError::invalid_value(format!(
264 "failed to serialize public_site_url origins: {error}"
265 ))
266 })
267}
268
269pub fn runtime_public_site_origins_with<F>(value: Option<&str>, mut on_invalid: F) -> Vec<String>
274where
275 F: FnMut(Option<&str>, &UtilsError),
276{
277 let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
278 return Vec::new();
279 };
280
281 let entries = match parse_public_site_origin_entries(value) {
282 Ok(entries) => entries,
283 Err(error) => {
284 on_invalid(None, &error);
285 return Vec::new();
286 }
287 };
288
289 let mut origins = Vec::new();
290 for origin in entries {
291 match normalize_public_site_origin(&origin) {
292 Ok(normalized) => {
293 if !origins.contains(&normalized) {
294 origins.push(normalized);
295 }
296 }
297 Err(error) => on_invalid(Some(&origin), &error),
298 }
299 }
300
301 origins
302}
303
304pub fn public_site_origin_for_request(
307 origins: &[String],
308 scheme: &str,
309 host: &str,
310) -> Option<String> {
311 if origins.is_empty() {
312 return None;
313 }
314
315 let request_origin = normalize_origin(&format!("{scheme}://{host}"), false).ok();
316 if let Some(request_origin) = request_origin
317 && origins.iter().any(|origin| origin == &request_origin)
318 {
319 return Some(request_origin);
320 }
321
322 origins.first().cloned()
323}
324
325pub fn join_origin_and_path(base: &str, path: &str) -> String {
327 let normalized_path = if path.starts_with('/') {
328 path.to_string()
329 } else {
330 format!("/{path}")
331 };
332
333 format!("{base}{normalized_path}")
334}
335
336#[cfg(test)]
337mod tests {
338 use super::{
339 HttpBaseUrlOptions, has_http_scheme, is_https_or_loopback_http, normalize_http_base_url,
340 normalize_origin, normalize_public_site_origins_config_value, parse_absolute_url,
341 parse_public_site_origins, parse_url, public_site_origin_for_request,
342 };
343 use crate::UtilsError;
344
345 #[test]
346 fn parse_url_maps_parser_errors_with_context() {
347 let parsed = parse_url("https://example.com/callback", "callback URL").unwrap();
348 assert_eq!(parsed.scheme(), "https");
349 assert_eq!(parsed.host_str(), Some("example.com"));
350
351 let error = parse_url("not a url", "callback URL").unwrap_err();
352 assert!(matches!(error, UtilsError::InvalidValue(_)));
353 assert!(error.to_string().contains("callback URL:"));
354 assert!(error.to_string().contains("relative URL without a base"));
355 }
356
357 #[test]
358 fn http_base_url_normalization_trims_and_removes_trailing_slashes() {
359 assert_eq!(
360 normalize_http_base_url(
361 " https://example.test/root// ",
362 "demo_url",
363 HttpBaseUrlOptions::required_without_query_fragment(),
364 )
365 .unwrap(),
366 Some("https://example.test/root".to_string())
367 );
368 }
369
370 #[test]
371 fn http_base_url_normalization_handles_empty_values() {
372 assert_eq!(
373 normalize_http_base_url(
374 " ",
375 "demo_url",
376 HttpBaseUrlOptions::optional_without_query_fragment(),
377 )
378 .unwrap(),
379 None
380 );
381 assert!(
382 normalize_http_base_url(
383 " ",
384 "demo_url",
385 HttpBaseUrlOptions::required_without_query_fragment(),
386 )
387 .is_err()
388 );
389 }
390
391 #[test]
392 fn http_base_url_normalization_rejects_bad_scheme_and_query_fragment() {
393 assert!(
394 normalize_http_base_url(
395 "ftp://example.test/root",
396 "demo_url",
397 HttpBaseUrlOptions::required_without_query_fragment(),
398 )
399 .is_err()
400 );
401 assert!(
402 normalize_http_base_url(
403 "https://example.test/root?x=1",
404 "demo_url",
405 HttpBaseUrlOptions::required_without_query_fragment(),
406 )
407 .is_err()
408 );
409 assert!(
410 normalize_http_base_url(
411 "https://example.test/root#frag",
412 "demo_url",
413 HttpBaseUrlOptions::required_without_query_fragment(),
414 )
415 .is_err()
416 );
417 }
418
419 #[test]
420 fn normalize_origin_trims_trailing_slash_and_lowercases() {
421 assert_eq!(
422 normalize_origin(" HTTPS://Example.COM:8443/ ", false).unwrap(),
423 "https://example.com:8443"
424 );
425 }
426
427 #[test]
428 fn normalize_origin_accepts_wildcard_only_when_allowed() {
429 assert_eq!(normalize_origin("*", true).unwrap(), "*");
430 assert!(normalize_origin("*", false).is_err());
431 }
432
433 #[test]
434 fn normalize_origin_rejects_invalid_origin_components() {
435 assert!(normalize_origin("https://app.example.com/path", false).is_err());
436 assert!(normalize_origin("https://app.example.com?x=1", false).is_err());
437 assert!(normalize_origin("https://user@app.example.com", false).is_err());
438 assert!(normalize_origin("ftp://app.example.com", false).is_err());
439 assert!(normalize_origin("https:///missing-host", false).is_err());
440 }
441
442 #[test]
443 fn normalize_origin_accepts_only_explicit_additional_schemes() {
444 use super::normalize_origin_with_additional_schemes;
445
446 let extension_origin = "chrome-extension://iikmkjmpaadaobahmlepeloendndfphd";
447 assert!(normalize_origin(extension_origin, false).is_err());
448 assert_eq!(
449 normalize_origin_with_additional_schemes(
450 extension_origin,
451 false,
452 &["chrome-extension"],
453 )
454 .unwrap(),
455 extension_origin
456 );
457 assert!(
458 normalize_origin_with_additional_schemes(
459 "custom-extension://example",
460 false,
461 &["chrome-extension"],
462 )
463 .is_err()
464 );
465 }
466
467 #[test]
468 fn public_site_origins_are_normalized_and_deduplicated() {
469 assert_eq!(
470 normalize_public_site_origins_config_value(
471 r#"[" HTTPS://Forge.EXAMPLE.com/ ","https://Panel.example.com","https://forge.example.com"]"#
472 )
473 .unwrap(),
474 r#"["https://forge.example.com","https://panel.example.com"]"#
475 );
476 assert_eq!(
477 parse_public_site_origins(
478 r#"["https://forge.example.com","","https://api.example.com"]"#
479 )
480 .unwrap(),
481 vec![
482 "https://forge.example.com".to_string(),
483 "https://api.example.com".to_string()
484 ]
485 );
486 }
487
488 #[test]
489 fn public_site_origins_reject_invalid_entries() {
490 assert!(
491 normalize_public_site_origins_config_value(r#"["https://forge.example.com/app"]"#)
492 .is_err()
493 );
494 assert!(
495 normalize_public_site_origins_config_value(r#"["ftp://forge.example.com"]"#).is_err()
496 );
497 assert!(normalize_public_site_origins_config_value(r#"["*"]"#).is_err());
498 assert!(
499 normalize_public_site_origins_config_value(r#""https://forge.example.com""#).is_err()
500 );
501 }
502
503 #[test]
504 fn public_site_origin_for_request_prefers_matching_origin() {
505 let origins = vec![
506 "https://forge.example.com".to_string(),
507 "https://panel.example.com".to_string(),
508 ];
509
510 assert_eq!(
511 public_site_origin_for_request(&origins, "https", "panel.example.com").as_deref(),
512 Some("https://panel.example.com")
513 );
514 assert_eq!(
515 public_site_origin_for_request(&origins, "https", "evil.example.com").as_deref(),
516 Some("https://forge.example.com")
517 );
518 }
519
520 #[test]
521 fn url_scheme_predicates_match_http_and_loopback_rules() {
522 let https = parse_absolute_url("https://example.com/callback", "callback").unwrap();
523 let http_loopback = parse_absolute_url("http://127.0.0.1/callback", "callback").unwrap();
524 let http_public = parse_absolute_url("http://example.com/callback", "callback").unwrap();
525 let ftp = parse_absolute_url("ftp://example.com/file", "file").unwrap();
526
527 assert!(has_http_scheme(&https));
528 assert!(has_http_scheme(&http_loopback));
529 assert!(!has_http_scheme(&ftp));
530 assert!(is_https_or_loopback_http(&https));
531 assert!(is_https_or_loopback_http(&http_loopback));
532 assert!(!is_https_or_loopback_http(&http_public));
533 }
534}