From 824859008e539bd39243c352cb9461c80627d5f0 Mon Sep 17 00:00:00 2001 From: pitboss Date: Thu, 21 May 2026 23:00:09 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0027 (20260521T201327Z-3848) --- src/dynamic/corpus/open_redirect/js.rs | 28 +++++++ src/dynamic/corpus/open_redirect/php.rs | 31 +++++++ src/dynamic/corpus/open_redirect/python.rs | 29 +++++++ src/dynamic/corpus/open_redirect/ruby.rs | 29 +++++++ src/dynamic/lang/js_shared.rs | 97 +++++++++++++++++++++- src/dynamic/lang/php.rs | 66 +++++++++++++++ src/dynamic/lang/python.rs | 94 +++++++++++++++++++++ src/dynamic/lang/ruby.rs | 74 +++++++++++++++++ tests/open_redirect_corpus.rs | 32 +++++++ 9 files changed, 478 insertions(+), 2 deletions(-) diff --git a/src/dynamic/corpus/open_redirect/js.rs b/src/dynamic/corpus/open_redirect/js.rs index 984d9254..6c6ea319 100644 --- a/src/dynamic/corpus/open_redirect/js.rs +++ b/src/dynamic/corpus/open_redirect/js.rs @@ -9,6 +9,14 @@ //! 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 `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; @@ -16,6 +24,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-js-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/js/vuln.js"], + 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 http.get; \ + no benign URL can hit the nonce path.", + ), + }, CuratedPayload { bytes: b"https://attacker.test/", label: "open-redirect-js-absolute", diff --git a/src/dynamic/corpus/open_redirect/php.rs b/src/dynamic/corpus/open_redirect/php.rs index bdba4239..04e8c222 100644 --- a/src/dynamic/corpus/open_redirect/php.rs +++ b/src/dynamic/corpus/open_redirect/php.rs @@ -11,6 +11,16 @@ //! 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 +//! `file_get_contents($location, stream_context_create(...))` 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; @@ -18,6 +28,27 @@ 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-php-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/php/vuln.php"], + 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 \ + file_get_contents under a stream context timeout; no benign URL can hit \ + the nonce path.", + ), + }, CuratedPayload { bytes: b"https://attacker.test/", label: "open-redirect-php-absolute", diff --git a/src/dynamic/corpus/open_redirect/python.rs b/src/dynamic/corpus/open_redirect/python.rs index ee61581b..94497037 100644 --- a/src/dynamic/corpus/open_redirect/python.rs +++ b/src/dynamic/corpus/open_redirect/python.rs @@ -10,6 +10,15 @@ //! Benign control: same shape but redirects to the relative 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 `urllib.request.urlopen` +//! 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-python-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/python/vuln.py"], + 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 \ + urllib.request.urlopen; no benign URL can hit the nonce path.", + ), + }, CuratedPayload { bytes: b"https://attacker.test/", label: "open-redirect-python-absolute", diff --git a/src/dynamic/corpus/open_redirect/ruby.rs b/src/dynamic/corpus/open_redirect/ruby.rs index 6b19acd5..8c17ceb4 100644 --- a/src/dynamic/corpus/open_redirect/ruby.rs +++ b/src/dynamic/corpus/open_redirect/ruby.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 real `Net::HTTP.get_response` 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-ruby-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/ruby/vuln.rb"], + 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_response; no benign URL can hit the nonce path.", + ), + }, CuratedPayload { bytes: b"https://attacker.test/", label: "open-redirect-ruby-absolute", diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 6e845a12..7c1f84aa 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -1618,9 +1618,9 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { }; let invoke_via_fixture = if uses_node_writer { - "const captured = nyxRedirectViaFixture(payload);\nif (Array.isArray(captured)) {\n const [location, requestHost] = captured;\n nyxRedirectProbe(location, requestHost);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n} else {\n // Synthetic fallback — fixture import / call failed.\n const requestHost = 'example.com';\n const location = payload;\n nyxRedirectProbe(location, requestHost);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n}\n" + "const captured = nyxRedirectViaFixture(payload);\nif (Array.isArray(captured)) {\n const [location, requestHost] = captured;\n nyxRedirectProbe(location, requestHost);\n nyxFollowLocation(location);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n} else {\n // Synthetic fallback — fixture import / call failed.\n const requestHost = 'example.com';\n const location = payload;\n nyxRedirectProbe(location, requestHost);\n nyxFollowLocation(location);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n}\n" } else { - "const requestHost = 'example.com';\nconst location = payload;\nnyxRedirectProbe(location, requestHost);\nconsole.log('__NYX_SINK_HIT__');\nconsole.log(JSON.stringify({ location: location, request_host: requestHost }));\n" + "const requestHost = 'example.com';\nconst location = payload;\nnyxRedirectProbe(location, requestHost);\nnyxFollowLocation(location);\nconsole.log('__NYX_SINK_HIT__');\nconsole.log(JSON.stringify({ location: location, request_host: requestHost }));\n" }; let body = format!( @@ -1647,6 +1647,31 @@ function nyxRedirectProbe(location, requestHost) {{ }} }} +// 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. +function nyxFollowLocation(location) {{ + if (!location || typeof location !== 'string') return; + const lower = location.toLowerCase(); + if (!(lower.startsWith('http://127.0.0.1') + || lower.startsWith('http://localhost') + || lower.startsWith('http://host-gateway'))) {{ + return; + }} + try {{ + const http = require('http'); + const req = http.get(location, {{ timeout: 2000 }}, (res) => {{ + res.resume(); + }}); + req.on('error', () => {{}}); + req.on('timeout', () => {{ try {{ req.destroy(); }} catch (e) {{}} }}); + }} catch (e) {{ + // best-effort OOB fetch + }} +}} + {via_fixture}const payload = process.env.NYX_PAYLOAD || ''; {invoke_via_fixture}"# ); @@ -3118,4 +3143,72 @@ 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_js_test_follow_location"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.js"); + std::fs::write( + &entry, + "const express = require('express');\nfunction run(req, res, value) { res.redirect(value); }\nmodule.exports = { run };\n", + ) + .unwrap(); + let h = emit_open_redirect_harness(&make_redirect_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + h.source.contains("function nyxFollowLocation(location)"), + "OPEN_REDIRECT harness must declare the nyxFollowLocation helper: {}", + h.source + ); + assert!( + h.source.contains("http.get(location"), + "follow-location helper must call http.get on the captured URL: {}", + h.source + ); + assert!( + h.source.contains("lower.startsWith('http://127.0.0.1')") + && h.source.contains("lower.startsWith('http://localhost')") + && h.source.contains("lower.startsWith('http://host-gateway')"), + "follow-location helper must gate on loopback host prefixes: {}", + h.source + ); + assert!( + h.source.contains("nyxRedirectProbe(location, requestHost);\n nyxFollowLocation(location);"), + "tier-(a) must follow the captured Location after emitting the probe: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_open_redirect_harness_follows_synthetic_location_in_fallback() { + let dir = std::env::temp_dir().join("nyx_phase09_js_test_follow_fallback"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.js"); + std::fs::write( + &entry, + "function run(req, res, value) { res.redirect(value); }\nmodule.exports = { run };\n", + ) + .unwrap(); + let h = emit_open_redirect_harness(&make_redirect_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + h.source.contains("function nyxFollowLocation(location)"), + "fallback path must still declare nyxFollowLocation: {}", + h.source + ); + assert!( + h.source.contains("nyxRedirectProbe(location, requestHost);\nnyxFollowLocation(location);"), + "fallback path must follow the synthetic location after the probe: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 7e7df7f8..1fc2be47 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -1350,6 +1350,7 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { if ($captured !== null) {{ [$location, $requestHost] = $captured; _nyx_redirect_probe($location, $requestHost); + _nyx_follow_location($location); echo "__NYX_SINK_HIT__\n"; echo json_encode(['location' => $location, 'request_host' => $requestHost]) . "\n"; return; @@ -1384,6 +1385,25 @@ function _nyx_redirect_probe(string $location, string $requestHost): void {{ @file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND); }} +// 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. +function _nyx_follow_location(string $location): void {{ + if ($location === '') return; + $lower = strtolower($location); + if (!(str_starts_with($lower, 'http://127.0.0.1') + || str_starts_with($lower, 'http://localhost') + || str_starts_with($lower, 'http://host-gateway'))) {{ + return; + }} + $ctx = stream_context_create([ + 'http' => ['timeout' => 2, 'follow_location' => 0, 'ignore_errors' => true], + ]); + @file_get_contents($location, false, $ctx); +}} + {via_fixture}function _nyx_run(): void {{ $payload = (string) (getenv('NYX_PAYLOAD') ?: ''); {invoke_via_fixture}// Synthetic fallback — records the raw payload as the redirect @@ -1394,6 +1414,7 @@ function _nyx_redirect_probe(string $location, string $requestHost): void {{ $requestHost = 'example.com'; $location = $payload; _nyx_redirect_probe($location, $requestHost); + _nyx_follow_location($location); echo "__NYX_SINK_HIT__\n"; echo json_encode(['location' => $location, 'request_host' => $requestHost]) . "\n"; }} @@ -2416,4 +2437,49 @@ 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_php_test_follow_location"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.php"); + std::fs::write( + &entry, + " 2"), + "follow-location helper must pin the stream context timeout to 2 seconds: {}", + h.source + ); + assert!( + h.source.contains("str_starts_with($lower, 'http://127.0.0.1')") + && h.source.contains("str_starts_with($lower, 'http://localhost')") + && h.source.contains("str_starts_with($lower, 'http://host-gateway')"), + "follow-location helper must gate on loopback host prefixes: {}", + h.source + ); + assert!( + h.source.contains("_nyx_redirect_probe($location, $requestHost);\n _nyx_follow_location($location);"), + "tier-(a) must follow the captured Location after emitting the probe: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 34b76b18..12fd12c0 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -2090,6 +2090,7 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { if captured is not None: location, request_host = captured _nyx_redirect_probe(location, request_host) + _nyx_follow_location(location) print("__NYX_SINK_HIT__", flush=True) sys.stdout.write(json.dumps({"location": location, "request_host": request_host}) + "\n") sys.stdout.flush() @@ -2106,6 +2107,7 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { import os import sys import time +import urllib.request {probe} @@ -2128,11 +2130,35 @@ def _nyx_redirect_probe(location, request_host): __nyx_emit(rec) +# 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. +def _nyx_follow_location(location): + if not location: + return + lower = location.lower() + if not ( + lower.startswith("http://127.0.0.1") + or lower.startswith("http://localhost") + or lower.startswith("http://host-gateway") + ): + return + try: + with urllib.request.urlopen(location, timeout=2.0) as resp: + resp.read(1) + except Exception: + # best-effort OOB fetch + pass + + {via_fixture}def _nyx_run(): payload = os.environ.get("NYX_PAYLOAD", "") {invoke_via_fixture} request_host = "example.com" location = payload _nyx_redirect_probe(location, request_host) + _nyx_follow_location(location) print("__NYX_SINK_HIT__", flush=True) sys.stdout.write(json.dumps({{"location": location, "request_host": request_host}}) + "\n") sys.stdout.flush() @@ -3397,4 +3423,72 @@ 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_py_test_follow_location"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.py"); + std::fs::write( + &entry, + "from flask import redirect\ndef run(value):\n return redirect(value)\n", + ) + .unwrap(); + let h = emit_open_redirect_harness(&make_redirect_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + h.source.contains("def _nyx_follow_location(location):"), + "OPEN_REDIRECT harness must declare the _nyx_follow_location helper: {}", + h.source + ); + assert!( + h.source.contains("import urllib.request"), + "OPEN_REDIRECT harness must import urllib.request for the loopback follow: {}", + h.source + ); + assert!( + h.source.contains("urllib.request.urlopen(location, timeout=2.0)"), + "follow-location helper must call urllib.request.urlopen with a 2-second timeout: {}", + h.source + ); + assert!( + h.source.contains("startswith(\"http://127.0.0.1\")") + && h.source.contains("startswith(\"http://localhost\")") + && h.source.contains("startswith(\"http://host-gateway\")"), + "follow-location helper must gate on loopback host prefixes: {}", + h.source + ); + 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_py_test_follow_captured"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.py"); + std::fs::write( + &entry, + "from flask import redirect\ndef run(value):\n return redirect(value)\n", + ) + .unwrap(); + let h = emit_open_redirect_harness(&make_redirect_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + h.source.contains("_nyx_redirect_probe(location, request_host)\n _nyx_follow_location(location)"), + "tier-(a) must follow the captured Location after emitting the probe: {}", + h.source + ); + assert!( + h.source.contains("_nyx_redirect_probe(location, request_host)\n _nyx_follow_location(location)"), + "tier-(b) fallback must also follow the synthetic location after the probe: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 462f88bf..2b0809d0 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -1319,6 +1319,7 @@ end if captured location, request_host = captured _nyx_redirect_probe(location, request_host) + _nyx_follow_location(location) STDOUT.puts '__NYX_SINK_HIT__' STDOUT.puts JSON.generate({ 'location' => location, 'request_host' => request_host }) STDOUT.flush @@ -1331,6 +1332,8 @@ end let body = format!( r#"# Nyx dynamic harness — OPEN_REDIRECT Rack::Response#redirect (Phase 09 / Track J.7). require 'json' +require 'net/http' +require 'uri' {shim} @@ -1350,11 +1353,36 @@ def _nyx_redirect_probe(location, request_host) File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }} end +# 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. +def _nyx_follow_location(location) + return if location.nil? || location.empty? + lower = location.to_s.downcase + unless lower.start_with?('http://127.0.0.1') || + lower.start_with?('http://localhost') || + lower.start_with?('http://host-gateway') + return + end + begin + uri = URI(location) + Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) do |http| + req = Net::HTTP::Get.new(uri.request_uri) + http.request(req) {{ |resp| resp.read_body {{ |_chunk| break }} }} + end + rescue StandardError + # best-effort OOB fetch + end +end + {via_fixture}def _nyx_run payload = ENV['NYX_PAYLOAD'] || '' {invoke_via_fixture} request_host = 'example.com' location = payload _nyx_redirect_probe(location, request_host) + _nyx_follow_location(location) STDOUT.puts '__NYX_SINK_HIT__' STDOUT.puts JSON.generate({{ 'location' => location, 'request_host' => request_host }}) STDOUT.flush @@ -2067,4 +2095,50 @@ 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_rb_test_follow_location"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.rb"); + std::fs::write( + &entry, + "require 'rack'\n\ + def run(value)\n r = Rack::Response.new\n r.redirect(value)\n r\nend\n", + ) + .unwrap(); + let h = emit_open_redirect_harness(&make_redirect_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + h.source.contains("def _nyx_follow_location(location)"), + "OPEN_REDIRECT harness must declare the _nyx_follow_location helper: {}", + h.source + ); + assert!( + h.source.contains("require 'net/http'") && h.source.contains("require 'uri'"), + "OPEN_REDIRECT harness must require net/http and uri for the loopback follow: {}", + h.source + ); + assert!( + h.source.contains("Net::HTTP.start(uri.host, uri.port"), + "follow-location helper must invoke Net::HTTP.start: {}", + h.source + ); + assert!( + h.source.contains("start_with?('http://127.0.0.1')") + && h.source.contains("start_with?('http://localhost')") + && h.source.contains("start_with?('http://host-gateway')"), + "follow-location helper must gate on loopback host prefixes: {}", + h.source + ); + assert!( + h.source.contains("_nyx_redirect_probe(location, request_host)\n _nyx_follow_location(location)"), + "tier-(a) must follow the captured Location after emitting the probe: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/tests/open_redirect_corpus.rs b/tests/open_redirect_corpus.rs index 26d111ec..5892ebb5 100644 --- a/tests/open_redirect_corpus.rs +++ b/tests/open_redirect_corpus.rs @@ -711,4 +711,36 @@ mod e2e_phase_09 { }; assert_oob_recorded(&outcome, "open-redirect-java-oob-nonce"); } + + #[test] + fn python_open_redirect_oob_loopback_records_callback() { + let Some(outcome) = run_oob(Lang::Python, "vuln.py", "run") else { + return; + }; + assert_oob_recorded(&outcome, "open-redirect-python-oob-nonce"); + } + + #[test] + fn js_open_redirect_oob_loopback_records_callback() { + let Some(outcome) = run_oob(Lang::JavaScript, "vuln.js", "run") else { + return; + }; + assert_oob_recorded(&outcome, "open-redirect-js-oob-nonce"); + } + + #[test] + fn ruby_open_redirect_oob_loopback_records_callback() { + let Some(outcome) = run_oob(Lang::Ruby, "vuln.rb", "run") else { + return; + }; + assert_oob_recorded(&outcome, "open-redirect-ruby-oob-nonce"); + } + + #[test] + fn php_open_redirect_oob_loopback_records_callback() { + let Some(outcome) = run_oob(Lang::Php, "vuln.php", "run") else { + return; + }; + assert_oob_recorded(&outcome, "open-redirect-php-oob-nonce"); + } }