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}