Skip to main content

aster_forge_utils/
url.rs

1//! URL and origin normalization helpers.
2//!
3//! This module contains product-neutral URL rules shared by Aster services. It normalizes HTTP
4//! origins for CORS and public-site matching, validates HTTP base URLs used by integrations, and
5//! exposes small predicates for OAuth-style redirect and endpoint checks. Callers still decide
6//! whether failures are configuration errors, validation errors, or domain-specific errors.
7
8use http::Uri;
9use url::Url;
10
11use crate::{Result, UtilsError, net::is_loopback_host};
12
13/// Options for [`normalize_http_base_url`].
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct HttpBaseUrlOptions {
16    /// Whether empty input should return `None`.
17    pub allow_empty: bool,
18    /// Whether query strings and fragments should be rejected.
19    pub forbid_query_fragment: bool,
20}
21
22impl HttpBaseUrlOptions {
23    /// Creates options for a required base URL without query or fragment components.
24    pub const fn required_without_query_fragment() -> Self {
25        Self {
26            allow_empty: false,
27            forbid_query_fragment: true,
28        }
29    }
30
31    /// Creates options for an optional base URL without query or fragment components.
32    pub const fn optional_without_query_fragment() -> Self {
33        Self {
34            allow_empty: true,
35            forbid_query_fragment: true,
36        }
37    }
38}
39
40/// Returns whether `url` uses `http` or `https`.
41pub fn has_http_scheme(url: &Url) -> bool {
42    matches!(url.scheme(), "http" | "https")
43}
44
45/// Parses a URL and maps parser failures into [`UtilsError`].
46///
47/// The parser requires an absolute URL. The `context` is included verbatim in the error message so
48/// callers can name the field or operation that supplied the value, then map [`UtilsError`] into
49/// their own validation or configuration error category at the application boundary.
50pub fn parse_url(value: &str, context: &str) -> Result<Url> {
51    Url::parse(value).map_err(|error| UtilsError::invalid_value(format!("{context}: {error}")))
52}
53
54/// Returns whether `url` is HTTPS or an HTTP loopback URL.
55///
56/// This is useful for development-friendly security checks where plain HTTP is accepted only for
57/// localhost and loopback IP addresses.
58pub 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
63/// Parses an absolute URL and maps parser failures into [`UtilsError`].
64pub 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
70/// Parses a required absolute HTTP or HTTPS URL with a host.
71///
72/// Paths, queries, and fragments are preserved. This is intended for ordinary
73/// navigation or redirect targets rather than base URLs used for path joining.
74pub 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
91/// Normalizes an HTTP base URL.
92///
93/// Surrounding whitespace and trailing slashes are removed before parsing. The URL must be absolute,
94/// use `http` or `https`, and include a host. When `options.forbid_query_fragment` is set, query
95/// strings and fragments are rejected so callers can safely append paths.
96pub 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
130/// Normalizes an HTTP origin for CORS and public-site comparisons.
131///
132/// The returned value is lowercase `scheme://authority`. Paths other than `/`, query strings,
133/// fragments, and userinfo are rejected. When `allow_wildcard` is true, `*` is returned unchanged.
134pub fn normalize_origin(origin: &str, allow_wildcard: bool) -> Result<String> {
135    normalize_origin_with_additional_schemes(origin, allow_wildcard, &[])
136}
137
138/// Normalizes an origin while accepting explicitly selected non-HTTP schemes.
139///
140/// Additional schemes only affect syntax validation. Callers must still apply their own exact
141/// origin allowlist; accepting a scheme here does not authorize every origin using that scheme.
142pub 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
202/// Parses a JSON array of public site origins.
203///
204/// Empty input is rejected because a public-site URL configuration value should either be absent
205/// at the product layer or contain an explicit JSON array. Empty strings inside the array are
206/// ignored so operators can clean up accidental blank entries without blocking the whole value.
207pub 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
228/// Normalizes a public site origin.
229///
230/// Public-site origins intentionally reject wildcards because they are used for selecting concrete
231/// callback, CSRF, and frontend URLs.
232pub 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
246/// Parses, normalizes, and de-duplicates configured public site origins while preserving order.
247pub 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
259/// Normalizes a public-site URL config value into canonical JSON.
260pub 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
269/// Parses runtime public-site origins, ignoring invalid entries individually.
270///
271/// The `on_invalid` callback receives invalid entries or whole-value parse failures so product
272/// crates can log with their own config key and context.
273pub 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
304/// Selects the configured public-site origin that matches the current request, falling back to the
305/// first configured origin.
306pub 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
325/// Joins an origin and an application path.
326pub 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}