diff --git a/src/dynamic/corpus/open_redirect/java.rs b/src/dynamic/corpus/open_redirect/java.rs index 33753ecb..44b525b5 100644 --- a/src/dynamic/corpus/open_redirect/java.rs +++ b/src/dynamic/corpus/open_redirect/java.rs @@ -15,6 +15,15 @@ //! code path redirects to the relative path `/dashboard` (no host //! component). The captured `location` has no off-origin authority //! 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 `HttpURLConnection.getInputStream()` +//! 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; @@ -22,6 +31,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-java-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/java/Vuln.java"], + 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 \ + HttpURLConnection.getInputStream; no benign URL can hit the nonce path.", + ), + }, CuratedPayload { bytes: b"https://attacker.test/", label: "open-redirect-java-absolute", diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 9b7c5659..50fdb1ca 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -1706,18 +1706,22 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { String captured = response.getRedirectedUrl(); if (fixtureInvoked && captured != null) {{ nyxRedirectProbe(captured, requestHost); + nyxFollowLocation(captured); }} else {{ nyxRedirectProbe(payload, requestHost); + nyxFollowLocation(payload); }}"# ) } else { - r#" nyxRedirectProbe(payload, requestHost);"#.to_owned() + r#" nyxRedirectProbe(payload, requestHost); + nyxFollowLocation(payload);"# + .to_owned() }; let imports = if has_servlet_stubs { - "import java.lang.reflect.InvocationTargetException;\nimport java.lang.reflect.Method;\n" + "import java.lang.reflect.InvocationTargetException;\nimport java.lang.reflect.Method;\nimport java.net.HttpURLConnection;\nimport java.net.URL;\n" } else { - "" + "import java.net.HttpURLConnection;\nimport java.net.URL;\n" }; let source = format!( @@ -1757,6 +1761,31 @@ public class NyxHarness {{ }} }} + // Phase 09 OOB closure: when the captured Location is a fully-qualified + // loopback URL, follow it with a real GET so the OOB listener records + // the per-finding nonce. Skips non-loopback hosts (no real network egress) + // and any non-HTTP scheme. Best-effort: failures do not propagate, the + // listener may still have observed the connect before the read errored. + static void nyxFollowLocation(String location) {{ + if (location == null || location.isEmpty()) return; + String lower = location.toLowerCase(); + if (!(lower.startsWith("http://127.0.0.1") + || lower.startsWith("http://localhost") + || lower.startsWith("http://host-gateway"))) {{ + return; + }} + try {{ + HttpURLConnection conn = (HttpURLConnection) new URL(location).openConnection(); + conn.setConnectTimeout(2000); + conn.setReadTimeout(2000); + conn.setInstanceFollowRedirects(false); + conn.getInputStream().close(); + conn.disconnect(); + }} catch (Exception ignored) {{ + // best-effort OOB fetch + }} + }} + public static void main(String[] args) {{ String payload = System.getenv("NYX_PAYLOAD"); if (payload == null) payload = ""; @@ -3487,6 +3516,65 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn emit_open_redirect_harness_ships_follow_location_helper() { + let dir = std::env::temp_dir().join("nyx_phase09_test_follow_helper"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = write_servlet_fixture( + &dir, + "public class Vuln { public static void run(String v) { System.out.println(v); } }\n", + ); + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::OPEN_REDIRECT; + spec.entry_file = entry; + spec.entry_name = "run".into(); + let h = emit_open_redirect_harness(&spec); + assert!( + h.source.contains("static void nyxFollowLocation(String location)"), + "OPEN_REDIRECT harness must declare the nyxFollowLocation helper", + ); + assert!( + h.source.contains("import java.net.HttpURLConnection;"), + "OPEN_REDIRECT harness must import HttpURLConnection", + ); + assert!( + h.source.contains("import java.net.URL;"), + "OPEN_REDIRECT harness must import URL", + ); + assert!( + h.source.contains("http://127.0.0.1"), + "follow-location helper must whitelist loopback hosts", + ); + assert!( + h.source.contains("nyxFollowLocation(payload)"), + "tier-(b) fallback must follow the synthetic payload location", + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_open_redirect_harness_follows_captured_location_in_tier_a() { + let dir = std::env::temp_dir().join("nyx_phase09_test_follow_tier_a"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = write_servlet_fixture( + &dir, + "import javax.servlet.http.HttpServletResponse;\n\ + public class Vuln {\n public static void run(HttpServletResponse r, String v) throws Exception {\n r.sendRedirect(v);\n }\n}\n", + ); + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::OPEN_REDIRECT; + spec.entry_file = entry; + spec.entry_name = "run".into(); + let h = emit_open_redirect_harness(&spec); + assert!( + h.source.contains("nyxRedirectProbe(captured, requestHost);\n nyxFollowLocation(captured);"), + "tier-(a) must follow the captured Location: value, not the raw payload", + ); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn emit_xpath_harness_drives_fixture_through_real_xpath_when_imported() { let dir = std::env::temp_dir().join("nyx_phase07_test_drive_fixture"); diff --git a/tests/open_redirect_corpus.rs b/tests/open_redirect_corpus.rs index e8da6f52..26d111ec 100644 --- a/tests/open_redirect_corpus.rs +++ b/tests/open_redirect_corpus.rs @@ -90,7 +90,12 @@ fn open_redirect_unsupported_caps_unchanged_for_other_langs() { fn benign_control_resolves_within_lang_slice() { for lang in LANGS { let slice = payloads_for_lang(Cap::OPEN_REDIRECT, *lang); - let vuln = slice.iter().find(|p| !p.is_benign).unwrap(); + // Skip OOB-nonce variants — they self-confirm via the per-finding + // listener and carry no paired benign control by design. + let vuln = slice + .iter() + .find(|p| !p.is_benign && !p.oob_nonce_slot) + .unwrap(); let resolved = resolve_benign_control_lang(vuln, Cap::OPEN_REDIRECT, *lang).expect("paired control"); assert!(resolved.is_benign); @@ -103,7 +108,12 @@ fn benign_control_resolves_within_lang_slice() { fn payload_oracle_carries_redirect_host_not_in_predicate() { for lang in LANGS { let slice = payloads_for_lang(Cap::OPEN_REDIRECT, *lang); - let vuln = slice.iter().find(|p| !p.is_benign).unwrap(); + // The off-origin-URL vuln carries the RedirectHostNotIn predicate; + // OOB-nonce variants observe via the listener and use OobCallback. + let vuln = slice + .iter() + .find(|p| !p.is_benign && !p.oob_nonce_slot) + .unwrap(); match &vuln.oracle { Oracle::SinkProbe { predicates } => { assert!( @@ -122,7 +132,12 @@ fn payload_oracle_carries_redirect_host_not_in_predicate() { fn vuln_payload_bytes_carry_off_origin_url_benign_bytes_do_not() { for lang in LANGS { let slice = payloads_for_lang(Cap::OPEN_REDIRECT, *lang); - let vuln = slice.iter().find(|p| !p.is_benign).unwrap(); + // OOB-nonce variants ship empty `bytes` (the runner substitutes the + // loopback nonce URL at run-time); inspect only the curated vuln here. + let vuln = slice + .iter() + .find(|p| !p.is_benign && !p.oob_nonce_slot) + .unwrap(); let benign = slice.iter().find(|p| p.is_benign).unwrap(); let vuln_text = std::str::from_utf8(vuln.bytes).unwrap(); let benign_text = std::str::from_utf8(benign.bytes).unwrap(); @@ -603,4 +618,97 @@ mod e2e_phase_09 { }; assert_confirmed(Lang::Rust, &outcome); } + + /// Phase 09 OOB-loopback observation: when an [`nyx_scanner::dynamic::oob::OobListener`] + /// is attached and the runner exercises the `open-redirect-java-oob-nonce` + /// payload, the harness follows the captured `Location:` URL with a real + /// `HttpURLConnection.getInputStream()` against the loopback nonce URL and + /// the listener records the hit. Asserts both halves of the OOB closure: + /// the callback observation AND the verdict-tier promotion from + /// `Confirmed` to `ConfirmedProvenOob` (the runner's + /// `build_oob_self_confirmed_outcome` path treats the OOB-nonce payload as + /// self-confirming since a benign URL structurally cannot hit a + /// per-finding nonce). + fn run_oob(lang: Lang, fixture: &str, entry_name: &str) -> Option { + use nyx_scanner::dynamic::oob::OobListener; + use nyx_scanner::dynamic::sandbox::NetworkPolicy; + use std::sync::Arc; + + let bin = toolchain_for(lang); + if !command_available(bin) { + eprintln!("SKIP {lang:?} {fixture} (oob): missing toolchain {bin}"); + return None; + } + let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let listener = Arc::new(OobListener::bind().expect("bind OOB listener on loopback")); + let (mut spec, _tmp) = build_spec(lang, fixture, entry_name); + // Use a distinct workdir from the non-OOB e2e tests so the probe + // channel files do not collide (both tests use the same fixture, so + // the default spec_hash would resolve to the same + // `/tmp/nyx-harness//__nyx_probes.jsonl` and the two runs + // could clobber each other's drains under parallel nextest). + spec.spec_hash = format!("{}-oob", spec.spec_hash); + spec.finding_id = spec.spec_hash.clone(); + if matches!(lang, Lang::Java) { + let workdir = std::path::PathBuf::from("/tmp/nyx-harness").join(&spec.spec_hash); + let _ = std::fs::remove_dir_all(&workdir); + } + + let opts = SandboxOptions { + backend: SandboxBackend::Process, + network_policy: NetworkPolicy::OobOutbound { + listener: Arc::clone(&listener), + }, + ..SandboxOptions::default() + }; + + match run_spec(&spec, &opts) { + Ok(outcome) => Some(outcome), + Err(RunError::BuildFailed { stderr, attempts }) => { + eprintln!( + "SKIP {lang:?} {fixture} (oob): harness build failed after {attempts} attempts: {stderr}", + ); + None + } + Err(e) => panic!("run_spec({lang:?} {fixture} oob) errored: {e:?}"), + } + } + + fn assert_oob_recorded(outcome: &RunOutcome, label: &str) { + let oob_attempt = outcome + .attempts + .iter() + .find(|a| a.payload_label == label) + .unwrap_or_else(|| { + panic!( + "OOB payload {label:?} must run when listener is attached; outcome={outcome:?}" + ) + }); + assert!( + oob_attempt.outcome.oob_callback_seen, + "harness must follow captured Location URL so OOB listener records the nonce; got attempt={oob_attempt:?}", + ); + assert!( + oob_attempt.triggered, + "OOB attempt must mark triggered=true under the self-confirming OOB path; got attempt={oob_attempt:?}", + ); + let diff = outcome + .differential + .as_ref() + .expect("self-confirming OOB run must carry a DifferentialOutcome"); + assert_eq!( + diff.verdict, + DifferentialVerdict::ConfirmedProvenOob, + "OOB callback observation must promote verdict tier; got diff={diff:?}", + ); + } + + #[test] + fn java_open_redirect_oob_loopback_records_callback() { + let Some(outcome) = run_oob(Lang::Java, "Vuln.java", "run") else { + return; + }; + assert_oob_recorded(&outcome, "open-redirect-java-oob-nonce"); + } }