test(fuzz): arbitrary bytes into the HTTP door and the upload path (P12)
P2 promised these two targets and never added them. `fuzz_http_mcp_request` feeds a method, a path, headers (token present, absent, wrong, raw) and a JSON-RPC body drawn from the real method and tool names into the in-process router. Whatever arrives, the door answers: no token live for the book is refused 4xx, never run (INVARIANT 8), and a live token never makes the door fail (no 5xx on the MCP route). Whether a token is live is asked of the door itself (`FuzzDoor::carries_a_token`), so a token smuggled in an extra header is judged by the real rule rather than by the target's guess. `fuzz_upload_put` declares a size and a hash and sends bytes: taken only when both are exactly what was declared, from the token the grant names, and only once (INVARIANT 9); a 200 for anything else is the bug. Both join the weekly matrix in scheduled.yml; fuzz/Cargo.lock gains ledger-mcp's dependency closure because the targets drive its router.
This commit is contained in:
1 parent
5e474cd1ac
commit
7002e89efd
5 files changed
+1252
-2
No files matched your search
Generated
+934
-1
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,10 @@ path = "../crates/ledger-store"
|
||||
[dependencies.ledger-import]
|
||||
path = "../crates/ledger-import"
|
||||
|
||||
[dependencies.ledger-mcp]
|
||||
path = "../crates/ledger-mcp"
|
||||
features = ["fuzzing"]
|
||||
|
||||
[dependencies.ledger-config]
|
||||
path = "../crates/ledger-config"
|
||||
|
||||
@@ -105,3 +109,17 @@ path = "fuzz_targets/fuzz_settings_replay.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_http_mcp_request"
|
||||
path = "fuzz_targets/fuzz_http_mcp_request.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_upload_put"
|
||||
path = "fuzz_targets/fuzz_upload_put.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,169 @@
|
||||
// Fuzz target: the books server's HTTP door takes arbitrary requests.
|
||||
// Method, path, headers and body are all untrusted; the door is the very
|
||||
// router the server mounts (ledger-mcp local.rs `router`), in-process over a
|
||||
// real book, with no socket (`fuzz_door`). Whatever arrives: the door
|
||||
// answers (no panic, no hang), and a request that carries no token live
|
||||
// for the book is refused 4xx and never run (INVARIANT 8, local.rs); one
|
||||
// that carries it never makes the door fail (no 5xx on the MCP route).
|
||||
#![no_main]
|
||||
|
||||
use arbitrary::Arbitrary;
|
||||
use ledger_mcp::local::fuzz_door::FuzzDoor;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[derive(Arbitrary, Debug)]
|
||||
enum Auth {
|
||||
None,
|
||||
Valid,
|
||||
Other,
|
||||
/// `Authorization` with whatever text.
|
||||
Raw(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Arbitrary, Debug)]
|
||||
enum Body {
|
||||
Raw(Vec<u8>),
|
||||
/// A JSON-RPC message the server could understand.
|
||||
Rpc {
|
||||
method: u8,
|
||||
id: u8,
|
||||
params: String,
|
||||
},
|
||||
/// `tools/call` of a name picked from a few real ones, arguments as text.
|
||||
Call {
|
||||
tool: u8,
|
||||
arguments: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Arbitrary, Debug)]
|
||||
struct Request {
|
||||
method: Vec<u8>,
|
||||
verb: u8,
|
||||
route: u8,
|
||||
book: u8,
|
||||
suffix: String,
|
||||
auth: Auth,
|
||||
session: Option<String>,
|
||||
content_type: u8,
|
||||
accept: bool,
|
||||
extra: Vec<(Vec<u8>, Vec<u8>)>,
|
||||
body: Body,
|
||||
}
|
||||
|
||||
const METHODS: [&str; 6] = ["POST", "GET", "DELETE", "PUT", "OPTIONS", "PATCH"];
|
||||
const RPC: [&str; 8] = [
|
||||
"initialize",
|
||||
"notifications/initialized",
|
||||
"tools/list",
|
||||
"ping",
|
||||
"resources/list",
|
||||
"prompts/list",
|
||||
"tools/call",
|
||||
"logging/setLevel",
|
||||
];
|
||||
const TOOLS: [&str; 6] = [
|
||||
"books.start",
|
||||
"books.trial_balance",
|
||||
"books.draft_entry",
|
||||
"books.begin_upload",
|
||||
"books.settings",
|
||||
"books.handbook",
|
||||
];
|
||||
|
||||
thread_local! {
|
||||
/// The door, and how many requests it has had: a fresh book every so
|
||||
/// often, because sessions and the access log only grow.
|
||||
static DOOR: RefCell<Option<(FuzzDoor, u32)>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn clean(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || "-_.".contains(*c))
|
||||
.take(40)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fuzz_target!(|r: Request| {
|
||||
DOOR.with(|cell| {
|
||||
let mut cell = cell.borrow_mut();
|
||||
if cell.as_ref().is_none_or(|(d, n)| *n > 3000 || d.sessions() > 500) {
|
||||
*cell = Some((FuzzDoor::new(), 0));
|
||||
}
|
||||
let (door, n) = cell.as_mut().unwrap();
|
||||
*n += 1;
|
||||
|
||||
let method: Vec<u8> = if r.verb % 4 == 0 {
|
||||
r.method.clone()
|
||||
} else {
|
||||
METHODS[r.verb as usize % METHODS.len()].as_bytes().to_vec()
|
||||
};
|
||||
let book = match r.book % 4 {
|
||||
0 | 1 => "acme".to_string(),
|
||||
2 => "nobody".to_string(),
|
||||
_ => clean(&r.suffix),
|
||||
};
|
||||
let path = match r.route % 6 {
|
||||
0..=2 => format!("/b/{book}/mcp"),
|
||||
3 => format!("/b/{book}/u/{}", clean(&r.suffix)),
|
||||
4 => format!("/b/{book}/a/{}", clean(&r.suffix)),
|
||||
_ => format!("/b/{book}/x/{}", clean(&r.suffix)),
|
||||
};
|
||||
let mut headers: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
|
||||
match &r.auth {
|
||||
Auth::None => {}
|
||||
Auth::Valid => {
|
||||
headers.push((b"authorization".to_vec(), format!("Bearer {}", door.bearer()).into_bytes()));
|
||||
}
|
||||
Auth::Other => {
|
||||
headers.push((b"authorization".to_vec(), format!("Bearer {}x", door.other_bearer()).into_bytes()));
|
||||
}
|
||||
Auth::Raw(v) => {
|
||||
headers.push((b"authorization".to_vec(), v.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(s) = &r.session {
|
||||
headers.push((b"mcp-session-id".to_vec(), clean(s).into_bytes()));
|
||||
}
|
||||
headers.push((
|
||||
b"content-type".to_vec(),
|
||||
["application/json", "text/plain", "application/octet-stream", ""][r.content_type as usize % 4]
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
));
|
||||
if r.accept {
|
||||
headers.push((b"accept".to_vec(), b"application/json, text/event-stream".to_vec()));
|
||||
}
|
||||
headers.extend(r.extra.iter().take(4).cloned());
|
||||
// The door's own rule, asked directly: any header (the authorization
|
||||
// one, or a smuggled extra) can carry a token live for the book.
|
||||
let live = door.carries_a_token(&headers);
|
||||
let body = match &r.body {
|
||||
Body::Raw(b) => b.clone(),
|
||||
Body::Rpc { method, id, params } => {
|
||||
let params = serde_json::from_str::<serde_json::Value>(params).unwrap_or_default();
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": RPC[*method as usize % RPC.len()], "params": params})
|
||||
.to_string()
|
||||
.into_bytes()
|
||||
}
|
||||
Body::Call { tool, arguments } => {
|
||||
let args = serde_json::from_str::<serde_json::Value>(arguments).unwrap_or_default();
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": {"name": TOOLS[*tool as usize % TOOLS.len()], "arguments": args}})
|
||||
.to_string()
|
||||
.into_bytes()
|
||||
}
|
||||
};
|
||||
|
||||
let Some((status, _)) = door.send(&method, &path, &headers, body) else {
|
||||
return; // not an HTTP request at all
|
||||
};
|
||||
if !live && book == "acme" && path.ends_with("/mcp") {
|
||||
assert!((400..500).contains(&status), "no token, yet {status} for {path}");
|
||||
}
|
||||
if live && path.ends_with("/mcp") && book == "acme" {
|
||||
assert!(status < 500, "a valid token made the door fail: {status}");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// Fuzz target: a file's bytes come in only by `PUT /b/{book}/u/{upload}`
|
||||
// (ledger-mcp local.rs INVARIANT 9), through the same in-process router as
|
||||
// fuzz_http_mcp_request. The body, the declared size and hash, the upload
|
||||
// id and the token are all arbitrary. Whatever arrives: the door answers,
|
||||
// and bytes are taken only if they are exactly the declared ones, from the
|
||||
// token the upload was granted to, once: a 200 for anything else is a bug.
|
||||
#![no_main]
|
||||
|
||||
use arbitrary::Arbitrary;
|
||||
use ledger_mcp::local::fuzz_door::FuzzDoor;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[derive(Arbitrary, Debug)]
|
||||
enum Declared {
|
||||
Honest,
|
||||
/// The size off by this much (never 0).
|
||||
SizeOff(i8),
|
||||
/// The hash of other bytes.
|
||||
HashOf(Vec<u8>),
|
||||
/// A hash that is not 64 hex digits.
|
||||
BadHash(String),
|
||||
}
|
||||
|
||||
#[derive(Arbitrary, Debug)]
|
||||
enum Who {
|
||||
Owner,
|
||||
Other,
|
||||
Nobody,
|
||||
}
|
||||
|
||||
#[derive(Arbitrary, Debug)]
|
||||
struct Put {
|
||||
body: Vec<u8>,
|
||||
declared: Declared,
|
||||
who: Who,
|
||||
/// Use an id that was never granted (this text, cleaned).
|
||||
unknown_id: Option<String>,
|
||||
/// Send it a second time too.
|
||||
again: bool,
|
||||
/// And a body of another length the second time.
|
||||
second: Vec<u8>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static DOOR: RefCell<Option<(FuzzDoor, u32)>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fuzz_target!(|p: Put| {
|
||||
DOOR.with(|cell| {
|
||||
let mut cell = cell.borrow_mut();
|
||||
if cell.as_ref().is_none_or(|(_, n)| *n > 1500) {
|
||||
*cell = Some((FuzzDoor::new(), 0));
|
||||
}
|
||||
let (door, n) = cell.as_mut().unwrap();
|
||||
*n += 1;
|
||||
|
||||
let sha = ledger_store::journal::sha256_hex(&p.body);
|
||||
let (grant_sha, grant_size) = match &p.declared {
|
||||
Declared::Honest => (sha.clone(), p.body.len() as u64),
|
||||
Declared::SizeOff(d) => {
|
||||
let d = if *d == 0 { 1 } else { i64::from(*d) };
|
||||
(sha.clone(), (p.body.len() as i64 + d).max(0) as u64)
|
||||
}
|
||||
Declared::HashOf(other) => (
|
||||
ledger_store::journal::sha256_hex(other),
|
||||
p.body.len() as u64,
|
||||
),
|
||||
Declared::BadHash(s) => (s.clone(), p.body.len() as u64),
|
||||
};
|
||||
let size_ok = grant_size == p.body.len() as u64 && grant_sha == sha;
|
||||
|
||||
let granted = door.grant(&grant_sha, grant_size);
|
||||
let id = match (&p.unknown_id, &granted) {
|
||||
(Some(s), _) => {
|
||||
let s: String = s
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
|
||||
.take(40)
|
||||
.collect();
|
||||
if s.is_empty() { "up_x".to_string() } else { s }
|
||||
}
|
||||
(None, Ok(id)) => id.clone(),
|
||||
(None, Err(_)) => "up_none".to_string(),
|
||||
};
|
||||
let real = p.unknown_id.is_none() && granted.is_ok();
|
||||
let bearer = match p.who {
|
||||
Who::Owner => Some(door.bearer().to_string()),
|
||||
Who::Other => Some(door.other_bearer().to_string()),
|
||||
Who::Nobody => None,
|
||||
};
|
||||
let mut headers = Vec::new();
|
||||
if let Some(b) = &bearer {
|
||||
headers.push((
|
||||
b"authorization".to_vec(),
|
||||
format!("Bearer {b}").into_bytes(),
|
||||
));
|
||||
}
|
||||
let path = format!("/b/acme/u/{id}");
|
||||
let Some((status, text)) = door.send(b"PUT", &path, &headers, p.body.clone()) else {
|
||||
return;
|
||||
};
|
||||
let may_succeed = real && matches!(p.who, Who::Owner) && size_ok;
|
||||
if status == 200 {
|
||||
assert!(
|
||||
may_succeed,
|
||||
"bytes taken that should not be: {:?} {:?}",
|
||||
p.who, p.declared
|
||||
);
|
||||
let said = String::from_utf8_lossy(&text);
|
||||
assert!(
|
||||
said.contains(&sha),
|
||||
"the answer doesn't name the bytes' hash: {said}"
|
||||
);
|
||||
} else if may_succeed {
|
||||
// Everything lined up: the only way not to be taken is a
|
||||
// failure of the door itself.
|
||||
panic!(
|
||||
"honest upload by its owner refused: {status} {}",
|
||||
String::from_utf8_lossy(&text)
|
||||
);
|
||||
}
|
||||
if status == 200 && p.again {
|
||||
let Some((again, _)) = door.send(b"PUT", &path, &headers, p.second.clone()) else {
|
||||
return;
|
||||
};
|
||||
assert_ne!(again, 200, "an upload taken twice");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user