feroxfuzz/observers/response.rs
1use super::{Observer, ObserverHooks};
2use crate::actions::Action;
3use crate::requests::{Request, RequestId};
4use crate::responses::{Response, Timed};
5use crate::std_ext::tuple::Named;
6
7use std::collections::HashMap;
8
9use cfg_if::cfg_if;
10#[cfg(feature = "serde")]
11use serde::{Deserialize, Serialize};
12use tracing::instrument;
13use url::Url;
14
15const RESPONSE_OBSERVER_NAME: &str = "ResponseObserver";
16
17/// observes the given implementor of implementor of [`Response`] and [`Timed`]
18#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd)]
19#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
20pub struct ResponseObserver<R>
21where
22 R: Response,
23{
24 // satisfy Named trait
25 name: &'static str,
26 // satisfy ResponseExt trait
27 response: R,
28}
29
30impl<R> Timed for ResponseObserver<R>
31where
32 R: Response + Timed,
33{
34 fn elapsed(&self) -> &std::time::Duration {
35 self.response.elapsed()
36 }
37}
38
39impl<R> ResponseObserver<R>
40where
41 R: Response + Default,
42{
43 /// create a new `ResponseObserver`
44 #[must_use]
45 pub fn new() -> Self {
46 Self::default()
47 }
48
49 /// given an implementor of [`Response`], return a new `ResponseObserver`
50 ///
51 /// # Examples
52 ///
53 /// While the example below works, the normal use-case for this struct is to pass
54 /// it, and any other [`Observers`] to the [`build_observers`] macro, and pass
55 /// the result of that call to your chosen [`Fuzzer`] implementation.
56 ///
57 /// [`build_observers`]: crate::build_observers
58 /// [`Fuzzer`]: crate::fuzzers::Fuzzer
59 /// [`Observers`]: crate::observers::Observers
60 ///
61 /// ```
62 /// # use http;
63 /// # use feroxfuzz::responses::{Response, AsyncResponse};
64 /// # use feroxfuzz::requests::Request;
65 /// # use feroxfuzz::prelude::*;
66 /// # use feroxfuzz::observers::ResponseObserver;
67 /// # use tokio_test;
68 /// # use std::time::Duration;
69 /// # fn main() -> Result<(), FeroxFuzzError> {
70 /// # tokio_test::block_on(async {
71 /// // for testing, normal Response comes as a result of a sent request
72 /// let reqwest_response = http::response::Builder::new().status(302).header("Location", "/somewhere").body("").unwrap();
73 ///
74 /// // should come from timing during the client's send function
75 /// let elapsed = Duration::from_secs(1);
76 ///
77 /// let response = AsyncResponse::try_from_reqwest_response(Request::default(), reqwest_response.into(), elapsed).await?;
78 /// let observer = ResponseObserver::with_response(response);
79 ///
80 /// # Result::<(), FeroxFuzzError>::Ok(())
81 /// # })
82 /// # }
83 /// ```
84 pub const fn with_response(response: R) -> Self
85 where
86 R: Response,
87 {
88 Self {
89 response,
90 name: RESPONSE_OBSERVER_NAME,
91 }
92 }
93
94 /// true if this `Response` is a well-formed HTTP redirect
95 ///
96 /// i.e. response code is 3XX and has a Location header
97 ///
98 /// # Examples
99 ///
100 /// ```
101 /// # use http;
102 /// # use feroxfuzz::responses::{Response, AsyncResponse};
103 /// # use feroxfuzz::requests::Request;
104 /// # use feroxfuzz::error::FeroxFuzzError;
105 /// # use feroxfuzz::observers::ResponseObserver;
106 /// # use tokio_test;
107 /// # use std::time::Duration;
108 /// # fn main() -> Result<(), FeroxFuzzError> {
109 /// # tokio_test::block_on(async {
110 /// // for testing, normal Response comes as a result of a sent request
111 /// let reqwest_response = http::response::Builder::new().status(302).header("Location", "/somewhere").body("").unwrap();
112 ///
113 /// // should come from timing during the client's send function
114 /// let elapsed = Duration::from_secs(1);
115 ///
116 /// let response = AsyncResponse::try_from_reqwest_response(Request::default(), reqwest_response.into(), elapsed).await?;
117 /// let observer: ResponseObserver<_> = response.into();
118 ///
119 /// assert_eq!(observer.is_redirect(), true);
120 /// assert_eq!(observer.is_permanent_redirect(), false);
121 /// # Result::<(), FeroxFuzzError>::Ok(())
122 /// # })
123 /// # }
124 /// ```
125 #[must_use]
126 pub fn is_redirect(&self) -> bool {
127 let is_redirect = (300..400).contains(&self.status_code());
128
129 is_redirect
130 && (self.headers().contains_key("Location") || self.headers().contains_key("location"))
131 }
132
133 /// true if this `Response` is one of the permanent versions of redirect
134 ///
135 /// i.e. response code is 301 (Moved Permanently) or 308 (Permanent Redirect)
136 /// and has a Location header
137 ///
138 /// # Examples
139 ///
140 /// ```
141 /// # use http;
142 /// # use feroxfuzz::responses::{Response, AsyncResponse};
143 /// # use feroxfuzz::observers::ResponseObserver;
144 /// # use feroxfuzz::requests::Request;
145 /// # use feroxfuzz::error::FeroxFuzzError;
146 /// # use tokio_test;
147 /// # use std::time::Duration;
148 /// # fn main() -> Result<(), FeroxFuzzError> {
149 /// # tokio_test::block_on(async {
150 /// // for testing, normal Response comes as a result of a sent request
151 /// let reqwest_response = http::response::Builder::new().status(308).header("Location", "/somewhere").body("").unwrap();
152 ///
153 /// // should come from timing during the client's send function
154 /// let elapsed = Duration::from_secs(1);
155 ///
156 /// let response = AsyncResponse::try_from_reqwest_response(Request::default(), reqwest_response.into(), elapsed).await?;
157 /// let observer: ResponseObserver<_> = response.into();
158 ///
159 /// assert_eq!(observer.is_redirect(), true);
160 /// assert_eq!(observer.is_permanent_redirect(), true);
161 /// # Result::<(), FeroxFuzzError>::Ok(())
162 /// # })
163 /// # }
164 /// ```
165 #[must_use]
166 pub fn is_permanent_redirect(&self) -> bool {
167 let numeric_code = self.status_code();
168 let is_permanent_redirect = numeric_code == 301 || numeric_code == 308;
169
170 is_permanent_redirect
171 && (self.headers().contains_key("Location") || self.headers().contains_key("location"))
172 }
173
174 /// determine whether the response is a directory
175 ///
176 /// handles 2xx and 3xx responses by either checking if the url ends with a / (2xx)
177 /// or if the Location header is present and matches the base url + / (3xx)
178 ///
179 /// # Examples
180 ///
181 /// example code requires the **crate-feature** `reqwest`
182 ///
183 /// A 2xx status code, with a path ending in `/` is interpreted as a directory
184 ///
185 /// ```
186 /// # use http;
187 /// # use feroxfuzz::responses::{Response, AsyncResponse};
188 /// # use feroxfuzz::requests::Request;
189 /// # use feroxfuzz::error::FeroxFuzzError;
190 /// # use feroxfuzz::observers::ResponseObserver;
191 /// # use tokio_test;
192 /// # use std::time::Duration;
193 /// # fn main() -> Result<(), FeroxFuzzError> {
194 /// # tokio_test::block_on(async {
195 /// // for testing, normal Response comes as a result of a sent request
196 /// let reqwest_response = http::response::Builder::new().status(200).body("derp").unwrap();
197 ///
198 /// // should come from timing during the client's send function
199 /// let elapsed = Duration::from_secs(1);
200 ///
201 /// let response = AsyncResponse::try_from_reqwest_response(Request::default(), reqwest_response.into(), elapsed).await?;
202 /// let observer: ResponseObserver<_> = response.into();
203 ///
204 /// assert_eq!(observer.url().as_str(), "http://no.url.provided.local/");
205 /// assert_eq!(observer.is_directory(), true);
206 /// # Result::<(), FeroxFuzzError>::Ok(())
207 /// # })
208 /// # }
209 /// ```
210 ///
211 /// A 3xx response with a location header that matches base url + /
212 ///
213 /// ```
214 /// # use http;
215 /// # use feroxfuzz::responses::{Response, AsyncResponse};
216 /// # use feroxfuzz::observers::ResponseObserver;
217 /// # use feroxfuzz::requests::Request;
218 /// # use feroxfuzz::error::FeroxFuzzError;
219 /// # use tokio_test;
220 /// # use std::time::Duration;
221 /// # fn main() -> Result<(), FeroxFuzzError> {
222 /// # tokio_test::block_on(async {
223 /// // for testing, normal Response comes as a result of a sent request
224 /// let mut reqwest_response = http::response::Builder::new().status(301).header("Location", "/").body("").unwrap();
225 ///
226 /// // should come from timing during the client's send function
227 /// let elapsed = Duration::from_secs(1);
228 ///
229 /// let response = AsyncResponse::try_from_reqwest_response(Request::default(), reqwest_response.into(), elapsed).await?;
230 /// let observer: ResponseObserver<_> = response.into();
231 ///
232 /// assert_eq!(observer.url().as_str(), "http://no.url.provided.local/");
233 /// assert_eq!(observer.is_directory(), true);
234 /// # Result::<(), FeroxFuzzError>::Ok(())
235 /// # })
236 /// # }
237 /// ```
238 pub fn is_directory(&self) -> bool {
239 if self.status_code() >= 300 && self.status_code() < 400 {
240 // status code is 3xx
241 if let Some(location) = self.get_header_case_insensitive("location") {
242 // and has a location header
243
244 // get absolute redirect Url based on the already known base url
245 if let Ok(abs_url) = self.url().join(&String::from_utf8_lossy(location)) {
246 let mut trailing_slash = self.url().as_str().to_string();
247
248 if !trailing_slash.ends_with('/') {
249 // only append a slash if not present already
250 trailing_slash.push('/');
251 }
252
253 if trailing_slash == abs_url.as_str() {
254 // if current response's Url + / == the absolute redirection
255 // location, we've found a directory
256 return true;
257 }
258 }
259 } else {
260 // 3xx response without a Location header
261 return false;
262 }
263 } else if self.status_code() >= 200 && self.status_code() < 300 {
264 // status code is 2xx, need to check if it ends in /
265 if self.url().as_str().ends_with('/') {
266 return true;
267 }
268 }
269
270 false
271 }
272
273 /// examine the response's url and grab the file's extension if one is available
274 /// to be grabbed.
275 #[allow(clippy::missing_panics_doc)]
276 #[must_use]
277 pub fn extension(&self) -> Option<&str> {
278 // path_segments:
279 // Return None for cannot-be-a-base URLs.
280 // When Some is returned, the iterator always contains at least one string
281 // (which may be empty).
282 //
283 // meaning: the two unwraps here are fine, the worst outcome is an empty string
284 let filename = self.url().path_segments().unwrap().next_back().unwrap();
285
286 if !filename.is_empty() {
287 // non-empty string, try to get extension
288 let parts: Vec<_> = filename
289 .split('.')
290 // keep things like /.bash_history from becoming an extension
291 .filter(|part| !part.is_empty())
292 .collect();
293
294 if parts.len() > 1 {
295 // filename + at least one extension, i.e. whatever.js becomes ["whatever", "js"]
296 return Some(parts.last().unwrap());
297 }
298 }
299
300 None
301 }
302
303 // account for any case variations in header names
304 fn get_header_case_insensitive(&self, header_name: &str) -> Option<&Vec<u8>> {
305 let lowercase_name = header_name.to_lowercase();
306
307 self.headers()
308 .iter()
309 .find(|(key, _)| key.to_lowercase() == lowercase_name)
310 .map(|(_, value)| value)
311 }
312}
313
314impl<R> Named for ResponseObserver<R>
315where
316 R: Response,
317{
318 fn name(&self) -> &str {
319 self.name
320 }
321}
322
323impl<R> Response for ResponseObserver<R>
324where
325 R: Response,
326{
327 /// get the `id` from the associated `Response`
328 fn id(&self) -> RequestId {
329 self.response.id()
330 }
331
332 /// get the `url` from the associated `Response`
333 fn url(&self) -> &Url {
334 self.response.url()
335 }
336
337 /// get the `status_code` from the associated `Response`
338 fn status_code(&self) -> u16 {
339 self.response.status_code()
340 }
341 /// get the `headers` from the associated `Response`
342 fn headers(&self) -> &HashMap<String, Vec<u8>> {
343 self.response.headers()
344 }
345
346 /// get the `body` from the associated `Response`
347 fn body(&self) -> &[u8] {
348 self.response.body()
349 }
350
351 /// get the `content_length` from the associated `Response`
352 fn content_length(&self) -> usize {
353 self.response.content_length()
354 }
355
356 /// get the `line_count` from the associated `Response`
357 fn line_count(&self) -> usize {
358 self.response.line_count()
359 }
360
361 /// get the `word_count` from the associated `Response`
362 fn word_count(&self) -> usize {
363 self.response.word_count()
364 }
365
366 /// get the original http request method used to generate the response
367 fn method(&self) -> &str {
368 self.response.method()
369 }
370
371 /// Get the [`Action`] to be taken as a result of this response
372 fn action(&self) -> Option<&Action> {
373 self.response.action()
374 }
375
376 fn request(&self) -> &Request {
377 self.response.request()
378 }
379}
380
381impl<R> Observer for ResponseObserver<R> where R: Response {}
382
383impl<R> ObserverHooks<R> for ResponseObserver<R>
384where
385 R: Response,
386{
387 #[instrument(skip_all, fields(%self.name), level = "trace")]
388 fn post_send_hook(&mut self, response: R) {
389 self.response = response;
390 }
391}
392
393impl<R> Default for ResponseObserver<R>
394where
395 R: Response + Default,
396{
397 fn default() -> Self {
398 Self {
399 name: RESPONSE_OBSERVER_NAME,
400 response: R::default(),
401 }
402 }
403}
404
405cfg_if! {
406 if #[cfg(feature = "async")] {
407 use crate::responses::AsyncResponse;
408
409 impl From<AsyncResponse> for ResponseObserver<AsyncResponse> {
410 fn from(response: AsyncResponse) -> Self {
411 Self::with_response(response)
412 }
413 }
414 }
415}
416
417cfg_if! {
418 if #[cfg(feature = "blocking")] {
419 use crate::responses::BlockingResponse;
420
421 impl From<BlockingResponse> for ResponseObserver<BlockingResponse> {
422 fn from(response: BlockingResponse) -> Self {
423 Self::with_response(response)
424 }
425 }
426 }
427
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use crate::error::FeroxFuzzError;
434 use crate::responses::AsyncResponse;
435 use http::response;
436 use std::time::Duration;
437
438 #[tokio::test]
439 async fn test_get_header_case_insensitive() -> Result<(), FeroxFuzzError> {
440 // Create a response with headers in different cases
441
442 let reqwest_response = response::Builder::new()
443 .status(200)
444 .header("Content-Type", "text/html")
445 .header("X-Custom-Header", "CustomValue")
446 .header("accept-encoding", "gzip")
447 .body("")
448 .unwrap();
449
450 let elapsed = Duration::from_secs(1);
451
452 let response = AsyncResponse::try_from_reqwest_response(
453 Request::default(),
454 reqwest_response.into(),
455 elapsed,
456 )
457 .await?;
458
459 let observer = ResponseObserver::with_response(response);
460
461 // Test exact case match
462 assert_eq!(
463 observer.get_header_case_insensitive("Content-Type"),
464 Some(&b"text/html".to_vec())
465 );
466
467 // Test different case variations
468 assert_eq!(
469 observer.get_header_case_insensitive("content-type"),
470 Some(&b"text/html".to_vec())
471 );
472 assert_eq!(
473 observer.get_header_case_insensitive("CONTENT-TYPE"),
474 Some(&b"text/html".to_vec())
475 );
476 assert_eq!(
477 observer.get_header_case_insensitive("CoNtEnT-tYpE"),
478 Some(&b"text/html".to_vec())
479 );
480
481 // Test another header with mixed case
482 assert_eq!(
483 observer.get_header_case_insensitive("x-custom-header"),
484 Some(&b"CustomValue".to_vec())
485 );
486 assert_eq!(
487 observer.get_header_case_insensitive("X-CUSTOM-HEADER"),
488 Some(&b"CustomValue".to_vec())
489 );
490
491 // Test lowercase header
492 assert_eq!(
493 observer.get_header_case_insensitive("ACCEPT-ENCODING"),
494 Some(&b"gzip".to_vec())
495 );
496
497 // Test header that doesn't exist
498 assert_eq!(
499 observer.get_header_case_insensitive("non-existent-header"),
500 None
501 );
502
503 Ok(())
504 }
505
506 #[tokio::test]
507 async fn test_is_redirect_with_case_variations() -> Result<(), FeroxFuzzError> {
508 // Create a response with Location header in lowercase
509
510 let reqwest_response = response::Builder::new()
511 .status(302)
512 .header("location", "/redirect")
513 .body("")
514 .unwrap();
515
516 let elapsed = Duration::from_secs(1);
517
518 let response = AsyncResponse::try_from_reqwest_response(
519 Request::default(),
520 reqwest_response.into(),
521 elapsed,
522 )
523 .await?;
524
525 let observer_lowercase = ResponseObserver::with_response(response);
526
527 assert!(observer_lowercase.is_redirect());
528
529 // Create a response with Location header in uppercase
530 let reqwest_response = response::Builder::new()
531 .status(302)
532 .header("LOCaTION", "/redirect")
533 .body("")
534 .unwrap();
535
536 let response = AsyncResponse::try_from_reqwest_response(
537 Request::default(),
538 reqwest_response.into(),
539 elapsed,
540 )
541 .await?;
542
543 let observer_uppercase = ResponseObserver::with_response(response);
544
545 // This should still pass with the current implementation, which only checks for "Location" and "location"
546 // If we update is_redirect to use get_header_case_insensitive, this test would still pass
547 assert!(observer_uppercase.is_redirect());
548
549 Ok(())
550 }
551}