diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index d04b85fb..2dffd5a5 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -139,12 +139,23 @@ fn compute_rust_lockfile_hash(workdir: &Path) -> String { h.update(&content); } } - // Entry file is compiled into the binary, so it must be part of the cache key. - // Without this, two fixtures with the same Cargo.toml but different entry.rs - // would collide and the second would receive the wrong cached binary. - if let Ok(content) = std::fs::read(workdir.join("src").join("entry.rs")) { - h.update(b"src/entry.rs"); - h.update(&content); + // Every Rust file under src/ feeds the binary so any change must + // invalidate the cache. Walk src/ recursively and hash every .rs + // file path + content in deterministic (sorted) order so the cache + // key is stable across runs. Without this, an emitter change to + // main.rs / nyx_harness_stubs.rs / etc. with no Cargo.toml / + // entry.rs change would silently re-use a stale binary built from + // the old emitter source. + let src_dir = workdir.join("src"); + let mut rs_files: Vec = Vec::new(); + collect_rs_files(&src_dir, &src_dir, &mut rs_files); + rs_files.sort(); + for rel in &rs_files { + if let Ok(content) = std::fs::read(src_dir.join(rel)) { + h.update(rel.to_string_lossy().as_bytes()); + h.update(b"\0"); + h.update(&content); + } } let out = h.finalize(); format!( @@ -153,6 +164,23 @@ fn compute_rust_lockfile_hash(workdir: &Path) -> String { ) } +fn collect_rs_files(root: &Path, dir: &Path, out: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rs_files(root, &path, out); + } else if path.extension().and_then(|s| s.to_str()) == Some("rs") + && let Ok(rel) = path.strip_prefix(root) + { + out.push(rel.to_path_buf()); + } + } +} + /// Result of a successful build. #[derive(Debug, Clone)] pub struct BuildResult { diff --git a/src/dynamic/corpus/open_redirect/go.rs b/src/dynamic/corpus/open_redirect/go.rs index 62019259..b4f543b2 100644 --- a/src/dynamic/corpus/open_redirect/go.rs +++ b/src/dynamic/corpus/open_redirect/go.rs @@ -10,6 +10,15 @@ //! Benign control: same shape but redirects to the same-origin path //! `/dashboard`, so the captured `location` has no authority //! component and the predicate stays clear. +//! +//! OOB-nonce variant (added 2026-05-22): when the runner attaches an +//! [`crate::dynamic::oob::OobListener`] the harness follows the +//! captured `Location:` URL via a real `net/http.Get` against the +//! loopback nonce URL so the listener records the per-finding +//! callback. Ordered first so the runner exercises the OOB observation +//! path before the absolute-URL vuln below triggers and short-circuits +//! iteration; runs without a listener skip cleanly (runner +//! `oob_nonce_slot` branch). use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; use crate::dynamic::oracle::ProbePredicate; @@ -17,6 +26,26 @@ use crate::dynamic::oracle::ProbePredicate; const ALLOWLIST: &[&str] = &["example.com"]; pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + // `bytes` is unused when `oob_nonce_slot = true`; the runner + // substitutes a per-finding loopback URL (see runner.rs:405-413). + bytes: b"", + label: "open-redirect-go-oob-nonce", + oracle: Oracle::OobCallback { host: "127.0.0.1" }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/open_redirect/go/vuln.go"], + oob_nonce_slot: true, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: Some( + "OOB-nonce open-redirect payload self-confirms via the per-finding listener \ + callback when the harness follows the captured Location URL with net/http.Get; \ + no benign URL can hit the nonce path.", + ), + }, CuratedPayload { bytes: b"https://attacker.test/", label: "open-redirect-go-absolute", diff --git a/src/dynamic/corpus/open_redirect/rust.rs b/src/dynamic/corpus/open_redirect/rust.rs index d8a47599..2f852935 100644 --- a/src/dynamic/corpus/open_redirect/rust.rs +++ b/src/dynamic/corpus/open_redirect/rust.rs @@ -9,6 +9,15 @@ //! Benign control: same shape but redirects to the same-origin path //! `/dashboard`, so the captured `location` has no authority //! component and the predicate stays clear. +//! +//! OOB-nonce variant (added 2026-05-22): when the runner attaches an +//! [`crate::dynamic::oob::OobListener`] the harness follows the +//! captured `Location:` URL via a zero-dep `std::net::TcpStream` +//! `GET / HTTP/1.0` against the loopback nonce URL so the listener +//! records the per-finding callback. Ordered first so the runner +//! exercises the OOB observation path before the absolute-URL vuln +//! below triggers and short-circuits iteration; runs without a +//! listener skip cleanly (runner `oob_nonce_slot` branch). use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; use crate::dynamic::oracle::ProbePredicate; @@ -16,6 +25,26 @@ use crate::dynamic::oracle::ProbePredicate; const ALLOWLIST: &[&str] = &["example.com"]; pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + // `bytes` is unused when `oob_nonce_slot = true`; the runner + // substitutes a per-finding loopback URL (see runner.rs:405-413). + bytes: b"", + label: "open-redirect-rust-oob-nonce", + oracle: Oracle::OobCallback { host: "127.0.0.1" }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/open_redirect/rust/vuln.rs"], + oob_nonce_slot: true, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: Some( + "OOB-nonce open-redirect payload self-confirms via the per-finding listener \ + callback when the harness follows the captured Location URL with a zero-dep \ + TcpStream-based GET; no benign URL can hit the nonce path.", + ), + }, CuratedPayload { bytes: b"https://attacker.test/", label: "open-redirect-rust-absolute", diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 48b0b2fa..f5c5ea6c 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -972,7 +972,7 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { "## )); via_fixture_invoke.push_str( - "\tif loc, ok := nyxRedirectViaFixture(payload); ok {\n\t\tnyxRedirectProbe(loc, requestHost)\n\t} else {\n\t\tnyxRedirectProbe(payload, requestHost)\n\t}\n", + "\tif loc, ok := nyxRedirectViaFixture(payload); ok {\n\t\tnyxRedirectProbe(loc, requestHost)\n\t\tnyxFollowLocation(loc)\n\t} else {\n\t\tnyxRedirectProbe(payload, requestHost)\n\t\tnyxFollowLocation(payload)\n\t}\n", ); } else if imports_net_http { // Plain stdlib `http.Redirect(w, r, value, status)` fixture. @@ -999,10 +999,15 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { "## )); via_fixture_invoke.push_str( - "\tif loc, ok := nyxRedirectViaFixture(payload); ok {\n\t\tnyxRedirectProbe(loc, requestHost)\n\t} else {\n\t\tnyxRedirectProbe(payload, requestHost)\n\t}\n", + "\tif loc, ok := nyxRedirectViaFixture(payload); ok {\n\t\tnyxRedirectProbe(loc, requestHost)\n\t\tnyxFollowLocation(loc)\n\t} else {\n\t\tnyxRedirectProbe(payload, requestHost)\n\t\tnyxFollowLocation(payload)\n\t}\n", ); } else { - via_fixture_invoke.push_str("\tnyxRedirectProbe(payload, requestHost)\n"); + // Tier-(b) fallback gate doesn't import net/http, but the OOB + // follower itself needs it. Pull the stdlib net/http surface + // unconditionally so `nyxFollowLocation` compiles. + extra_imports.push_str("\t\"net/http\"\n"); + via_fixture_invoke + .push_str("\tnyxRedirectProbe(payload, requestHost)\n\tnyxFollowLocation(payload)\n"); } let source = format!( @@ -1034,6 +1039,30 @@ func nyxRedirectProbe(location, requestHost string) {{ }}) }} +// Phase 09 OOB closure: when the captured Location is a loopback URL, +// follow it with a real GET so the OOB listener observes the per-finding +// nonce. Skips non-loopback hosts and non-HTTP schemes (no real network +// egress). Best-effort: errors do not propagate; the listener may still +// record the TCP connect before the read fails. +func nyxFollowLocation(location string) {{ + if location == "" {{ + return + }} + if !(strings.HasPrefix(location, "http://127.0.0.1") || + strings.HasPrefix(location, "http://localhost") || + strings.HasPrefix(location, "http://host-gateway")) {{ + return + }} + client := &http.Client{{Timeout: 2 * time.Second}} + resp, err := client.Get(location) + if err != nil {{ + return + }} + defer resp.Body.Close() + buf := make([]byte, 1) + _, _ = resp.Body.Read(buf) +}} + {via_fixture_decl}func main() {{ __nyx_install_crash_guard("gin.Context.Redirect") defer __nyx_recover_crash("gin.Context.Redirect")() @@ -2225,6 +2254,70 @@ mod tests { ); } + #[test] + fn emit_open_redirect_harness_ships_follow_location_helper() { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.entry_name = "Run".into(); + spec.expected_cap = Cap::OPEN_REDIRECT; + spec.entry_file = "/nonexistent/missing.go".into(); + let harness = emit_open_redirect_harness(&spec); + assert!( + harness.source.contains("func nyxFollowLocation(location string)"), + "OPEN_REDIRECT harness must declare the nyxFollowLocation helper", + ); + assert!( + harness.source.contains("strings.HasPrefix(location, \"http://127.0.0.1\")"), + "follower must gate on loopback 127.0.0.1 host prefix", + ); + assert!( + harness.source.contains("strings.HasPrefix(location, \"http://localhost\")"), + "follower must gate on loopback localhost host prefix", + ); + assert!( + harness.source.contains("strings.HasPrefix(location, \"http://host-gateway\")"), + "follower must gate on loopback host-gateway prefix", + ); + assert!( + harness.source.contains("client.Get(location)"), + "follower must drive a real http.Client.Get against the captured Location", + ); + // Tier-(b) callsite must call the follower on the synthetic payload. + assert!( + harness + .source + .contains("nyxRedirectProbe(payload, requestHost)\n\tnyxFollowLocation(payload)"), + "tier-(b) callsite must invoke nyxFollowLocation after the synthetic probe", + ); + // Even tier-(b) must pull in net/http so the follower compiles. + assert!( + harness.source.contains("\"net/http\""), + "OPEN_REDIRECT harness must always import net/http so nyxFollowLocation compiles", + ); + } + + #[test] + fn emit_open_redirect_harness_follows_captured_location_in_tier_a() { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.entry_name = "Run".into(); + spec.expected_cap = Cap::OPEN_REDIRECT; + spec.entry_file = "tests/dynamic_fixtures/open_redirect/go/vuln.go".into(); + let harness = emit_open_redirect_harness(&spec); + // Tier-(a) gin: when fixture call succeeds, follow the captured loc. + assert!( + harness + .source + .contains("nyxRedirectProbe(loc, requestHost)\n\t\tnyxFollowLocation(loc)"), + "tier-(a) callsite must invoke nyxFollowLocation on the captured Location", + ); + // Tier-(a) fixture-call-failed branch falls back to payload-as-loc. + assert!( + harness + .source + .contains("nyxRedirectProbe(payload, requestHost)\n\t\tnyxFollowLocation(payload)"), + "tier-(a) fixture-failure branch must still follow the synthetic payload", + ); + } + #[test] fn gin_stub_pkg_exposes_redirect_method() { let stub = gin_stub_pkg(); diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index ec1b283b..6da67ba7 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -923,9 +923,9 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { "## ); - via_fixture_invoke = " let location = match nyx_redirect_via_fixture(payload.clone()) {\n Some(loc) if !loc.is_empty() => loc,\n _ => payload.clone(),\n };\n nyx_redirect_probe(&location, request_host);\n".to_owned(); + via_fixture_invoke = " let location = match nyx_redirect_via_fixture(payload.clone()) {\n Some(loc) if !loc.is_empty() => loc,\n _ => payload.clone(),\n };\n nyx_redirect_probe(&location, request_host);\n nyx_follow_location(&location);\n".to_owned(); } else { - via_fixture_invoke = " let location = payload.clone();\n nyx_redirect_probe(&location, request_host);\n".to_owned(); + via_fixture_invoke = " let location = payload.clone();\n nyx_redirect_probe(&location, request_host);\n nyx_follow_location(&location);\n".to_owned(); } let cargo_toml = generate_cargo_toml(Cap::OPEN_REDIRECT); @@ -982,6 +982,52 @@ fn nyx_redirect_probe(location: &str, request_host: &str) {{ }} }} +// Phase 09 OOB closure: when the captured Location is a loopback URL, +// follow it with a zero-dep `TcpStream` GET so the OOB listener +// observes the per-finding nonce. Skips non-loopback hosts and +// non-HTTP schemes (no real network egress). Best-effort: errors do +// not propagate; the listener may still record the TCP connect before +// the read fails. +fn nyx_follow_location(location: &str) {{ + if location.is_empty() {{ return; }} + let loopback = location.starts_with("http://127.0.0.1") + || location.starts_with("http://localhost") + || location.starts_with("http://host-gateway"); + if !loopback {{ return; }} + let rest = match location.strip_prefix("http://") {{ + Some(r) => r, + None => return, + }}; + let (authority, path) = match rest.find('/') {{ + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }}; + let (host, port): (&str, u16) = match authority.rfind(':') {{ + Some(i) => {{ + let p = authority[i + 1..].parse::().unwrap_or(80); + (&authority[..i], p) + }} + None => (authority, 80), + }}; + use std::io::{{Read, Write}}; + use std::net::{{TcpStream, ToSocketAddrs}}; + use std::time::Duration; + let addr = match (host, port).to_socket_addrs() {{ + Ok(mut it) => match it.next() {{ Some(a) => a, None => return }}, + Err(_) => return, + }}; + let mut stream = match TcpStream::connect_timeout(&addr, Duration::from_secs(2)) {{ + Ok(s) => s, + Err(_) => return, + }}; + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(2))); + let req = format!("GET {{path}} HTTP/1.0\r\nHost: {{host}}\r\nConnection: close\r\n\r\n", path = path, host = host); + let _ = stream.write_all(req.as_bytes()); + let mut buf = [0u8; 1]; + let _ = stream.read(&mut buf); +}} + {via_fixture_decl}fn main() {{ let payload = env::var("NYX_PAYLOAD").unwrap_or_default(); let request_host = "example.com"; @@ -2252,6 +2298,60 @@ mod tests { ); } + #[test] + fn emit_open_redirect_harness_ships_follow_location_helper() { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.entry_name = "run".into(); + spec.expected_cap = Cap::OPEN_REDIRECT; + spec.entry_file = "/nonexistent/missing.rs".into(); + let harness = emit_open_redirect_harness(&spec); + assert!( + harness.source.contains("fn nyx_follow_location(location: &str)"), + "OPEN_REDIRECT harness must declare the nyx_follow_location helper", + ); + for prefix in [ + "http://127.0.0.1", + "http://localhost", + "http://host-gateway", + ] { + assert!( + harness + .source + .contains(&format!("starts_with(\"{prefix}\")")), + "follower must gate on loopback {prefix} prefix", + ); + } + assert!( + harness.source.contains("TcpStream::connect_timeout"), + "follower must drive a zero-dep TcpStream::connect_timeout against the captured Location", + ); + assert!( + harness.source.contains("GET {path} HTTP/1.0"), + "follower must write a HTTP/1.0 GET request line", + ); + // Tier-(b) callsite must call the follower on the synthetic payload. + assert!( + harness + .source + .contains("nyx_redirect_probe(&location, request_host);\n nyx_follow_location(&location);"), + "tier-(b) callsite must invoke nyx_follow_location after the synthetic probe", + ); + } + + #[test] + fn emit_open_redirect_harness_follows_captured_location_in_tier_a() { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.entry_name = "run".into(); + spec.expected_cap = Cap::OPEN_REDIRECT; + spec.entry_file = "tests/dynamic_fixtures/open_redirect/rust/vuln.rs".into(); + let harness = emit_open_redirect_harness(&spec); + // Tier-(a) callsite: captured loc → probe + follow. + assert!( + harness.source.contains("nyx_redirect_probe(&location, request_host);\n nyx_follow_location(&location);"), + "tier-(a) callsite must invoke nyx_follow_location on the captured Location", + ); + } + #[test] fn cargo_toml_extras_pins_percent_encoding_when_requested() { let cargo = generate_cargo_toml_with_extras(Cap::HEADER_INJECTION, true); diff --git a/tests/open_redirect_corpus.rs b/tests/open_redirect_corpus.rs index 5892ebb5..bd274f48 100644 --- a/tests/open_redirect_corpus.rs +++ b/tests/open_redirect_corpus.rs @@ -743,4 +743,20 @@ mod e2e_phase_09 { }; assert_oob_recorded(&outcome, "open-redirect-php-oob-nonce"); } + + #[test] + fn go_open_redirect_oob_loopback_records_callback() { + let Some(outcome) = run_oob(Lang::Go, "vuln.go", "Run") else { + return; + }; + assert_oob_recorded(&outcome, "open-redirect-go-oob-nonce"); + } + + #[test] + fn rust_open_redirect_oob_loopback_records_callback() { + let Some(outcome) = run_oob(Lang::Rust, "vuln.rs", "run") else { + return; + }; + assert_oob_recorded(&outcome, "open-redirect-rust-oob-nonce"); + } }