use std::{
collections::HashMap,
convert::{TryFrom, TryInto},
fmt,
str::FromStr,
sync::Arc,
};
use anyhow::{Context, Result};
use console::style;
use reqwest::{
header::{HeaderMap, HeaderName, HeaderValue},
Method, Response, StatusCode, Url,
};
use serde::ser::SerializeStruct;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use crate::{
config::OutputLevel,
event_handlers::{Command, Handles},
traits::FeroxSerialize,
url::FeroxUrl,
utils::{self, fmt_err, status_colorizer},
CommandSender,
};
#[derive(Debug, Clone)]
pub struct FeroxResponse {
url: Url,
original_url: String,
status: StatusCode,
method: Method,
text: String,
content_length: u64,
line_count: usize,
word_count: usize,
headers: HeaderMap,
wildcard: bool,
pub(crate) output_level: OutputLevel,
pub(crate) extension: Option<String>,
}
impl Default for FeroxResponse {
fn default() -> Self {
Self {
url: Url::parse("http://localhost").unwrap(),
original_url: "".to_string(),
status: Default::default(),
method: Method::default(),
text: "".to_string(),
content_length: 0,
line_count: 0,
word_count: 0,
headers: Default::default(),
wildcard: false,
output_level: Default::default(),
extension: None,
}
}
}
impl fmt::Display for FeroxResponse {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"FeroxResponse {{ url: {}, method: {}, status: {}, content-length: {} }}",
self.url(),
self.method(),
self.status(),
self.content_length()
)
}
}
impl FeroxResponse {
pub fn status(&self) -> &StatusCode {
&self.status
}
pub fn method(&self) -> &Method {
&self.method
}
pub fn wildcard(&self) -> bool {
self.wildcard
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn text(&self) -> &str {
&self.text
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn content_length(&self) -> u64 {
self.content_length
}
pub fn set_url(&mut self, url: &str) {
match Url::parse(url) {
Ok(url) => {
self.url = url;
}
Err(e) => {
log::warn!("Could not parse {} into a Url: {}", url, e);
}
};
}
pub fn set_wildcard(&mut self, is_wildcard: bool) {
self.wildcard = is_wildcard;
}
#[cfg(test)]
pub fn set_text(&mut self, text: &str) {
self.text = String::from(text);
self.content_length = self.text.len() as u64;
self.line_count = self.text.lines().count();
self.word_count = self
.text
.lines()
.map(|s| s.split_whitespace().count())
.sum();
}
pub fn drop_text(&mut self) {
self.text = String::new();
}
pub fn is_file(&self) -> bool {
let has_extension = match self.url.path_segments() {
Some(path) => {
if let Some(last) = path.last() {
last.contains('.') } else {
false
}
}
None => false,
};
self.url.query_pairs().count() > 0 || has_extension
}
pub fn line_count(&self) -> usize {
self.line_count
}
pub fn word_count(&self) -> usize {
self.word_count
}
pub async fn from(
response: Response,
original_url: &str,
method: &str,
output_level: OutputLevel,
) -> Self {
let url = response.url().clone();
let status = response.status();
let headers = response.headers().clone();
let content_length = response.content_length().unwrap_or(0);
let text = response
.text()
.await
.with_context(|| "Could not parse body from response")
.unwrap_or_default();
let line_count = text.lines().count();
let word_count = text.lines().map(|s| s.split_whitespace().count()).sum();
FeroxResponse {
url,
original_url: original_url.to_string(),
status,
method: Method::from_bytes(method.as_bytes()).unwrap_or(Method::GET),
content_length,
text,
headers,
line_count,
word_count,
output_level,
wildcard: false,
extension: None,
}
}
pub(crate) fn parse_extension(&mut self, handles: Arc<Handles>) -> Result<()> {
log::trace!("enter: parse_extension");
if !handles.config.collect_extensions {
return Ok(());
}
let filename = self.url.path_segments().unwrap().last().unwrap();
if !filename.is_empty() {
let parts: Vec<_> = filename
.split('.')
.filter(|part| !part.is_empty())
.collect();
if parts.len() > 1 {
self.extension = Some(parts.last().unwrap().to_string())
}
}
if let Some(extension) = &self.extension {
if handles
.config
.status_codes
.contains(&self.status().as_u16()) || !handles.config.filter_status.is_empty()
{
#[cfg(test)]
handles
.send_scan_command(Command::AddDiscoveredExtension(extension.to_owned()))
.unwrap_or_default();
#[cfg(not(test))]
handles.send_scan_command(Command::AddDiscoveredExtension(extension.to_owned()))?;
}
}
log::trace!("exit: parse_extension");
Ok(())
}
pub(crate) fn reached_max_depth(
&self,
base_depth: usize,
max_depth: usize,
handles: Arc<Handles>,
) -> bool {
log::trace!(
"enter: reached_max_depth({}, {}, {:?})",
base_depth,
max_depth,
handles
);
if max_depth == 0 {
log::trace!("exit: reached_max_depth -> false");
return false;
}
let url = FeroxUrl::from_url(&self.url, handles);
let depth = url.depth().unwrap_or_default();
if depth - base_depth >= max_depth {
return true;
}
log::trace!("exit: reached_max_depth -> false");
false
}
pub fn is_directory(&self) -> bool {
log::trace!("enter: is_directory({})", self);
if self.status().is_redirection() {
match self.headers().get("Location") {
Some(loc) => {
log::debug!("Location header: {:?}", loc);
if let Ok(loc_str) = loc.to_str() {
if let Ok(abs_url) = self.url().join(loc_str) {
if format!("{}/", self.url()) == abs_url.as_str() {
log::debug!(
"found directory suitable for recursion: {}",
self.url()
);
log::trace!("exit: is_directory -> true");
return true;
}
}
}
}
None => {
log::debug!("expected Location header, but none was found: {}", self);
log::trace!("exit: is_directory -> false");
return false;
}
}
} else if self.status().is_success() || matches!(self.status(), &StatusCode::FORBIDDEN) {
if self.url().as_str().ends_with('/') {
log::debug!("{} is directory suitable for recursion", self.url());
log::trace!("exit: is_directory -> true");
return true;
}
}
log::trace!("exit: is_directory -> false");
false
}
pub fn send_report(self, report_sender: CommandSender) -> Result<()> {
log::trace!("enter: send_report({:?}", report_sender);
report_sender.send(Command::Report(Box::new(self)))?;
log::trace!("exit: send_report");
Ok(())
}
}
impl FeroxSerialize for FeroxResponse {
fn as_str(&self) -> String {
let lines = self.line_count().to_string();
let words = self.word_count().to_string();
let chars = self.content_length().to_string();
let status = self.status().as_str();
let method = self.method().as_str();
let wild_status = status_colorizer("WLD");
let mut url_with_redirect = match (
self.status().is_redirection(),
self.headers().get("Location").is_some(),
) {
(true, true) => {
let loc = self
.headers()
.get("Location")
.unwrap() .to_str()
.unwrap_or("Unknown")
.to_string();
let loc = if loc.starts_with('/') {
if let Ok(joined) = self.url().join(&loc) {
joined.to_string()
} else {
loc
}
} else {
loc
};
let loc = style(loc).yellow();
format!("{} => {loc}", self.url())
}
_ => {
self.url().to_string()
}
};
if self.wildcard && matches!(self.output_level, OutputLevel::Default | OutputLevel::Quiet) {
let mut message = format!(
"{} {:>8} {:>8}l {:>8}w {:>8}c Got {} for {} (url length: {})\n",
wild_status,
method,
lines,
words,
chars,
status_colorizer(status),
self.url(),
FeroxUrl::path_length_of_url(&self.url)
);
if self.status().is_redirection() {
url_with_redirect = format!(
"{} {:>9} {:>9} {:>9} {}\n",
wild_status, "-", "-", "-", url_with_redirect
);
message.push_str(&url_with_redirect);
}
message
} else {
utils::create_report_string(
self.status.as_str(),
method,
&lines,
&words,
&chars,
&url_with_redirect,
self.output_level,
)
}
}
fn as_json(&self) -> anyhow::Result<String> {
let mut json = serde_json::to_string(&self)
.with_context(|| fmt_err(&format!("Could not convert {} to JSON", self.url())))?;
json.push('\n');
Ok(json)
}
}
impl Serialize for FeroxResponse {
fn serialize<S>(&self, serializer: S) -> anyhow::Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut headers = HashMap::new();
let mut state = serializer.serialize_struct("FeroxResponse", 8)?;
for (key, value) in &self.headers {
let k = key.as_str().to_owned();
let v = String::from_utf8_lossy(value.as_bytes());
headers.insert(k, v);
}
state.serialize_field("type", "response")?;
state.serialize_field("url", self.url.as_str())?;
state.serialize_field("original_url", self.original_url.as_str())?;
state.serialize_field("path", self.url.path())?;
state.serialize_field("wildcard", &self.wildcard)?;
state.serialize_field("status", &self.status.as_u16())?;
state.serialize_field("method", &self.method.as_str())?;
state.serialize_field("content_length", &self.content_length)?;
state.serialize_field("line_count", &self.line_count)?;
state.serialize_field("word_count", &self.word_count)?;
state.serialize_field("headers", &headers)?;
state.serialize_field(
"extension",
self.extension.as_ref().unwrap_or(&String::new()),
)?;
state.end()
}
}
impl<'de> Deserialize<'de> for FeroxResponse {
fn deserialize<D>(deserializer: D) -> anyhow::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let mut response = Self {
url: Url::parse("http://localhost").unwrap(),
original_url: String::new(),
status: StatusCode::OK,
method: Method::GET,
text: String::new(),
content_length: 0,
headers: HeaderMap::new(),
wildcard: false,
output_level: Default::default(),
line_count: 0,
word_count: 0,
extension: None,
};
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
for (key, value) in &map {
match key.as_str() {
"url" => {
if let Some(url) = value.as_str() {
if let Ok(parsed) = Url::parse(url) {
response.url = parsed;
}
}
}
"original_url" => {
if let Some(og_url) = value.as_str() {
response.original_url = String::from(og_url);
}
}
"status" => {
if let Some(num) = value.as_u64() {
if let Ok(smaller) = u16::try_from(num) {
if let Ok(status) = StatusCode::from_u16(smaller) {
response.status = status;
}
}
}
}
"method" => {
if let Some(method) = value.as_str() {
response.method = Method::from_bytes(method.as_bytes()).unwrap_or_default();
}
}
"content_length" => {
if let Some(num) = value.as_u64() {
response.content_length = num;
}
}
"line_count" => {
if let Some(num) = value.as_u64() {
response.line_count = num.try_into().unwrap_or_default();
}
}
"word_count" => {
if let Some(num) = value.as_u64() {
response.word_count = num.try_into().unwrap_or_default();
}
}
"headers" => {
let mut headers = HeaderMap::<HeaderValue>::default();
if let Some(map_headers) = value.as_object() {
for (h_key, h_value) in map_headers {
let h_value_str = h_value.as_str().unwrap_or("");
let h_name = HeaderName::from_str(h_key)
.unwrap_or_else(|_| HeaderName::from_str("Unknown").unwrap());
let h_value_parsed = HeaderValue::from_str(h_value_str)
.unwrap_or_else(|_| HeaderValue::from_str("Unknown").unwrap());
headers.insert(h_name, h_value_parsed);
}
}
response.headers = headers;
}
"wildcard" => {
if let Some(result) = value.as_bool() {
response.wildcard = result;
}
}
"extension" => {
if let Some(result) = value.as_str() {
response.extension = Some(result.to_string());
}
}
_ => {}
}
}
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Configuration;
use std::default::Default;
#[test]
fn reached_max_depth_returns_early_on_zero() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = Url::parse("http://localhost").unwrap();
let response = FeroxResponse {
url,
..Default::default()
};
let result = response.reached_max_depth(0, 0, handles);
assert!(!result);
}
#[test]
fn reached_max_depth_current_depth_equals_max() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = Url::parse("http://localhost/one/two").unwrap();
let response = FeroxResponse {
url,
..Default::default()
};
let result = response.reached_max_depth(0, 2, handles);
assert!(result);
}
#[test]
fn reached_max_depth_current_depth_less_than_max() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = Url::parse("http://localhost").unwrap();
let response = FeroxResponse {
url,
..Default::default()
};
let result = response.reached_max_depth(0, 2, handles);
assert!(!result);
}
#[test]
fn reached_max_depth_base_depth_equals_max_depth() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = Url::parse("http://localhost/one/two").unwrap();
let response = FeroxResponse {
url,
..Default::default()
};
let result = response.reached_max_depth(2, 2, handles);
assert!(!result);
}
#[test]
fn reached_max_depth_current_greater_than_max() {
let handles = Arc::new(Handles::for_testing(None, None).0);
let url = Url::parse("http://localhost/one/two/three").unwrap();
let response = FeroxResponse {
url,
..Default::default()
};
let result = response.reached_max_depth(0, 2, handles);
assert!(result);
}
#[test]
fn parse_extension_finds_simple_extension() {
let config = Configuration {
collect_extensions: true,
..Default::default()
};
let (handles, _) = Handles::for_testing(None, Some(Arc::new(config)));
let url = Url::parse("http://localhost/derp.js").unwrap();
let mut response = FeroxResponse {
url,
..Default::default()
};
response.parse_extension(Arc::new(handles)).unwrap();
assert_eq!(response.extension, Some(String::from("js")));
}
#[test]
fn parse_extension_ignores_hidden_files() {
let config = Configuration {
collect_extensions: true,
..Default::default()
};
let (handles, _) = Handles::for_testing(None, Some(Arc::new(config)));
let url = Url::parse("http://localhost/.bash_history").unwrap();
let mut response = FeroxResponse {
url,
..Default::default()
};
response.parse_extension(Arc::new(handles)).unwrap();
assert_eq!(response.extension, None);
}
#[test]
fn parse_extension_early_returns_based_on_config() {
let (handles, _) = Handles::for_testing(None, None);
let url = Url::parse("http://localhost/derp.js").unwrap();
let mut response = FeroxResponse {
url,
..Default::default()
};
response.parse_extension(Arc::new(handles)).unwrap();
assert_eq!(response.extension, None);
}
}