From 7c7301e5a58c07cf7a055a41cc5de186915b6bd7 Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:01:11 +0300 Subject: [PATCH 01/12] feat(error): add typed variants for parse and input limits Introduce dedicated error variants for payload size, recursion depth, file size, symlink rejection, non-regular files, invalid param identifiers, and body size. Add corresponding safety constants in lib.rs. --- src/error.rs | 21 +++++++++++++++++++++ src/lib.rs | 15 +++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/error.rs b/src/error.rs index fb8c3ee..5b4cfa0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,6 +3,12 @@ pub enum Error { #[error("TNetString parse error at byte {offset}: {message}")] TNetParse { offset: usize, message: String }, + #[error("TNetString payload too large: {len} bytes exceeds limit of {max} bytes")] + TNetStringPayloadTooLarge { len: usize, max: usize }, + + #[error("TNetString recursion depth exceeded: depth {depth} exceeds limit of {max}")] + TNetStringDepthExceeded { depth: usize, max: usize }, + #[error("Invalid flow state: {0}")] FlowState(String), @@ -20,6 +26,21 @@ pub enum Error { #[error("Schema error: {0}")] Schema(String), + + #[error("Input too large: {size} bytes exceeds limit of {max} bytes")] + InputTooLarge { size: u64, max: u64 }, + + #[error("Symlink rejected: {}", path.display())] + SymlinkRejected { path: std::path::PathBuf }, + + #[error("Not a regular file: {}", path.display())] + NotRegularFile { path: std::path::PathBuf }, + + #[error("Invalid path parameter identifier: {name:?}")] + InvalidParamIdent { name: String }, + + #[error("Body too large: {size} bytes exceeds limit of {max} bytes")] + BodyTooLarge { size: usize, max: usize }, } pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index cd4783d..056696a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,3 +27,18 @@ pub mod path_matching; pub mod schema; pub mod tnetstring; pub mod types; + +/// Maximum size of a single input file (2 GiB). +pub const MAX_INPUT_SIZE: u64 = 2 * 1024 * 1024 * 1024; + +/// Maximum size of a single TNetString payload (256 MiB). +pub const MAX_PAYLOAD_SIZE: usize = 256 * 1024 * 1024; + +/// Maximum recursion depth for TNetString parsing. +pub const MAX_DEPTH: usize = 256; + +/// Maximum recursion depth for JSON-to-schema conversion. +pub const MAX_SCHEMA_DEPTH: usize = 64; + +/// Maximum body size for response/request bodies (64 MiB). +pub const MAX_BODY_SIZE: usize = 64 * 1024 * 1024; From 34c01cf6db8cf793ab30ca91cd05f43138413270 Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:23:21 +0300 Subject: [PATCH 02/12] feat(tnetstring): cap payload size at 256 MiB --- src/tnetstring.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/tnetstring.rs b/src/tnetstring.rs index 827ba19..22cbedf 100644 --- a/src/tnetstring.rs +++ b/src/tnetstring.rs @@ -199,11 +199,25 @@ fn parse_length(reader: &mut TrackingReader) -> Result } fn parse_value(reader: &mut TrackingReader) -> Result, Error> { + parse_value_capped(reader, crate::MAX_PAYLOAD_SIZE) +} + +fn parse_value_capped( + reader: &mut TrackingReader, + max_payload: usize, +) -> Result, Error> { let len = match parse_length(reader)? { Some(l) => l, None => return Ok(None), }; + if len > max_payload { + return Err(Error::TNetStringPayloadTooLarge { + len, + max: max_payload, + }); + } + let mut data = vec![0u8; len]; if len > 0 { reader.read_exact(&mut data)?; @@ -699,6 +713,19 @@ mod tests { ); } + #[test] + fn payload_size_cap() { + // A tnetstring claiming a huge payload should be rejected before allocating + let input = b"999999999999:X,"; + let mut cursor = std::io::Cursor::new(input.as_slice()); + let results = parse_all_lenient(&mut cursor); + let first = results.into_iter().next().expect("should yield one result"); + assert!( + matches!(first, Err(Error::TNetStringPayloadTooLarge { .. })), + "expected TNetStringPayloadTooLarge, got {first:?}" + ); + } + // ── Proptest ───────────────────────────────────────────────────── mod proptests { From 38412520d5d3e710fb9c4ee80a0f034ea32e7adf Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:24:37 +0300 Subject: [PATCH 03/12] feat(tnetstring): enforce 256-level recursion depth limit --- src/tnetstring.rs | 84 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 8 deletions(-) diff --git a/src/tnetstring.rs b/src/tnetstring.rs index 22cbedf..177d9f8 100644 --- a/src/tnetstring.rs +++ b/src/tnetstring.rs @@ -199,12 +199,14 @@ fn parse_length(reader: &mut TrackingReader) -> Result } fn parse_value(reader: &mut TrackingReader) -> Result, Error> { - parse_value_capped(reader, crate::MAX_PAYLOAD_SIZE) + parse_value_with_depth(reader, 0, crate::MAX_PAYLOAD_SIZE, crate::MAX_DEPTH) } -fn parse_value_capped( +fn parse_value_with_depth( reader: &mut TrackingReader, + depth: usize, max_payload: usize, + max_depth: usize, ) -> Result, Error> { let len = match parse_length(reader)? { Some(l) => l, @@ -270,11 +272,23 @@ fn parse_value_capped( TNetValue::Null } b']' => { - let items = parse_nested_list(&data, reader.offset - len - 1)?; + let items = parse_nested_list( + &data, + reader.offset - len - 1, + depth, + max_payload, + max_depth, + )?; TNetValue::List(items) } b'}' => { - let pairs = parse_nested_dict(&data, reader.offset - len - 1)?; + let pairs = parse_nested_dict( + &data, + reader.offset - len - 1, + depth, + max_payload, + max_depth, + )?; TNetValue::Dict(pairs) } _ => { @@ -285,14 +299,28 @@ fn parse_value_capped( Ok(Some(value)) } -fn parse_nested_list(data: &[u8], base_offset: usize) -> Result, Error> { +fn parse_nested_list( + data: &[u8], + base_offset: usize, + parent_depth: usize, + max_payload: usize, + max_depth: usize, +) -> Result, Error> { + let child_depth = parent_depth + 1; + if child_depth > max_depth { + return Err(Error::TNetStringDepthExceeded { + depth: child_depth, + max: max_depth, + }); + } let mut items = Vec::new(); let mut cursor = std::io::Cursor::new(data); let mut reader = TrackingReader { inner: &mut cursor, offset: base_offset, }; - while let Some(val) = parse_value(&mut reader)? { + while let Some(val) = parse_value_with_depth(&mut reader, child_depth, max_payload, max_depth)? + { items.push(val); } Ok(items) @@ -301,15 +329,26 @@ fn parse_nested_list(data: &[u8], base_offset: usize) -> Result, fn parse_nested_dict( data: &[u8], base_offset: usize, + parent_depth: usize, + max_payload: usize, + max_depth: usize, ) -> Result, Error> { + let child_depth = parent_depth + 1; + if child_depth > max_depth { + return Err(Error::TNetStringDepthExceeded { + depth: child_depth, + max: max_depth, + }); + } let mut pairs = Vec::new(); let mut cursor = std::io::Cursor::new(data); let mut reader = TrackingReader { inner: &mut cursor, offset: base_offset, }; - while let Some(key) = parse_value(&mut reader)? { - let value = parse_value(&mut reader)? + while let Some(key) = parse_value_with_depth(&mut reader, child_depth, max_payload, max_depth)? + { + let value = parse_value_with_depth(&mut reader, child_depth, max_payload, max_depth)? .ok_or_else(|| reader.make_error("unexpected EOF: dict key without value".into()))?; pairs.push((key, value)); } @@ -713,6 +752,35 @@ mod tests { ); } + #[test] + fn depth_cap() { + // Build 300 nested lists: "N:[N:[...0:~...]...]" + let depth = 300usize; + let mut encoded = Vec::new(); + // Inner-most: null value "0:~" + let mut inner = b"0:~".to_vec(); + for _ in 0..depth { + // Wrap in list: ":]" + let wrapped = format!("{}:", inner.len()).into_bytes(); + let mut next = Vec::with_capacity(wrapped.len() + inner.len() + 1); + next.extend_from_slice(&wrapped); + next.extend_from_slice(&inner); + next.push(b']'); + inner = next; + } + encoded.extend_from_slice(&inner); + + let mut cursor = std::io::Cursor::new(encoded); + let results = parse_all_lenient(&mut cursor); + let has_depth_error = results + .into_iter() + .any(|r| matches!(r, Err(Error::TNetStringDepthExceeded { .. }))); + assert!( + has_depth_error, + "expected TNetStringDepthExceeded for 300-level nesting" + ); + } + #[test] fn payload_size_cap() { // A tnetstring claiming a huge payload should be rejected before allocating From e157940c36c4b5b064ab507e524ec55523ad2021 Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:26:12 +0300 Subject: [PATCH 04/12] feat(schema): enforce 64-level JSON recursion depth limit --- src/schema.rs | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/schema.rs b/src/schema.rs index 5ef4593..a52e39c 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -35,6 +35,16 @@ fn is_uuid(s: &str) -> bool { /// /// Matches Python mitmproxy2swagger `value_to_schema` behavior exactly. pub fn value_to_schema(value: &serde_json::Value) -> Schema { + value_to_schema_depth(value, 0) +} + +fn value_to_schema_depth(value: &serde_json::Value, depth: usize) -> Schema { + if depth >= crate::MAX_SCHEMA_DEPTH { + return Schema { + schema_data: SchemaData::default(), + schema_kind: SchemaKind::Any(AnySchema::default()), + }; + } match value { serde_json::Value::Null => Schema { schema_data: SchemaData { @@ -75,7 +85,10 @@ pub fn value_to_schema(value: &serde_json::Value) -> Schema { schema_kind: SchemaKind::Any(AnySchema::default()), }))) } else { - Some(ReferenceOr::Item(Box::new(value_to_schema(&arr[0])))) + Some(ReferenceOr::Item(Box::new(value_to_schema_depth( + &arr[0], + depth + 1, + )))) }; Schema { schema_data: SchemaData::default(), @@ -100,7 +113,7 @@ pub fn value_to_schema(value: &serde_json::Value) -> Schema { schema_data: SchemaData::default(), schema_kind: SchemaKind::Type(Type::Object(ObjectType { additional_properties: Some(AdditionalProperties::Schema(Box::new( - ReferenceOr::Item(value_to_schema(first_value)), + ReferenceOr::Item(value_to_schema_depth(first_value, depth + 1)), ))), ..ObjectType::default() })), @@ -111,7 +124,7 @@ pub fn value_to_schema(value: &serde_json::Value) -> Schema { .map(|(key, val)| { ( key.clone(), - ReferenceOr::Item(Box::new(value_to_schema(val))), + ReferenceOr::Item(Box::new(value_to_schema_depth(val, depth + 1))), ) }) .collect(); @@ -458,6 +471,33 @@ mod tests { assert!(is_uuid("ABCDEF01-2345-6789-abcd-ef0123456789")); } + #[test] + fn deeply_nested_json_caps_at_any_schema() { + let mut val = json!(null); + for _ in 0..80 { + val = json!({ "nested": val }); + } + let schema = value_to_schema(&val); + + fn find_any(s: &Schema, depth: usize) -> Option { + if matches!(s.schema_kind, SchemaKind::Any(_)) { + return Some(depth); + } + if let SchemaKind::Type(Type::Object(obj)) = &s.schema_kind { + for prop in obj.properties.values() { + if let ReferenceOr::Item(inner) = prop { + if let Some(d) = find_any(inner, depth + 1) { + return Some(d); + } + } + } + } + None + } + let any_depth = find_any(&schema, 0).expect("should have AnySchema at depth limit"); + assert_eq!(any_depth, crate::MAX_SCHEMA_DEPTH); + } + #[test] fn is_uuid_invalid() { assert!(!is_uuid("")); From 97864dba08dc95ae21107d04cb23345ae2be7006 Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:27:23 +0300 Subject: [PATCH 05/12] perf(builder): replace custom glob matcher with globset --- Cargo.toml | 1 + src/builder.rs | 48 +++++++----------------------------------------- 2 files changed, 8 insertions(+), 41 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2d480e8..2afe047 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ tracing = "0.1" tracing-subscriber = "0.3" uuid = { version = "1", features = ["v4"] } rmp-serde = "1" +globset = "0.4" [dev-dependencies] proptest = "1" diff --git a/src/builder.rs b/src/builder.rs index 309a3e6..7edd896 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -10,48 +10,14 @@ use crate::path_matching; use crate::schema; use crate::types::{CapturedRequest, Config}; -/// Match a path against a simple glob: `*` matches any chars except `/`, -/// `**` matches any chars including `/`. Used by `--exclude-patterns` to -/// drop static assets (e.g. "/static/**", "*.css") out of discovery. pub fn glob_match(pattern: &str, path: &str) -> bool { - let p_bytes = pattern.as_bytes(); - let s_bytes = path.as_bytes(); - glob_match_impl(p_bytes, 0, s_bytes, 0) -} - -fn glob_match_impl(pat: &[u8], mut pi: usize, s: &[u8], mut si: usize) -> bool { - while pi < pat.len() { - match pat[pi] { - b'*' => { - let double = pi + 1 < pat.len() && pat[pi + 1] == b'*'; - let skip = if double { 2 } else { 1 }; - if pi + skip >= pat.len() { - return if double { - true - } else { - !s[si..].contains(&b'/') - }; - } - for k in si..=s.len() { - if !double && s[si..k].contains(&b'/') { - break; - } - if glob_match_impl(pat, pi + skip, s, k) { - return true; - } - } - return false; - } - c => { - if si >= s.len() || s[si] != c { - return false; - } - pi += 1; - si += 1; - } - } - } - si == s.len() + let Ok(glob) = globset::GlobBuilder::new(pattern) + .literal_separator(true) + .build() + else { + return false; + }; + glob.compile_matcher().is_match(path) } /// Discover unique API paths from captured requests and generate templates. From 428125a45a4ac8c9a405cd5560d85c297c6afec0 Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:28:16 +0300 Subject: [PATCH 06/12] feat(reader): reject symlinks, non-regular files, and oversized inputs --- src/har_reader.rs | 1 + src/lib.rs | 27 +++++++++++++++++++++++++++ src/mitmproxy_reader.rs | 1 + 3 files changed, 29 insertions(+) diff --git a/src/har_reader.rs b/src/har_reader.rs index 40c7a6c..aca57a9 100644 --- a/src/har_reader.rs +++ b/src/har_reader.rs @@ -156,6 +156,7 @@ pub fn read_har_file(path: &Path) -> Result>> { } Ok(all) } else { + crate::validate_input_path(path, crate::MAX_INPUT_SIZE, false)?; let bytes = std::fs::read(path)?; debug!(path = %path.display(), "Parsing HAR file"); parse_har_bytes(&bytes) diff --git a/src/lib.rs b/src/lib.rs index 056696a..0b64c5a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,3 +42,30 @@ pub const MAX_SCHEMA_DEPTH: usize = 64; /// Maximum body size for response/request bodies (64 MiB). pub const MAX_BODY_SIZE: usize = 64 * 1024 * 1024; + +/// Validate that an input path is a regular file (not a symlink, FIFO, etc.) +/// and within the configured size limit. +pub fn validate_input_path( + path: &std::path::Path, + max_size: u64, + allow_symlinks: bool, +) -> Result<(), error::Error> { + if !allow_symlinks && path.symlink_metadata()?.file_type().is_symlink() { + return Err(error::Error::SymlinkRejected { + path: path.to_path_buf(), + }); + } + let meta = std::fs::metadata(path)?; + if !meta.is_file() { + return Err(error::Error::NotRegularFile { + path: path.to_path_buf(), + }); + } + if meta.len() > max_size { + return Err(error::Error::InputTooLarge { + size: meta.len(), + max: max_size, + }); + } + Ok(()) +} diff --git a/src/mitmproxy_reader.rs b/src/mitmproxy_reader.rs index 32e7a64..6c9e543 100644 --- a/src/mitmproxy_reader.rs +++ b/src/mitmproxy_reader.rs @@ -213,6 +213,7 @@ fn parse_flow(flow: &TNetValue) -> Result { } pub fn read_mitmproxy_file(path: &Path) -> Result>> { + crate::validate_input_path(path, crate::MAX_INPUT_SIZE, false)?; let data = std::fs::read(path)?; let mut cursor = std::io::Cursor::new(data); let values = tnetstring::parse_all_lenient(&mut cursor); From 4814171ed7417af5fe30eaf1666fdf2c4b25ac89 Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:30:04 +0300 Subject: [PATCH 07/12] feat(cli): expose --max-input-size, --max-payload-size, --max-depth, --max-body-size, --allow-symlinks --- src/cli.rs | 38 ++++++++++++++++++++++++++++++++++++++ src/har_reader.rs | 1 - src/main.rs | 25 ++++++++++++++++++++++--- src/mitmproxy_reader.rs | 1 - 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 330e3a2..7fc2f95 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,6 +30,23 @@ pub enum InputFormat { Mitmproxy, } +fn parse_byte_size(s: &str) -> Result { + let s = s.trim(); + let (num_str, multiplier) = if let Some(n) = s.strip_suffix("GiB") { + (n.trim(), 1024 * 1024 * 1024u64) + } else if let Some(n) = s.strip_suffix("MiB") { + (n.trim(), 1024 * 1024u64) + } else if let Some(n) = s.strip_suffix("KiB") { + (n.trim(), 1024u64) + } else { + (s, 1u64) + }; + num_str + .parse::() + .map(|n| n * multiplier) + .map_err(|e| format!("invalid size: {e}")) +} + #[derive(Parser, Debug)] pub struct DiscoverArgs { /// Input file or directory path @@ -59,6 +76,12 @@ pub struct DiscoverArgs { /// `ignore:` so you can review it. Saves a manual sed step. #[arg(long, value_delimiter = ',')] pub include_patterns: Vec, + + #[arg(long, value_parser = parse_byte_size, default_value = "2GiB")] + pub max_input_size: u64, + + #[arg(long, default_value_t = false)] + pub allow_symlinks: bool, } #[derive(Parser, Debug)] @@ -118,4 +141,19 @@ pub struct GenerateArgs { /// JSON string for tag overrides #[arg(long)] pub tags_overrides: Option, + + #[arg(long, value_parser = parse_byte_size, default_value = "2GiB")] + pub max_input_size: u64, + + #[arg(long, value_parser = parse_byte_size, default_value = "256MiB")] + pub max_payload_size: u64, + + #[arg(long, default_value_t = 256)] + pub max_depth: usize, + + #[arg(long, value_parser = parse_byte_size, default_value = "64MiB")] + pub max_body_size: u64, + + #[arg(long, default_value_t = false)] + pub allow_symlinks: bool, } diff --git a/src/har_reader.rs b/src/har_reader.rs index aca57a9..40c7a6c 100644 --- a/src/har_reader.rs +++ b/src/har_reader.rs @@ -156,7 +156,6 @@ pub fn read_har_file(path: &Path) -> Result>> { } Ok(all) } else { - crate::validate_input_path(path, crate::MAX_INPUT_SIZE, false)?; let bytes = std::fs::read(path)?; debug!(path = %path.display(), "Parsing HAR file"); parse_har_bytes(&bytes) diff --git a/src/main.rs b/src/main.rs index 8f335d8..70fbd40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,12 @@ fn main() -> Result<()> { Command::Discover(args) => { info!(input = %args.input.display(), output = %args.output.display(), "Starting discovery"); - let requests = read_input(&args.input, &args.format)?; + let requests = read_input( + &args.input, + &args.format, + args.max_input_size, + args.allow_symlinks, + )?; info!(count = requests.len(), path = %args.input.display(), "Read requests"); let templates = builder::discover_paths( @@ -61,7 +66,12 @@ fn main() -> Result<()> { Command::Generate(args) => { info!(input = %args.input.display(), output = %args.output.display(), "Starting generation"); - let requests = read_input(&args.input, &args.format)?; + let requests = read_input( + &args.input, + &args.format, + args.max_input_size, + args.allow_symlinks, + )?; info!(count = requests.len(), path = %args.input.display(), "Read requests"); let all_templates = load_templates(&args.templates).with_context(|| { @@ -142,7 +152,16 @@ fn detect_format_score(path: &Path) -> (u8, u8) { (mitmproxy_score, har_score) } -fn read_input(path: &Path, format: &InputFormat) -> Result>> { +fn read_input( + path: &Path, + format: &InputFormat, + max_input_size: u64, + allow_symlinks: bool, +) -> Result>> { + if !path.is_dir() { + mitm2openapi::validate_input_path(path, max_input_size, allow_symlinks) + .context("input file validation failed")?; + } match format { InputFormat::Mitmproxy => { debug!(path = %path.display(), "Reading as mitmproxy format"); diff --git a/src/mitmproxy_reader.rs b/src/mitmproxy_reader.rs index 6c9e543..32e7a64 100644 --- a/src/mitmproxy_reader.rs +++ b/src/mitmproxy_reader.rs @@ -213,7 +213,6 @@ fn parse_flow(flow: &TNetValue) -> Result { } pub fn read_mitmproxy_file(path: &Path) -> Result>> { - crate::validate_input_path(path, crate::MAX_INPUT_SIZE, false)?; let data = std::fs::read(path)?; let mut cursor = std::io::Cursor::new(data); let values = tnetstring::parse_all_lenient(&mut cursor); From cfa13972cd57693129a285e666879313e9ffe12c Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:30:37 +0300 Subject: [PATCH 08/12] perf(har): bound format-detection read to 4 KiB --- src/har_reader.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/har_reader.rs b/src/har_reader.rs index 40c7a6c..d3ab6a6 100644 --- a/src/har_reader.rs +++ b/src/har_reader.rs @@ -166,10 +166,17 @@ pub fn har_heuristic(path: &Path) -> bool { if path.is_dir() { return false; } - let Ok(bytes) = std::fs::read(path) else { + let Ok(file) = std::fs::File::open(path) else { return false; }; - let clean = strip_bom(&bytes); + use std::io::Read; + let mut buf = [0u8; 4096]; + let mut reader = std::io::BufReader::new(file); + let n = match reader.read(&mut buf) { + Ok(n) => n, + Err(_) => return false, + }; + let clean = strip_bom(&buf[..n]); clean .iter() .find(|b| !b.is_ascii_whitespace()) From a57e3d658156b3305984e00163dccab6c0f8c729 Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:31:17 +0300 Subject: [PATCH 09/12] feat(path_matching): validate path parameter identifiers --- src/path_matching.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/path_matching.rs b/src/path_matching.rs index 4deca72..19c347f 100644 --- a/src/path_matching.rs +++ b/src/path_matching.rs @@ -8,6 +8,12 @@ use std::collections::HashSet; /// /// Escapes special regex chars in literal parts, then inserts named capture groups /// for `{param}` placeholders. The resulting regex is anchored with `^` and `$`. +fn is_valid_param_ident(name: &str) -> bool { + !name.is_empty() + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && !name.starts_with(|c: char| c.is_ascii_digit()) +} + pub fn path_to_regex(template: &str) -> Result { let mut pattern = String::from("^"); let mut remaining = template; @@ -18,6 +24,11 @@ pub fn path_to_regex(template: &str) -> Result { if let Some(close) = remaining.find('}') { let param_name = &remaining[..close]; + if !is_valid_param_ident(param_name) { + return Err(crate::error::Error::InvalidParamIdent { + name: param_name.to_string(), + }); + } pattern.push_str(&format!("(?P<{}>[^/]+)", param_name)); remaining = &remaining[close + 1..]; } else { @@ -333,6 +344,24 @@ mod tests { // ── path_to_regex edge cases ─────────────────────────────────── + #[test] + fn invalid_param_ident_rejected() { + let err = path_to_regex("/users/{foo bar}"); + assert!( + matches!(err, Err(crate::error::Error::InvalidParamIdent { .. })), + "expected InvalidParamIdent, got {err:?}" + ); + } + + #[test] + fn digit_leading_param_rejected() { + let err = path_to_regex("/users/{1abc}"); + assert!(matches!( + err, + Err(crate::error::Error::InvalidParamIdent { .. }) + )); + } + #[test] fn root_path() { let re = path_to_regex("/").unwrap(); From 476e25c2584b413b24bcf1a2dee5d27392762cf9 Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:32:34 +0300 Subject: [PATCH 10/12] test(security): cover symlink, FIFO, and oversize input rejection --- tests/security.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/security.rs diff --git a/tests/security.rs b/tests/security.rs new file mode 100644 index 0000000..8641a04 --- /dev/null +++ b/tests/security.rs @@ -0,0 +1,77 @@ +use std::io::Write; +use std::os::unix::fs as unix_fs; +use tempfile::TempDir; + +#[test] +fn symlink_rejected_by_default() { + let dir = TempDir::new().unwrap(); + let real = dir.path().join("real.flow"); + std::fs::write(&real, b"1:X,").unwrap(); + let link = dir.path().join("link.flow"); + unix_fs::symlink(&real, &link).unwrap(); + + let err = mitm2openapi::validate_input_path(&link, mitm2openapi::MAX_INPUT_SIZE, false); + assert!( + matches!(err, Err(mitm2openapi::error::Error::SymlinkRejected { .. })), + "expected SymlinkRejected, got {err:?}" + ); +} + +#[test] +fn symlink_allowed_when_opted_in() { + let dir = TempDir::new().unwrap(); + let real = dir.path().join("real.flow"); + std::fs::write(&real, b"1:X,").unwrap(); + let link = dir.path().join("link.flow"); + unix_fs::symlink(&real, &link).unwrap(); + + let result = mitm2openapi::validate_input_path(&link, mitm2openapi::MAX_INPUT_SIZE, true); + assert!( + result.is_ok(), + "should allow symlinks when opted in: {result:?}" + ); +} + +#[test] +fn fifo_rejected() { + let dir = TempDir::new().unwrap(); + let fifo_path = dir.path().join("input.fifo"); + + let status = std::process::Command::new("mkfifo") + .arg(&fifo_path) + .status() + .expect("mkfifo command failed"); + assert!(status.success(), "mkfifo should succeed"); + + let err = mitm2openapi::validate_input_path(&fifo_path, mitm2openapi::MAX_INPUT_SIZE, false); + assert!( + matches!(err, Err(mitm2openapi::error::Error::NotRegularFile { .. })), + "expected NotRegularFile for FIFO, got {err:?}" + ); +} + +#[test] +fn oversize_input_rejected() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("big.flow"); + { + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(&[0u8; 1024]).unwrap(); + } + + let err = mitm2openapi::validate_input_path(&path, 512, false); + assert!( + matches!(err, Err(mitm2openapi::error::Error::InputTooLarge { .. })), + "expected InputTooLarge, got {err:?}" + ); +} + +#[test] +fn normal_file_passes_validation() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("ok.flow"); + std::fs::write(&path, b"1:X,").unwrap(); + + let result = mitm2openapi::validate_input_path(&path, mitm2openapi::MAX_INPUT_SIZE, false); + assert!(result.is_ok(), "normal file should pass: {result:?}"); +} From a7423d2a0f663291610acf2b587e34e13c24ded4 Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:34:12 +0300 Subject: [PATCH 11/12] chore: update Cargo.lock for globset dependency --- Cargo.lock | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 344de38..e97a16e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -357,6 +357,19 @@ dependencies = [ "wasip3", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "har" version = "0.9.0" @@ -527,6 +540,7 @@ dependencies = [ "assert_cmd", "base64", "clap", + "globset", "har", "indexmap 2.14.0", "openapiv3", From de268f9d5f29df279d74ddfd317ca5e5ec4c1cab Mon Sep 17 00:00:00 2001 From: arkptz Date: Wed, 22 Apr 2026 21:56:25 +0300 Subject: [PATCH 12/12] fix(test): gate symlink and FIFO tests behind cfg(unix) std::os::unix::fs::symlink and the mkfifo command are POSIX-only. Guard the three tests that depend on them with #[cfg(unix)] so Windows CI runs the two cross-platform tests (oversize rejection and normal file acceptance) and skips the rest. --- tests/security.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/security.rs b/tests/security.rs index 8641a04..1d9248c 100644 --- a/tests/security.rs +++ b/tests/security.rs @@ -1,9 +1,10 @@ use std::io::Write; -use std::os::unix::fs as unix_fs; use tempfile::TempDir; +#[cfg(unix)] #[test] fn symlink_rejected_by_default() { + use std::os::unix::fs as unix_fs; let dir = TempDir::new().unwrap(); let real = dir.path().join("real.flow"); std::fs::write(&real, b"1:X,").unwrap(); @@ -17,8 +18,10 @@ fn symlink_rejected_by_default() { ); } +#[cfg(unix)] #[test] fn symlink_allowed_when_opted_in() { + use std::os::unix::fs as unix_fs; let dir = TempDir::new().unwrap(); let real = dir.path().join("real.flow"); std::fs::write(&real, b"1:X,").unwrap(); @@ -32,6 +35,7 @@ fn symlink_allowed_when_opted_in() { ); } +#[cfg(unix)] #[test] fn fifo_rejected() { let dir = TempDir::new().unwrap();