toolkit_http/layers/
redirect.rs1use crate::config::RedirectConfig;
21use http::{Request, Uri, header};
22use tower_http::follow_redirect::policy::{Action, Attempt, Policy};
23
24static 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#[derive(Debug, Clone)]
62pub struct SecureRedirectPolicy {
63 config: RedirectConfig,
64 redirect_count: usize,
66 cross_origin_detected: bool,
68}
69
70impl SecureRedirectPolicy {
71 #[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 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 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 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 #[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
127fn 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 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 let original = attempt.previous();
151 let target = attempt.location();
152
153 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 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 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 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 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 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 assert!(!SecureRedirectPolicy::is_https_downgrade(
271 &uri("http://example.com/foo"),
272 &uri("https://example.com/bar")
273 ));
274
275 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); }
326
327 #[test]
328 fn test_should_strip_headers() {
329 let config = RedirectConfig::default();
330 let policy = SecureRedirectPolicy::new(config);
331
332 assert!(
334 !policy
335 .should_strip_headers(&uri("https://example.com/a"), &uri("https://example.com/b"))
336 );
337
338 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 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}