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