Skip to main content

securitydept_utils/
redirect.rs

1use iri_string::types::{UriReferenceString, UriRelativeString, UriString};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use snafu::Snafu;
5use typed_builder::TypedBuilder;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(tag = "type", content = "value", rename_all = "snake_case")]
9pub enum RedirectTargetRule {
10    Regex {
11        #[serde(with = "serde_regex")]
12        value: Regex,
13    },
14    All,
15    Strict {
16        value: String,
17    },
18}
19
20impl PartialEq for RedirectTargetRule {
21    fn eq(&self, other: &Self) -> bool {
22        match (self, other) {
23            (Self::All, Self::All) => true,
24            (Self::Strict { value: left }, Self::Strict { value: right }) => left == right,
25            (Self::Regex { value: left }, Self::Regex { value: right }) => {
26                left.as_str() == right.as_str()
27            }
28            _ => false,
29        }
30    }
31}
32
33impl Eq for RedirectTargetRule {}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TypedBuilder)]
36pub struct RedirectTargetConfig {
37    #[serde(default)]
38    #[builder(default, setter(strip_option, into))]
39    pub default_redirect_target: Option<String>,
40    #[serde(default)]
41    #[builder(default)]
42    pub dynamic_redirect_target_enabled: bool,
43    #[serde(default)]
44    #[builder(default)]
45    pub allowed_redirect_targets: Vec<RedirectTargetRule>,
46}
47
48impl RedirectTargetConfig {
49    pub fn strict_default(redirect_target: impl Into<String>) -> Self {
50        Self::builder()
51            .default_redirect_target(redirect_target.into())
52            .build()
53    }
54
55    pub fn dynamic_targets(targets: impl IntoIterator<Item = RedirectTargetRule>) -> Self {
56        Self::builder()
57            .dynamic_redirect_target_enabled(true)
58            .allowed_redirect_targets(targets.into_iter().collect())
59            .build()
60    }
61
62    pub fn dynamic_default_and_dynamic_targets(
63        default_redirect_target: impl Into<String>,
64        targets: impl IntoIterator<Item = RedirectTargetRule>,
65    ) -> Self {
66        Self::builder()
67            .default_redirect_target(default_redirect_target.into())
68            .dynamic_redirect_target_enabled(true)
69            .allowed_redirect_targets(targets.into_iter().collect())
70            .build()
71    }
72
73    pub fn validate_as_uri_reference(&self) -> Result<(), RedirectTargetError> {
74        self.validate_with(parse_uri_reference, "URI reference")
75    }
76
77    pub fn validate_as_uri(&self) -> Result<(), RedirectTargetError> {
78        self.validate_with(parse_uri, "URI")
79    }
80
81    pub fn validate_as_uri_relative(&self) -> Result<(), RedirectTargetError> {
82        self.validate_with(parse_uri_relative, "relative URI reference")
83    }
84
85    fn validate_with<T>(
86        &self,
87        parse: fn(&str) -> Result<T, RedirectTargetError>,
88        target_type: &str,
89    ) -> Result<(), RedirectTargetError> {
90        if let Some(default_redirect_target) = self.default_redirect_target.as_deref() {
91            parse(default_redirect_target).map_err(|source| {
92                RedirectTargetError::InvalidRedirectTarget {
93                    message: format!(
94                        "default_redirect_target is not a valid {target_type}: {source}"
95                    ),
96                }
97            })?;
98        }
99
100        if self.dynamic_redirect_target_enabled && self.allowed_redirect_targets.is_empty() {
101            return Err(RedirectTargetError::InvalidRedirectTarget {
102                message: "allowed_redirect_targets is required when \
103                          dynamic_redirect_target_enabled is true"
104                    .to_string(),
105            });
106        }
107
108        for rule in &self.allowed_redirect_targets {
109            if let RedirectTargetRule::Strict { value } = rule {
110                parse(value).map_err(|source| RedirectTargetError::InvalidRedirectTarget {
111                    message: format!(
112                        "invalid strict redirect target `{value}` for {target_type}: {source}"
113                    ),
114                })?;
115            }
116        }
117
118        Ok(())
119    }
120}
121
122#[derive(Debug, Snafu)]
123pub enum RedirectTargetError {
124    #[snafu(display("redirect target is invalid: {message}"))]
125    InvalidRedirectTarget { message: String },
126}
127
128#[derive(Clone, Debug)]
129pub struct UriReferenceRedirectTargetResolver {
130    config: RedirectTargetConfig,
131}
132
133#[derive(Clone, Debug)]
134pub struct UriRedirectTargetResolver {
135    config: RedirectTargetConfig,
136}
137
138#[derive(Clone, Debug)]
139pub struct UriRelativeRedirectTargetResolver {
140    config: RedirectTargetConfig,
141}
142
143impl UriReferenceRedirectTargetResolver {
144    pub fn from_config(config: RedirectTargetConfig) -> Result<Self, RedirectTargetError> {
145        config.validate_as_uri_reference()?;
146        Ok(Self { config })
147    }
148
149    pub fn resolve_redirect_target(
150        &self,
151        requested_redirect_target: Option<&str>,
152    ) -> Result<UriReferenceString, RedirectTargetError> {
153        resolve_with_config(&self.config, requested_redirect_target, parse_uri_reference)
154    }
155}
156
157impl UriRedirectTargetResolver {
158    pub fn from_config(config: RedirectTargetConfig) -> Result<Self, RedirectTargetError> {
159        config.validate_as_uri()?;
160        Ok(Self { config })
161    }
162
163    pub fn resolve_redirect_target(
164        &self,
165        requested_redirect_target: Option<&str>,
166    ) -> Result<UriString, RedirectTargetError> {
167        resolve_with_config(&self.config, requested_redirect_target, parse_uri)
168    }
169}
170
171impl UriRelativeRedirectTargetResolver {
172    pub fn from_config(config: RedirectTargetConfig) -> Result<Self, RedirectTargetError> {
173        config.validate_as_uri_relative()?;
174        Ok(Self { config })
175    }
176
177    pub fn resolve_redirect_target(
178        &self,
179        requested_redirect_target: Option<&str>,
180    ) -> Result<UriRelativeString, RedirectTargetError> {
181        resolve_with_config(&self.config, requested_redirect_target, parse_uri_relative)
182    }
183}
184
185fn resolve_with_config<T>(
186    config: &RedirectTargetConfig,
187    requested_redirect_target: Option<&str>,
188    parse: fn(&str) -> Result<T, RedirectTargetError>,
189) -> Result<T, RedirectTargetError> {
190    match requested_redirect_target {
191        Some(requested_redirect_target) => {
192            if !config.dynamic_redirect_target_enabled {
193                return Err(RedirectTargetError::InvalidRedirectTarget {
194                    message: "dynamic redirect target is disabled".to_string(),
195                });
196            }
197
198            let parsed = parse(requested_redirect_target).map_err(|source| {
199                RedirectTargetError::InvalidRedirectTarget {
200                    message: format!("requested redirect target is invalid: {source}"),
201                }
202            })?;
203
204            if config
205                .allowed_redirect_targets
206                .iter()
207                .any(|rule| rule_matches(rule, requested_redirect_target))
208            {
209                Ok(parsed)
210            } else {
211                Err(RedirectTargetError::InvalidRedirectTarget {
212                    message: format!(
213                        "requested redirect target `{requested_redirect_target}` is not allowed"
214                    ),
215                })
216            }
217        }
218        None => config
219            .default_redirect_target
220            .as_deref()
221            .ok_or_else(|| RedirectTargetError::InvalidRedirectTarget {
222                message: "redirect target is required when no default_redirect_target is \
223                          configured"
224                    .to_string(),
225            })
226            .and_then(parse),
227    }
228}
229
230fn rule_matches(rule: &RedirectTargetRule, redirect_target: &str) -> bool {
231    match rule {
232        RedirectTargetRule::All => true,
233        RedirectTargetRule::Regex { value } => value.is_match(redirect_target),
234        RedirectTargetRule::Strict { value } => value == redirect_target,
235    }
236}
237
238fn parse_uri_reference(value: &str) -> Result<UriReferenceString, RedirectTargetError> {
239    UriReferenceString::try_from(value.to_string()).map_err(|e| {
240        RedirectTargetError::InvalidRedirectTarget {
241            message: e.to_string(),
242        }
243    })
244}
245
246fn parse_uri(value: &str) -> Result<UriString, RedirectTargetError> {
247    UriString::try_from(value.to_string()).map_err(|e| RedirectTargetError::InvalidRedirectTarget {
248        message: e.to_string(),
249    })
250}
251
252fn parse_uri_relative(value: &str) -> Result<UriRelativeString, RedirectTargetError> {
253    UriRelativeString::try_from(value.to_string()).map_err(|e| {
254        RedirectTargetError::InvalidRedirectTarget {
255            message: e.to_string(),
256        }
257    })
258}
259
260#[cfg(test)]
261mod tests {
262    use regex::Regex;
263
264    use super::{
265        RedirectTargetConfig, RedirectTargetRule, UriRedirectTargetResolver,
266        UriReferenceRedirectTargetResolver, UriRelativeRedirectTargetResolver,
267    };
268
269    #[test]
270    fn uri_config_rejects_relative_default() {
271        let error = UriRedirectTargetResolver::from_config(RedirectTargetConfig {
272            default_redirect_target: Some("/not-absolute".to_string()),
273            dynamic_redirect_target_enabled: false,
274            allowed_redirect_targets: Vec::new(),
275        })
276        .expect_err("relative path should be rejected");
277
278        assert!(format!("{error}").contains("default_redirect_target is not a valid URI"));
279    }
280
281    #[test]
282    fn uri_relative_config_rejects_absolute_default() {
283        let error = UriRelativeRedirectTargetResolver::from_config(RedirectTargetConfig {
284            default_redirect_target: Some("https://evil.example.com".to_string()),
285            dynamic_redirect_target_enabled: false,
286            allowed_redirect_targets: Vec::new(),
287        })
288        .expect_err("absolute uri should be rejected");
289
290        assert!(format!("{error}").contains("relative URI reference"));
291    }
292
293    #[test]
294    fn uri_relative_resolver_allows_strict_default() {
295        let resolved = UriRelativeRedirectTargetResolver::from_config(
296            RedirectTargetConfig::strict_default("/"),
297        )
298        .expect("config should be valid")
299        .resolve_redirect_target(None)
300        .expect("default target should resolve");
301
302        assert_eq!(resolved.as_str(), "/");
303    }
304
305    #[test]
306    fn uri_resolver_allows_regex_rule() {
307        let resolved =
308            UriRedirectTargetResolver::from_config(RedirectTargetConfig::dynamic_targets(vec![
309                RedirectTargetRule::Regex {
310                    value: Regex::new(r"^https://app\.example\.com/callback(/.*)?$")
311                        .expect("regex should compile"),
312                },
313            ]))
314            .expect("config should be valid")
315            .resolve_redirect_target(Some("https://app.example.com/callback/a"))
316            .expect("regex target should resolve");
317
318        assert_eq!(resolved.as_str(), "https://app.example.com/callback/a");
319    }
320
321    #[test]
322    fn uri_reference_resolver_allows_relative_and_absolute() {
323        let resolver = UriReferenceRedirectTargetResolver::from_config(
324            RedirectTargetConfig::dynamic_targets(vec![RedirectTargetRule::All]),
325        )
326        .expect("config should be valid");
327
328        assert_eq!(
329            resolver
330                .resolve_redirect_target(Some("/foo"))
331                .expect("relative reference should resolve")
332                .as_str(),
333            "/foo"
334        );
335        assert_eq!(
336            resolver
337                .resolve_redirect_target(Some("https://example.com/foo"))
338                .expect("absolute reference should resolve")
339                .as_str(),
340            "https://example.com/foo"
341        );
342    }
343}