Skip to main content

modkit_http/layers/
redirect.rs

1//! Secure redirect policy for HTTP clients
2//!
3//! This module provides a security-hardened redirect policy that protects against:
4//! - SSRF (Server-Side Request Forgery) via cross-origin redirects
5//! - Credential leakage via `Authorization` header forwarding
6//! - HTTPS downgrade attacks
7//!
8//! ## Default Behavior
9//!
10//! By default, `SecureRedirectPolicy`:
11//! - Only follows same-origin redirects (same scheme, host, and port)
12//! - Strips sensitive headers (`Authorization`, `Cookie`, `Proxy-Authorization`) on cross-origin redirects
13//! - Blocks HTTPS → HTTP downgrades
14//! - Limits total redirects (configurable, default: 10)
15//!
16//! ## Configuration
17//!
18//! Use [`RedirectConfig`](crate::RedirectConfig) to customize behavior.
19
20use crate::config::RedirectConfig;
21use http::{Request, Uri, header};
22use tower_http::follow_redirect::policy::{Action, Attempt, Policy};
23
24/// Headers that are stripped on cross-origin redirects to prevent credential leakage
25const SENSITIVE_HEADERS: &[header::HeaderName] = &[
26    header::AUTHORIZATION,
27    header::COOKIE,
28    header::PROXY_AUTHORIZATION,
29];
30
31/// A security-hardened redirect policy
32///
33/// Implements [`tower_http::follow_redirect::policy::Policy`] with configurable
34/// security controls.
35///
36/// ## Security Features
37///
38/// 1. **Same-origin enforcement**: By default, only follows redirects to the same host
39/// 2. **Header stripping**: Removes `Authorization`, `Cookie` on cross-origin redirects
40/// 3. **Downgrade protection**: Blocks HTTPS → HTTP redirects
41/// 4. **Host allow-list**: Configurable list of trusted redirect targets
42///
43/// ## Example
44///
45/// ```rust,ignore
46/// use modkit_http::{SecureRedirectPolicy, RedirectConfig};
47///
48/// let policy = SecureRedirectPolicy::new(RedirectConfig::default());
49/// ```
50#[derive(Debug, Clone)]
51pub struct SecureRedirectPolicy {
52    config: RedirectConfig,
53    /// Track the number of redirects followed (resets per-request via Clone)
54    redirect_count: usize,
55    /// Track if we're in a cross-origin redirect chain (for header stripping)
56    cross_origin_detected: bool,
57}
58
59impl SecureRedirectPolicy {
60    /// Create a new secure redirect policy with the given configuration
61    #[must_use]
62    pub fn new(config: RedirectConfig) -> Self {
63        Self {
64            config,
65            redirect_count: 0,
66            cross_origin_detected: false,
67        }
68    }
69
70    /// Check if the redirect is to the same origin (scheme, host, port)
71    ///
72    /// Missing schemes default to "https" (fail-closed): a scheme-less URI is
73    /// treated as HTTPS so that cross-scheme comparisons err on the side of
74    /// security rather than silently downgrading.
75    fn is_same_origin(original: &Uri, target: &Uri) -> bool {
76        let orig_scheme = original.scheme_str().unwrap_or("https");
77        let target_scheme = target.scheme_str().unwrap_or("https");
78
79        let orig_host = original.host().unwrap_or("");
80        let target_host = target.host().unwrap_or("");
81
82        let orig_port = original
83            .port_u16()
84            .unwrap_or_else(|| default_port(orig_scheme));
85        let target_port = target
86            .port_u16()
87            .unwrap_or_else(|| default_port(target_scheme));
88
89        orig_scheme == target_scheme && orig_host == target_host && orig_port == target_port
90    }
91
92    /// Check if the redirect is an HTTPS → HTTP downgrade
93    fn is_https_downgrade(original: &Uri, target: &Uri) -> bool {
94        let orig_scheme = original.scheme_str().unwrap_or("https");
95        let target_scheme = target.scheme_str().unwrap_or("https");
96
97        orig_scheme == "https" && target_scheme == "http"
98    }
99
100    /// Check if the target host is in the allowed hosts list
101    fn is_allowed_host(&self, target: &Uri) -> bool {
102        if let Some(host) = target.host() {
103            self.config.allowed_redirect_hosts.contains(host)
104        } else {
105            false
106        }
107    }
108
109    /// Determine if we should strip sensitive headers for this redirect
110    #[cfg(test)]
111    fn should_strip_headers(&self, original: &Uri, target: &Uri) -> bool {
112        self.config.strip_sensitive_headers && !Self::is_same_origin(original, target)
113    }
114}
115
116/// Get the default port for a scheme
117fn default_port(scheme: &str) -> u16 {
118    match scheme {
119        "http" => 80,
120        "https" => 443,
121        _ => 0,
122    }
123}
124
125impl<B: Clone, E> Policy<B, E> for SecureRedirectPolicy {
126    fn redirect(&mut self, attempt: &Attempt<'_>) -> Result<Action, E> {
127        // Check max redirects
128        self.redirect_count += 1;
129        if self.redirect_count > self.config.max_redirects {
130            tracing::debug!(
131                count = self.redirect_count,
132                max = self.config.max_redirects,
133                "Redirect limit reached"
134            );
135            return Ok(Action::Stop);
136        }
137
138        // previous() returns the original request URI
139        let original = attempt.previous();
140        let target = attempt.location();
141
142        // Check HTTPS → HTTP downgrade
143        if !self.config.allow_https_downgrade && Self::is_https_downgrade(original, target) {
144            tracing::warn!(
145                original = %original,
146                target = %target,
147                "Blocking HTTPS to HTTP downgrade redirect"
148            );
149            return Ok(Action::Stop);
150        }
151
152        // Check same-origin policy
153        let is_same_origin = Self::is_same_origin(original, target);
154        let is_allowed_host = self.is_allowed_host(target);
155
156        if self.config.same_origin_only && !is_same_origin && !is_allowed_host {
157            tracing::warn!(
158                original = %original,
159                target = %target,
160                "Blocking cross-origin redirect (same_origin_only=true)"
161            );
162            return Ok(Action::Stop);
163        }
164
165        // Track if we've crossed origins for header stripping in on_request
166        if !is_same_origin {
167            self.cross_origin_detected = true;
168            tracing::debug!(
169                original = %original,
170                target = %target,
171                "Cross-origin redirect detected"
172            );
173        }
174
175        Ok(Action::Follow)
176    }
177
178    fn on_request(&mut self, request: &mut Request<B>) {
179        // Strip sensitive headers if we've detected a cross-origin redirect
180        // This happens AFTER the redirect() decision, so we know we're following it
181        if self.cross_origin_detected && self.config.strip_sensitive_headers {
182            let headers = request.headers_mut();
183            for header_name in SENSITIVE_HEADERS {
184                if headers.remove(header_name).is_some() {
185                    tracing::debug!(header = %header_name, "Stripped sensitive header on cross-origin redirect");
186                }
187            }
188        }
189    }
190
191    fn clone_body(&self, body: &B) -> Option<B> {
192        // Clone body for 307/308 redirects that require preserving the request body
193        Some(body.clone())
194    }
195}
196
197#[cfg(test)]
198#[cfg_attr(coverage_nightly, coverage(off))]
199mod tests {
200    use super::*;
201    use std::collections::HashSet;
202
203    fn uri(s: &str) -> Uri {
204        s.parse().unwrap()
205    }
206
207    #[test]
208    fn test_is_same_origin_same() {
209        assert!(SecureRedirectPolicy::is_same_origin(
210            &uri("https://example.com/foo"),
211            &uri("https://example.com/bar")
212        ));
213    }
214
215    #[test]
216    fn test_is_same_origin_different_host() {
217        assert!(!SecureRedirectPolicy::is_same_origin(
218            &uri("https://example.com/foo"),
219            &uri("https://other.com/bar")
220        ));
221    }
222
223    #[test]
224    fn test_is_same_origin_different_scheme() {
225        assert!(!SecureRedirectPolicy::is_same_origin(
226            &uri("https://example.com/foo"),
227            &uri("http://example.com/bar")
228        ));
229    }
230
231    #[test]
232    fn test_is_same_origin_different_port() {
233        assert!(!SecureRedirectPolicy::is_same_origin(
234            &uri("https://example.com/foo"),
235            &uri("https://example.com:8443/bar")
236        ));
237    }
238
239    #[test]
240    fn test_is_same_origin_explicit_default_port() {
241        // https with explicit 443 should match https without port
242        assert!(SecureRedirectPolicy::is_same_origin(
243            &uri("https://example.com/foo"),
244            &uri("https://example.com:443/bar")
245        ));
246    }
247
248    #[test]
249    fn test_is_https_downgrade() {
250        assert!(SecureRedirectPolicy::is_https_downgrade(
251            &uri("https://example.com/foo"),
252            &uri("http://example.com/bar")
253        ));
254    }
255
256    #[test]
257    fn test_is_not_https_downgrade() {
258        // HTTP to HTTPS is an upgrade, not downgrade
259        assert!(!SecureRedirectPolicy::is_https_downgrade(
260            &uri("http://example.com/foo"),
261            &uri("https://example.com/bar")
262        ));
263
264        // HTTPS to HTTPS is not a downgrade
265        assert!(!SecureRedirectPolicy::is_https_downgrade(
266            &uri("https://example.com/foo"),
267            &uri("https://other.com/bar")
268        ));
269    }
270
271    #[test]
272    fn test_allowed_host() {
273        let config = RedirectConfig {
274            allowed_redirect_hosts: HashSet::from(["trusted.com".to_owned()]),
275            ..Default::default()
276        };
277        let policy = SecureRedirectPolicy::new(config);
278
279        assert!(policy.is_allowed_host(&uri("https://trusted.com/path")));
280        assert!(!policy.is_allowed_host(&uri("https://untrusted.com/path")));
281    }
282
283    #[test]
284    fn test_redirect_config_default() {
285        let config = RedirectConfig::default();
286        assert_eq!(config.max_redirects, 10);
287        assert!(config.same_origin_only);
288        assert!(config.strip_sensitive_headers);
289        assert!(!config.allow_https_downgrade);
290        assert!(config.allowed_redirect_hosts.is_empty());
291    }
292
293    #[test]
294    fn test_redirect_config_permissive() {
295        let config = RedirectConfig::permissive();
296        assert_eq!(config.max_redirects, 10);
297        assert!(!config.same_origin_only);
298        assert!(config.strip_sensitive_headers);
299        assert!(!config.allow_https_downgrade);
300    }
301
302    #[test]
303    fn test_redirect_config_disabled() {
304        let config = RedirectConfig::disabled();
305        assert_eq!(config.max_redirects, 0);
306    }
307
308    #[test]
309    fn test_redirect_config_for_testing() {
310        let config = RedirectConfig::for_testing();
311        assert!(!config.same_origin_only);
312        assert!(config.allow_https_downgrade);
313        assert!(config.strip_sensitive_headers); // Still strip headers
314    }
315
316    #[test]
317    fn test_should_strip_headers() {
318        let config = RedirectConfig::default();
319        let policy = SecureRedirectPolicy::new(config);
320
321        // Same origin - don't strip
322        assert!(
323            !policy
324                .should_strip_headers(&uri("https://example.com/a"), &uri("https://example.com/b"))
325        );
326
327        // Cross origin - strip
328        assert!(
329            policy.should_strip_headers(&uri("https://example.com/a"), &uri("https://other.com/b"))
330        );
331    }
332
333    #[test]
334    fn test_should_strip_headers_disabled() {
335        let config = RedirectConfig {
336            strip_sensitive_headers: false,
337            ..Default::default()
338        };
339        let policy = SecureRedirectPolicy::new(config);
340
341        // Cross origin but stripping disabled
342        assert!(
343            !policy
344                .should_strip_headers(&uri("https://example.com/a"), &uri("https://other.com/b"))
345        );
346    }
347
348    #[test]
349    fn test_policy_new() {
350        let config = RedirectConfig::default();
351        let policy = SecureRedirectPolicy::new(config);
352        assert_eq!(policy.redirect_count, 0);
353        assert!(!policy.cross_origin_detected);
354    }
355}