hanzo-research: harden transport + validate since (red)

Rescued the Rust SDK security hardening (the fixing agent died at the wall
before committing): validate the server-supplied since as a git object id
(hex, 1..=64) before it reaches 'git log <since>..HEAD' so it can never be
read as a flag/path (arg-injection); refuse HTTP redirects (.redirects(0),
no SSRF bounce); cap the response body at 8MiB (no hostile-server OOM).
13 tests green incl since_must_be_a_git_object_id.
This commit is contained in:
Hanzo Dev
2026-07-23 01:57:05 -07:00
parent 9770fe1656
commit a83fb1dc7f
2 changed files with 67 additions and 13 deletions
+39 -10
View File
@@ -2,8 +2,9 @@
//!
//! Record and query R&D evidence from Rust: the ONE way hanzo-ml / hanzo-engine kernel
//! benchmarks self-log to the unified cloud research plane (`/v1/research`, HIP-0512).
//! The Rust peer of `hanzo_research` (Python) — identical verbs, identical wire records,
//! so every language accrues into one evidence corpus.
//! The Rust peer of `hanzo_research` (Python) — identical verbs. Records are semantically
//! identical across languages (the server keys on `(project, id = kind:subject:task)`), so
//! every language accrues into one evidence corpus regardless of JSON serialization.
//!
//! ```no_run
//! use hanzo_research::{Research, Verdict};
@@ -31,6 +32,7 @@
use std::collections::BTreeMap;
use std::fmt;
use std::io::Read;
use std::sync::Arc;
use std::time::Duration;
@@ -61,6 +63,9 @@ pub const DEFAULT_LIBS: &[&str] = &[
const DEFAULT_BASE: &str = "https://api.hanzo.ai";
const NARRATIVE_WINDOW: usize = 10;
const TIMEOUT: Duration = Duration::from_secs(120);
/// Response-body cap (8 MiB): a hostile or broken server cannot stream an unbounded body to
/// OOM the client. Parity with the Go/C++ ports.
const MAX_RESPONSE_BYTES: u64 = 8 << 20;
/// The crate's result type.
pub type Result<T> = std::result::Result<T, Error>;
@@ -172,7 +177,13 @@ impl Research {
repo.to_string()
};
let names: &[&str] = if libs.is_empty() { DEFAULT_LIBS } else { libs };
let agent = ureq::AgentBuilder::new().timeout(TIMEOUT).build();
// Never follow redirects: a /v1/research endpoint has no legitimate redirect, so
// refusing them removes any SSRF/cross-host bounce surface. (ureq already strips the
// Authorization header across hosts; this closes the redirect entirely.)
let agent = ureq::AgentBuilder::new()
.timeout(TIMEOUT)
.redirects(0)
.build();
Research {
inner: Arc::new(Inner {
git: provenance::git_state(&repo),
@@ -287,13 +298,13 @@ impl Research {
.auth(self.inner.agent.post(&url))
.set("Content-Type", "application/json")
.send_bytes(&bytes)?;
Ok(serde_json::from_reader(resp.into_reader())?)
read_json(resp)
}
fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
let url = format!("{}{path}", self.inner.base);
let resp = self.auth(self.inner.agent.get(&url)).call()?;
Ok(serde_json::from_reader(resp.into_reader())?)
read_json(resp)
}
/// The git sha of this experiment's last recorded run, for the since-narrative.
@@ -465,8 +476,8 @@ impl Experiment {
/// Post the run in-flight exactly once, capturing the commit narrative since its last
/// recorded run. Called at the top of every entry method, so the in-flight record is
/// stamped with the frame (hypothesis/predict) but before any log — byte-identical to
/// the Python running post. Marks started only on success, so a failed post retries.
/// stamped with the frame (hypothesis/predict) but before any log — the same in-flight
/// record the Python SDK posts. Marks started only on success, so a failed post retries.
fn start(&mut self) -> Result<()> {
if self.started {
return Ok(());
@@ -565,8 +576,9 @@ impl Outcome {
// ── wire records (the exact shape every language's SDK produces) ─────────────────────────
/// One experiment version on the wire. Field order mirrors the Python SDK; JSON object
/// identity is order-independent, so records are indistinguishable across languages.
/// One experiment version on the wire. Field order mirrors the Python SDK for readability;
/// JSON objects are unordered and the server keys on (project, id), so records are
/// semantically identical across languages regardless of serialization.
#[derive(Debug, Clone, Serialize)]
pub struct ExperimentRecord {
pub id: String,
@@ -714,6 +726,23 @@ pub struct KindTotal {
// ── helpers ──────────────────────────────────────────────────────────────────────────────
/// Deserialize a JSON response body under a hard size cap, so a hostile server cannot stream
/// an unbounded body to OOM the client. Reads one byte past the cap to detect an overflow
/// rather than silently truncating.
fn read_json<T: serde::de::DeserializeOwned>(resp: ureq::Response) -> Result<T> {
let mut buf = Vec::new();
resp.into_reader()
.take(MAX_RESPONSE_BYTES + 1)
.read_to_end(&mut buf)?;
if buf.len() as u64 > MAX_RESPONSE_BYTES {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"research: response exceeds size cap",
)));
}
Ok(serde_json::from_slice(&buf)?)
}
const HEX: &[u8; 16] = b"0123456789abcdef";
/// Lowercase hex sha256 of the bytes — the client-side integrity hash the server verifies.
@@ -822,7 +851,7 @@ mod tests {
#[test]
fn running_post_has_empty_log_and_no_verdict() {
// The in-flight record carries the frame (hypothesis/predict) but no verdict and
// an empty log — byte-identical to the Python running post.
// an empty log — the same in-flight record the Python SDK posts.
let mut e = pinned();
e.hypothesis = "H".into();
e.predict = "P".into();
+28 -3
View File
@@ -56,10 +56,13 @@ pub fn git_state(repo: &str) -> Git {
/// last recorded run's sha when known (`<since>..HEAD`), else the last `window` commits.
/// Research self-documents as a side effect of running: commit normally, nothing extra.
pub fn commit_narrative(repo: &str, since_sha: &str, window: usize) -> Vec<String> {
let raw = if since_sha.is_empty() {
git(repo, &["log", &format!("-{window}"), "--format=%s"])
} else {
// since_sha can arrive from a server response and reaches `git log <since>..HEAD`; only a
// git object id (hex) is accepted, so it can never be read as a flag or a path — anything
// else falls back to the recent window. Parity with the C++ port.
let raw = if is_object_id(since_sha) {
git(repo, &["log", &format!("{since_sha}..HEAD"), "--format=%s"])
} else {
git(repo, &["log", &format!("-{window}"), "--format=%s"])
};
raw.lines()
.map(str::trim)
@@ -68,6 +71,13 @@ pub fn commit_narrative(repo: &str, since_sha: &str, window: usize) -> Vec<Strin
.collect()
}
/// True when `s` is a git object id — hex only, 1..=64 chars (sha1=40, sha256=64). Gating a
/// server-supplied `since` to hex means it can never be read as a git flag (a leading `-`)
/// or a path (`/`), which closes the arg-injection.
fn is_object_id(s: &str) -> bool {
!s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
/// `{crate: version}` resolved from the repo's `Cargo.lock`, for the named crates that
/// are present. The Rust analog of Python's installed-version lookup: the run's ACTUAL
/// resolved dependency versions, so a kernel-perf result is pinned to the versions that
@@ -170,4 +180,19 @@ version = "1.7.92"
let miss = lib_versions_from_lock(lock, &["does-not-exist"]);
assert!(miss.is_empty());
}
#[test]
fn since_must_be_a_git_object_id() {
// A real object id drives `<since>..HEAD`.
assert!(is_object_id("abc123"));
assert!(is_object_id(&"a".repeat(40))); // sha1
assert!(is_object_id(&"F".repeat(64))); // sha256
// Anything else falls back to the window — no git flag/path can be injected.
assert!(!is_object_id("")); // empty
assert!(!is_object_id("--output=/tmp/pwned")); // flag injection
assert!(!is_object_id("HEAD~5")); // ~ not hex
assert!(!is_object_id("../etc")); // path chars
assert!(!is_object_id("a b")); // whitespace
assert!(!is_object_id(&"a".repeat(65))); // too long
}
}