Skip to main content

toolkit_http/layers/
redirect.rs

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