mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-21 11:56:38 +00:00
feat(fuzz): scaffold cargo-fuzz harness for smoke targets (#3537)
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rustfs::app::object_usecase::{normalize_extract_entry_key, validate_extract_relative_path};
|
||||
|
||||
fn parse_case(data: &[u8]) -> (String, Option<String>, bool, Vec<String>) {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
let mut parts = text.splitn(4, '\n');
|
||||
let path = parts.next().unwrap_or_default().to_string();
|
||||
let prefix = parts
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let is_dir = parts.next().unwrap_or_default().trim().eq_ignore_ascii_case("dir");
|
||||
let flags = parts
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|flag| !flag.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
|
||||
(path, prefix, is_dir, flags)
|
||||
}
|
||||
|
||||
fn apply_flag(mut path: String, mut prefix: Option<String>, flag: &str) -> (String, Option<String>) {
|
||||
match flag {
|
||||
"path_parent" => path.push_str("/../escape.txt"),
|
||||
"path_current" => path.push_str("/./child"),
|
||||
"path_backslash_parent" => path.push_str("\\..\\escape.txt"),
|
||||
"path_leading_slash" => path = format!("/{path}"),
|
||||
"path_empty" => path.clear(),
|
||||
"path_long" => path = format!("{path}/{}", "segment".repeat(256)),
|
||||
"prefix_parent" => prefix = Some("../victim-bucket".to_string()),
|
||||
"prefix_trimmed" => prefix = prefix.map(|value| format!(" /{value}/ ")),
|
||||
"prefix_empty" => prefix = Some(String::new()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
(path, prefix)
|
||||
}
|
||||
|
||||
fn materialize_case(path: String, prefix: Option<String>, flags: &[String]) -> (String, Option<String>) {
|
||||
flags
|
||||
.iter()
|
||||
.fold((path, prefix), |(path, prefix), flag| apply_flag(path, prefix, flag))
|
||||
}
|
||||
|
||||
fn has_dot_segments(path: &str) -> bool {
|
||||
path.split(['/', '\\']).any(|segment| {
|
||||
let trimmed = segment.trim();
|
||||
trimmed == "." || trimmed == ".."
|
||||
})
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (path, prefix, is_dir, flags) = parse_case(data);
|
||||
let (path, prefix) = materialize_case(path, prefix, &flags);
|
||||
|
||||
let _ = validate_extract_relative_path(&path);
|
||||
if let Some(prefix) = prefix.as_deref() {
|
||||
let _ = validate_extract_relative_path(prefix);
|
||||
}
|
||||
|
||||
if let Ok(key) = normalize_extract_entry_key(&path, prefix.as_deref(), is_dir) {
|
||||
assert!(
|
||||
!has_dot_segments(&key),
|
||||
"accepted archive entry retained dot segments: path={:?} prefix={:?} key={:?}",
|
||||
path,
|
||||
prefix,
|
||||
key
|
||||
);
|
||||
assert!(
|
||||
!key.starts_with('/'),
|
||||
"accepted archive entry escaped bucket namespace: path={:?} prefix={:?} key={:?}",
|
||||
path,
|
||||
prefix,
|
||||
key
|
||||
);
|
||||
if is_dir {
|
||||
assert!(
|
||||
key.ends_with('/'),
|
||||
"accepted archive directory lost trailing slash: path={:?} prefix={:?} key={:?}",
|
||||
path,
|
||||
prefix,
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rustfs_ecstore::bucket::utils::{check_bucket_and_object_names, check_list_objs_args, check_valid_bucket_name_strict};
|
||||
|
||||
fn parse_case(data: &[u8]) -> (String, String) {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
let mut parts = text.splitn(2, '\n');
|
||||
let bucket = parts.next().unwrap_or_default().to_string();
|
||||
let object = parts.next().unwrap_or_default().to_string();
|
||||
(bucket, object)
|
||||
}
|
||||
|
||||
fn looks_like_ipv4(text: &str) -> bool {
|
||||
let parts: Vec<_> = text.split('.').collect();
|
||||
parts.len() == 4
|
||||
&& parts
|
||||
.iter()
|
||||
.all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (bucket, object) = parse_case(data);
|
||||
|
||||
let strict_bucket_ok = check_valid_bucket_name_strict(&bucket).is_ok();
|
||||
let pair_ok = check_bucket_and_object_names(&bucket, &object).is_ok();
|
||||
let list_ok = check_list_objs_args(&bucket, &object, &None).is_ok();
|
||||
|
||||
if strict_bucket_ok {
|
||||
let trimmed = bucket.trim();
|
||||
assert!((3..=63).contains(&trimmed.len()), "accepted bucket has invalid length: {:?}", bucket);
|
||||
assert_eq!(trimmed, bucket, "strict bucket validation should not accept leading/trailing whitespace");
|
||||
assert!(trimmed != "rustfs", "strict bucket validation should reject reserved bucket name");
|
||||
assert!(!looks_like_ipv4(trimmed), "strict bucket validation should reject IPv4 bucket names");
|
||||
assert!(
|
||||
!trimmed.contains("..") && !trimmed.contains(".-") && !trimmed.contains("-."),
|
||||
"strict bucket validation should reject forbidden bucket sequences"
|
||||
);
|
||||
assert!(
|
||||
trimmed
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '.' || ch == '-'),
|
||||
"strict bucket validation accepted an unexpected character set: {:?}",
|
||||
bucket
|
||||
);
|
||||
}
|
||||
|
||||
if pair_ok {
|
||||
assert!(strict_bucket_ok, "accepted bucket/object pair must also satisfy strict bucket validation");
|
||||
}
|
||||
|
||||
if list_ok {
|
||||
assert!(strict_bucket_ok, "accepted list-object args must also satisfy strict bucket validation");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rustfs_filemeta::FileMeta;
|
||||
use rustfs_utils::compress::{CompressionAlgorithm, decompress_block};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn pick_algorithm(tag: u8) -> CompressionAlgorithm {
|
||||
match tag % 6 {
|
||||
0 => CompressionAlgorithm::Gzip,
|
||||
1 => CompressionAlgorithm::Deflate,
|
||||
2 => CompressionAlgorithm::Zstd,
|
||||
3 => CompressionAlgorithm::Lz4,
|
||||
4 => CompressionAlgorithm::Brotli,
|
||||
_ => CompressionAlgorithm::Snappy,
|
||||
}
|
||||
}
|
||||
|
||||
fn exercise_payload(payload: &[u8], algorithm: CompressionAlgorithm) {
|
||||
let _ = FileMeta::load(payload);
|
||||
let _ = FileMeta::load_or_convert(payload);
|
||||
let _ = FileMeta::read_format_versions(payload);
|
||||
let _ = decompress_block(payload, algorithm);
|
||||
}
|
||||
|
||||
fn interesting_prefix_lengths(len: usize) -> BTreeSet<usize> {
|
||||
let mut lengths = BTreeSet::from([0usize, 1, 2, 4, 5, 8, 16, 32]);
|
||||
lengths.insert(len);
|
||||
lengths.insert(len.saturating_sub(1));
|
||||
lengths.into_iter().filter(|candidate| *candidate <= len).collect()
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let algorithm = pick_algorithm(data.first().copied().unwrap_or_default());
|
||||
|
||||
exercise_payload(data, algorithm);
|
||||
|
||||
for prefix_len in interesting_prefix_lengths(data.len()) {
|
||||
exercise_payload(&data[..prefix_len], algorithm);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rustfs_ecstore::bucket::utils::{check_object_name_for_length_and_slash, has_bad_path_component, is_valid_object_prefix};
|
||||
use rustfs_utils::path::{clean, path_join};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const ROOT: &str = "/srv/rustfs";
|
||||
|
||||
fn parse_case(data: &[u8]) -> (String, String, String, Vec<String>) {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
let mut parts = text.splitn(4, '\n');
|
||||
let bucket = parts.next().unwrap_or_default().to_string();
|
||||
let object = parts.next().unwrap_or_default().to_string();
|
||||
let extra = parts.next().unwrap_or_default().to_string();
|
||||
let flags = parts
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|flag| !flag.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
|
||||
(bucket, object, extra, flags)
|
||||
}
|
||||
|
||||
fn stays_within_root(root: &Path, candidate: &Path) -> bool {
|
||||
candidate == root || candidate.starts_with(root)
|
||||
}
|
||||
|
||||
fn apply_flag(mut object: String, extra: &str, flag: &str) -> String {
|
||||
let extra_segment = if extra.is_empty() { "tail" } else { extra };
|
||||
|
||||
match flag {
|
||||
"leading_ws" => format!(" {object}"),
|
||||
"trailing_ws" => {
|
||||
object.push(' ');
|
||||
object
|
||||
}
|
||||
"leading_slash" => format!("/{object}"),
|
||||
"trailing_slash" => format!("{object}/"),
|
||||
"double_sep" => {
|
||||
if object.contains('/') {
|
||||
object.replacen('/', "//", 1)
|
||||
} else {
|
||||
format!("{object}//{extra_segment}")
|
||||
}
|
||||
}
|
||||
"backslash" => object.replace('/', "\\"),
|
||||
"append_extra" => format!("{object}/{extra_segment}"),
|
||||
"empty_segment" => {
|
||||
if let Some((left, right)) = object.split_once('/') {
|
||||
format!("{left}//{right}")
|
||||
} else {
|
||||
format!("{object}//{extra_segment}")
|
||||
}
|
||||
}
|
||||
"parent_segment" => format!("{object}/../{extra_segment}"),
|
||||
"current_segment" => format!("{object}/./{extra_segment}"),
|
||||
_ => object,
|
||||
}
|
||||
}
|
||||
|
||||
fn materialize_object(base: String, extra: &str, flags: &[String]) -> String {
|
||||
flags.iter().fold(base, |object, flag| apply_flag(object, extra, flag))
|
||||
}
|
||||
|
||||
fn has_dot_segments(path: &str) -> bool {
|
||||
path.split(['/', '\\']).any(|segment| {
|
||||
let trimmed = segment.trim();
|
||||
trimmed == "." || trimmed == ".."
|
||||
})
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (bucket, base_object, extra, flags) = parse_case(data);
|
||||
let object = materialize_object(base_object, &extra, &flags);
|
||||
|
||||
let has_bad_component = has_bad_path_component(&object);
|
||||
let is_valid_prefix = is_valid_object_prefix(&object);
|
||||
let length_and_slash_ok = check_object_name_for_length_and_slash(&bucket, &object).is_ok();
|
||||
|
||||
if object.is_empty() || !(is_valid_prefix && length_and_slash_ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert!(
|
||||
!has_bad_component,
|
||||
"accepted object unexpectedly retained a bad path component: {:?}",
|
||||
object
|
||||
);
|
||||
assert!(
|
||||
!has_dot_segments(&object),
|
||||
"accepted object unexpectedly retained dot segments: {:?}",
|
||||
object
|
||||
);
|
||||
|
||||
let root = PathBuf::from(ROOT);
|
||||
let joined = path_join(&[root.clone(), PathBuf::from(&object)]);
|
||||
let cleaned = PathBuf::from(clean(joined.to_string_lossy().as_ref()));
|
||||
let cleaned_text = cleaned.to_string_lossy();
|
||||
|
||||
assert!(
|
||||
stays_within_root(&root, &cleaned),
|
||||
"accepted object escaped root containment: object={:?} cleaned={:?}",
|
||||
object,
|
||||
cleaned
|
||||
);
|
||||
assert!(
|
||||
!has_dot_segments(cleaned_text.as_ref()),
|
||||
"cleaned accepted object unexpectedly retained dot segments: object={:?} cleaned={:?}",
|
||||
object,
|
||||
cleaned
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rustfs_policy::policy::{BucketPolicy, Policy, PolicyDoc};
|
||||
use rustfs_security_governance::{SerdePolicy, SerdePolicyKind, UnknownFieldPolicy, validate_serde_policies};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
fn add_unknown_top_level_field(data: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut value: Value = serde_json::from_slice(data).ok()?;
|
||||
let object = value.as_object_mut()?;
|
||||
object.insert("UnexpectedField".to_string(), json!(true));
|
||||
serde_json::to_vec(&value).ok()
|
||||
}
|
||||
|
||||
fn looks_like_policy_doc(value: &Value) -> bool {
|
||||
value
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("Policy") || object.contains_key("policy"))
|
||||
}
|
||||
|
||||
fn exercise_governance_contracts() {
|
||||
let ok = [
|
||||
SerdePolicy::new("BucketPolicy", SerdePolicyKind::StrictIngress, UnknownFieldPolicy::Deny),
|
||||
SerdePolicy::new("PolicyDoc", SerdePolicyKind::TolerantCompat, UnknownFieldPolicy::Warn),
|
||||
];
|
||||
assert!(validate_serde_policies(&ok).is_ok());
|
||||
|
||||
let bad = [SerdePolicy::new(
|
||||
"BucketPolicy",
|
||||
SerdePolicyKind::StrictIngress,
|
||||
UnknownFieldPolicy::Warn,
|
||||
)];
|
||||
assert!(validate_serde_policies(&bad).is_err());
|
||||
}
|
||||
|
||||
fn exercise_policy_doc(raw: &[u8]) {
|
||||
let _ = serde_json::from_slice::<PolicyDoc>(raw);
|
||||
let _ = serde_json::from_slice::<Policy>(raw);
|
||||
let parsed = PolicyDoc::try_from(raw.to_vec());
|
||||
|
||||
if parsed.is_ok()
|
||||
&& let Ok(value) = serde_json::from_slice::<Value>(raw)
|
||||
&& looks_like_policy_doc(&value)
|
||||
{
|
||||
let mutated = add_unknown_top_level_field(raw).expect("policy doc mutation should stay serializable");
|
||||
assert!(
|
||||
PolicyDoc::try_from(mutated).is_ok(),
|
||||
"policy doc ingress should tolerate unknown top-level fields for compat JSON"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
exercise_governance_contracts();
|
||||
|
||||
let bucket_policy = serde_json::from_slice::<BucketPolicy>(data);
|
||||
if bucket_policy.is_ok() {
|
||||
if let Some(mutated) = add_unknown_top_level_field(data) {
|
||||
assert!(
|
||||
serde_json::from_slice::<BucketPolicy>(&mutated).is_err(),
|
||||
"bucket policy strict ingress should reject unknown top-level fields"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
exercise_policy_doc(data);
|
||||
|
||||
if let Ok(value) = serde_json::from_slice::<Value>(data)
|
||||
&& let Some(object) = value.as_object()
|
||||
{
|
||||
let mut legacy_doc = Map::new();
|
||||
if let Some(policy) = object.get("Policy").or_else(|| object.get("policy")) {
|
||||
legacy_doc.insert("version".to_string(), json!(1));
|
||||
legacy_doc.insert("policy".to_string(), policy.clone());
|
||||
legacy_doc.insert("create_date".to_string(), json!("2025-03-07T12:00:00Z"));
|
||||
legacy_doc.insert("update_date".to_string(), json!("2025-03-07T12:00:00Z"));
|
||||
let legacy_doc = serde_json::to_vec(&Value::Object(legacy_doc)).expect("legacy policy doc should serialize");
|
||||
assert!(
|
||||
PolicyDoc::try_from(legacy_doc).is_ok(),
|
||||
"legacy policy doc aliases should remain parseable"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user