mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0027 (20260521T201327Z-3848)
This commit is contained in:
parent
0e1365455f
commit
824859008e
9 changed files with 478 additions and 2 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"<?php\nuse Symfony\\Component\\HttpFoundation\\RedirectResponse;\nfunction run($v) { return new RedirectResponse($v); }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let h = emit_open_redirect_harness(&make_redirect_spec(
|
||||
entry.to_str().unwrap(),
|
||||
"run",
|
||||
));
|
||||
assert!(
|
||||
h.source.contains("function _nyx_follow_location(string $location): void"),
|
||||
"OPEN_REDIRECT harness must declare the _nyx_follow_location helper: {}",
|
||||
h.source
|
||||
);
|
||||
assert!(
|
||||
h.source.contains("file_get_contents($location, false, $ctx)"),
|
||||
"follow-location helper must call file_get_contents with a stream context: {}",
|
||||
h.source
|
||||
);
|
||||
assert!(
|
||||
h.source.contains("'timeout' => 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue