modkit_http/layers/
redirect.rs1use crate::config::RedirectConfig;
21use http::{Request, Uri, header};
22use tower_http::follow_redirect::policy::{Action, Attempt, Policy};
23
24const SENSITIVE_HEADERS: &[header::HeaderName] = &[
26 header::AUTHORIZATION,
27 header::COOKIE,
28 header::PROXY_AUTHORIZATION,
29];
30
31#[derive(Debug, Clone)]
51pub struct SecureRedirectPolicy {
52 config: RedirectConfig,
53 redirect_count: usize,
55 cross_origin_detected: bool,
57}
58
59impl SecureRedirectPolicy {
60 #[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 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 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 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 #[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
116fn 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 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 let original = attempt.previous();
140 let target = attempt.location();
141
142 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 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 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 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 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 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 assert!(!SecureRedirectPolicy::is_https_downgrade(
260 &uri("http://example.com/foo"),
261 &uri("https://example.com/bar")
262 ));
263
264 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); }
315
316 #[test]
317 fn test_should_strip_headers() {
318 let config = RedirectConfig::default();
319 let policy = SecureRedirectPolicy::new(config);
320
321 assert!(
323 !policy
324 .should_strip_headers(&uri("https://example.com/a"), &uri("https://example.com/b"))
325 );
326
327 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 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}