use std::path::{Path, PathBuf};
use super::state::RequestRecord;
fn write_new_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
f.write_all(bytes)?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct YankReport {
pub path: PathBuf,
pub clipboard_ok: bool,
pub clipboard_error: Option<String>,
pub bytes: usize,
}
#[derive(Debug, Clone)]
pub struct ReplayReport {
pub path: PathBuf,
pub bytes: usize,
pub upstream_status: Option<i32>,
pub upstream_body_excerpt: Vec<u8>,
}
const HEADER_SKIP: &[&str] = &[
"host",
"content-length",
"connection",
"transfer-encoding",
"te",
"upgrade",
"proxy-connection",
"keep-alive",
];
#[must_use]
pub fn render_curl(rec: &RequestRecord, body_sidecar: Option<&PathBuf>) -> String {
let scheme = if rec.tls_profile.is_some() || rec.host.ends_with(":443") {
"https"
} else if rec.host.ends_with(":80") {
"http"
} else {
"https"
};
let url = format!("{scheme}://{}{}", rec.host, rec.path);
let mut out = String::new();
out.push_str("curl");
if rec.method != "GET" {
out.push_str(" -X '");
out.push_str(&shell_escape_single(&rec.method));
out.push('\'');
}
out.push_str(" \\\n ");
out.push('\'');
out.push_str(&shell_escape_single(&url));
out.push('\'');
for (k, v) in &rec.req_headers {
if HEADER_SKIP.iter().any(|s| s.eq_ignore_ascii_case(k)) {
continue;
}
out.push_str(" \\\n -H '");
out.push_str(&shell_escape_single(&format!("{k}: {v}")));
out.push('\'');
}
if !rec.req_body_excerpt.is_empty() {
match (body_sidecar, std::str::from_utf8(&rec.req_body_excerpt)) {
(_, Ok(text)) => {
out.push_str(" \\\n --data-binary '");
out.push_str(&shell_escape_single(text));
out.push('\'');
}
(Some(path), Err(_)) => {
out.push_str(" \\\n --data-binary '@");
out.push_str(&shell_escape_single(&path.display().to_string()));
out.push('\'');
}
(None, Err(_)) => {
out.push_str(" \\\n # binary body omitted (no sidecar path provided)");
}
}
}
out.push('\n');
out
}
fn shell_escape_single(s: &str) -> String {
s.replace('\'', r"'\''")
}
pub fn yank_to_disk_and_clipboard(rec: &RequestRecord, seq: u64) -> std::io::Result<YankReport> {
let dir = std::env::temp_dir();
let curl_path = dir.join(format!("wafrift-yank-{seq}.curl"));
let body_needs_sidecar =
!rec.req_body_excerpt.is_empty() && std::str::from_utf8(&rec.req_body_excerpt).is_err();
let body_path = if body_needs_sidecar {
Some(dir.join(format!("wafrift-yank-{seq}.body")))
} else {
None
};
let curl = render_curl(rec, body_path.as_ref());
write_new_only(&curl_path, curl.as_bytes())?;
if let Some(p) = &body_path {
write_new_only(p, &rec.req_body_excerpt)?;
}
let (clipboard_ok, clipboard_error) = try_set_clipboard(&curl);
Ok(YankReport {
path: curl_path,
clipboard_ok,
clipboard_error,
bytes: curl.len(),
})
}
fn build_curl_argv(rec: &RequestRecord, body_path: Option<&PathBuf>) -> Vec<std::ffi::OsString> {
let scheme = if rec.tls_profile.is_some() || rec.host.ends_with(":443") {
"https"
} else if rec.host.ends_with(":80") {
"http"
} else {
"https"
};
let url = format!("{scheme}://{}{}", rec.host, rec.path);
let mut argv: Vec<std::ffi::OsString> = Vec::new();
if rec.method != "GET" {
argv.push("-X".into());
argv.push((&rec.method).into());
}
argv.push(url.into());
for (k, v) in &rec.req_headers {
if HEADER_SKIP.iter().any(|s| s.eq_ignore_ascii_case(k)) {
continue;
}
argv.push("-H".into());
argv.push(format!("{k}: {v}").into());
}
if !rec.req_body_excerpt.is_empty() {
match (body_path, std::str::from_utf8(&rec.req_body_excerpt)) {
(_, Ok(text)) => {
argv.push("--data-binary".into());
argv.push(text.into());
}
(Some(p), Err(_)) => {
argv.push("--data-binary".into());
let at_path: std::ffi::OsString = {
#[cfg(unix)]
{
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let mut v = b"@".to_vec();
v.extend_from_slice(p.as_os_str().as_bytes());
std::ffi::OsString::from_vec(v)
}
#[cfg(not(unix))]
{
format!("@{}", p.display()).into()
}
};
argv.push(at_path);
}
(None, Err(_)) => {}
}
}
argv
}
pub fn replay_to_disk_and_optionally_exec(
rec: &RequestRecord,
seq: u64,
) -> std::io::Result<ReplayReport> {
let autoexec = std::env::var("WAFRIFT_REPLAY_AUTOEXEC")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
replay_to_disk_with_autoexec(rec, seq, autoexec)
}
pub(crate) fn replay_to_disk_with_autoexec(
rec: &RequestRecord,
seq: u64,
autoexec: bool,
) -> std::io::Result<ReplayReport> {
let dir = std::env::temp_dir();
let curl_path = dir.join(format!("wafrift-replay-{seq}.curl"));
let body_needs_sidecar =
!rec.req_body_excerpt.is_empty() && std::str::from_utf8(&rec.req_body_excerpt).is_err();
let body_path = if body_needs_sidecar {
Some(dir.join(format!("wafrift-replay-{seq}.body")))
} else {
None
};
let curl = render_curl(rec, body_path.as_ref());
write_new_only(&curl_path, curl.as_bytes())?;
if let Some(p) = &body_path {
write_new_only(p, &rec.req_body_excerpt)?;
}
let (upstream_status, upstream_body_excerpt) = if autoexec {
let argv = build_curl_argv(rec, body_path.as_ref());
match std::process::Command::new("curl").args(&argv).output() {
Ok(out) => {
let mut excerpt = out.stdout;
excerpt.truncate(256);
(out.status.code(), excerpt)
}
Err(_) => (None, Vec::new()),
}
} else {
(None, Vec::new())
};
Ok(ReplayReport {
path: curl_path,
bytes: curl.len(),
upstream_status,
upstream_body_excerpt,
})
}
#[cfg(feature = "clipboard")]
fn try_set_clipboard(s: &str) -> (bool, Option<String>) {
match arboard::Clipboard::new() {
Ok(mut clip) => match clip.set_text(s.to_string()) {
Ok(()) => (true, None),
Err(e) => (false, Some(e.to_string())),
},
Err(e) => (false, Some(e.to_string())),
}
}
#[cfg(not(feature = "clipboard"))]
fn try_set_clipboard(_s: &str) -> (bool, Option<String>) {
(
false,
Some("clipboard feature disabled at build time".into()),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn rec_full() -> RequestRecord {
RequestRecord {
timestamp: "12:34:56".into(),
host: "api.target.com".into(),
method: "POST".into(),
path: "/admin?id=1".into(),
status: 200,
bypassed: true,
blocked: false,
techniques: "encoding:UrlEncode".into(),
tls_profile: Some("chrome131".into()),
body_padded: false,
upstream_latency_ms: 45,
waf_name: Some("Cloudflare".into()),
req_headers: vec![
("Host".into(), "api.target.com".into()), ("X-Original-URL".into(), "/admin".into()),
("User-Agent".into(), "Mozilla'5.0".into()), ("Content-Length".into(), "10".into()), ],
req_body_excerpt: b"q=' OR 1=1--".to_vec(),
req_headers_pre: vec![],
req_body_pre_excerpt: vec![],
resp_headers: vec![],
resp_body_excerpt: vec![],
resp_body_total: 0,
attempts: 1,
}
}
#[test]
fn curl_uses_https_when_tls_profile_set() {
let r = rec_full();
let s = render_curl(&r, None);
assert!(s.contains("https://api.target.com/admin?id=1"));
assert!(s.starts_with("curl -X 'POST'"));
}
#[test]
fn curl_skips_host_and_content_length_headers() {
let s = render_curl(&rec_full(), None);
assert!(!s.contains("'Host: "));
assert!(!s.contains("'Content-Length: "));
assert!(s.contains("'X-Original-URL: /admin'"));
}
#[test]
fn curl_escapes_single_quotes_in_header_value() {
let s = render_curl(&rec_full(), None);
assert!(s.contains(r"Mozilla'\''5.0"));
}
#[test]
fn curl_emits_data_binary_with_escaping() {
let s = render_curl(&rec_full(), None);
assert!(s.contains(r"--data-binary 'q='\''"));
}
#[test]
fn curl_uses_get_implicit_when_method_is_get() {
let mut r = rec_full();
r.method = "GET".into();
r.req_body_excerpt.clear();
let s = render_curl(&r, None);
assert!(s.starts_with("curl \\\n"), "no -X for GET — got {s:?}");
}
#[test]
fn curl_falls_back_to_http_when_host_has_port_80() {
let mut r = rec_full();
r.tls_profile = None;
r.host = "intranet.local:80".into();
let s = render_curl(&r, None);
assert!(s.contains("http://intranet.local:80/"));
}
#[test]
fn curl_emits_data_binary_at_path_for_binary_body() {
let mut r = rec_full();
r.req_body_excerpt = vec![0xff, 0xfe, 0x00, 0x01];
let p = PathBuf::from("/tmp/wafrift-yank-7.body");
let s = render_curl(&r, Some(&p));
assert!(s.contains("--data-binary '@/tmp/wafrift-yank-7.body'"));
}
#[test]
fn replay_writes_curl_file_with_replay_prefix() {
let r = rec_full();
let report = replay_to_disk_with_autoexec(&r, 9999, false).expect("write");
assert!(
report
.path
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("wafrift-replay-"),
"replay file must use the replay prefix, got {}",
report.path.display()
);
assert!(
report.upstream_status.is_none(),
"no upstream call when autoexec is off"
);
assert!(report.path.exists(), "replay file must be on disk");
let body = std::fs::read_to_string(&report.path).expect("read back");
assert!(
body.starts_with("curl"),
"rendered curl recoverable: {body}"
);
std::fs::remove_file(&report.path).ok();
}
#[test]
fn replay_autoexec_branch_runs_without_panic_against_loopback() {
let mut r = rec_full();
r.host = "127.0.0.1:1".into();
r.tls_profile = None; let report = replay_to_disk_with_autoexec(&r, 9998, true).expect("write");
assert!(
report.path.exists(),
"replay file written even on the exec path"
);
assert!(
report.upstream_body_excerpt.len() <= 256,
"excerpt respects the 256-byte cap"
);
std::fs::remove_file(&report.path).ok();
}
#[test]
fn shell_escape_handles_apostrophes() {
assert_eq!(shell_escape_single("hello"), "hello");
assert_eq!(shell_escape_single("it's me"), r"it'\''s me");
assert_eq!(shell_escape_single("''"), r"'\'''\''");
}
#[test]
fn render_curl_quotes_method_field() {
let mut r = rec_full();
r.method = "POST$(id)".into();
let s = render_curl(&r, None);
assert!(
s.contains("-X 'POST$(id)'") || s.contains("-X 'POST$(id)'\\'"),
"method must be quoted in rendered curl, got: {s:?}"
);
assert!(
!s.contains("-X POST$(id)"),
"unquoted method would be shell-injected; got: {s:?}"
);
}
#[test]
fn build_curl_argv_does_not_include_shell_metacharacters_as_code() {
let mut r = rec_full();
r.method = "POST".into();
r.req_headers = vec![("X-Injected".into(), "$(id)>/tmp/pwned".into())];
r.req_body_excerpt = b"normal body".to_vec();
let argv = build_curl_argv(&r, None);
let header_arg: Vec<_> = argv
.iter()
.filter(|a| a.to_string_lossy().contains("$(id)"))
.collect();
assert!(
!header_arg.is_empty(),
"header with shell metacharacter must be carried verbatim"
);
let h_idx = argv
.iter()
.position(|a| a.to_string_lossy() == "-H")
.expect("-H flag present");
let h_val = argv[h_idx + 1].to_string_lossy();
assert!(
h_val.contains("$(id)"),
"header value must be a distinct OsString: got {h_val:?}"
);
}
#[test]
fn build_curl_argv_method_is_separate_element() {
let mut r = rec_full();
r.method = "DELETE".into();
r.req_body_excerpt.clear();
let argv = build_curl_argv(&r, None);
let x_idx = argv
.iter()
.position(|a| a.to_string_lossy() == "-X")
.expect("-X flag present for non-GET");
assert_eq!(
argv[x_idx + 1].to_string_lossy(),
"DELETE",
"method must be next element after -X, not concatenated"
);
}
#[test]
fn write_new_only_refuses_to_overwrite_existing_file() {
let path = std::env::temp_dir().join(format!(
"wafrift-yank-r26-exists-{}-{}.curl",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::write(&path, b"pre-existing").expect("seed");
let err =
super::write_new_only(&path, b"new content").expect_err("must refuse to overwrite");
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
let got = std::fs::read(&path).expect("read back");
assert_eq!(got, b"pre-existing");
let _ = std::fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
fn write_new_only_refuses_to_follow_symlink() {
let base = std::env::temp_dir().join(format!(
"wafrift-r26-symlink-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&base).expect("mkdir");
let target = base.join("secret.txt");
std::fs::write(&target, b"OPERATOR-SECRET").expect("seed target");
let link = base.join("yank.curl");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let err =
super::write_new_only(&link, b"ATTACKER-CONTENT").expect_err("must refuse symlink");
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
let got = std::fs::read(&target).expect("target intact");
assert_eq!(got, b"OPERATOR-SECRET", "symlink target was clobbered");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn write_new_only_succeeds_on_fresh_path() {
let path = std::env::temp_dir().join(format!(
"wafrift-yank-r26-fresh-{}-{}.curl",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
super::write_new_only(&path, b"hello").expect("must succeed");
let got = std::fs::read(&path).expect("read");
assert_eq!(got, b"hello");
let _ = std::fs::remove_file(&path);
}
}