[pitboss] phase 09: Track J.7 + Track L.7 — OPEN_REDIRECT corpus + redirect-aware adapters

This commit is contained in:
pitboss 2026-05-18 02:32:13 -05:00
parent 5697763f28
commit b881af5d93
47 changed files with 2592 additions and 32 deletions

View file

@ -52,6 +52,7 @@ mod deserialize;
mod fmt_string;
mod header_injection;
mod ldap;
mod open_redirect;
mod path_trav;
mod sqli;
mod ssrf;
@ -94,7 +95,8 @@ pub use crate::dynamic::oracle::Oracle;
/// | 10 | 2026-05-17 | Phase 06 / Track J.4: `LDAP_INJECTION` cap lit for Java / Python / PHP; `ProbeKind::Ldap` + `ProbePredicate::LdapResultCountGreaterThan`; `StubKind::Ldap` + in-sandbox LDAP server stub |
/// | 11 | 2026-05-17 | Phase 07 / Track J.5: `XPATH_INJECTION` cap lit for Java / Python / PHP / JS; `ProbeKind::Xpath`; `LdapResultCountGreaterThan` renamed to `QueryResultCountGreaterThan` (shared by LDAP + XPath); `xpath_corpus.xml` staged in workdir |
/// | 12 | 2026-05-18 | Phase 08 / Track J.6: `HEADER_INJECTION` cap lit for Java / Python / PHP / Ruby / JS / Go / Rust; `ProbeKind::HeaderEmit` + `ProbePredicate::HeaderInjected`; per-lang `setHeader` shims |
pub const CORPUS_VERSION: u32 = 12;
/// | 13 | 2026-05-18 | Phase 09 / Track J.7: `OPEN_REDIRECT` cap lit for Java / Python / PHP / Ruby / JS / Go / Rust; `ProbeKind::Redirect` + `ProbePredicate::RedirectHostNotIn`; per-lang `sendRedirect` / `redirect()` shims |
pub const CORPUS_VERSION: u32 = 13;
/// Where a payload originated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View file

@ -0,0 +1,54 @@
//! Go `Cap::OPEN_REDIRECT` payloads — `gin.Context.Redirect` /
//! `http.Redirect` off-origin redirect.
//!
//! Vuln payload: an absolute attacker URL spliced into
//! `c.Redirect(http.StatusFound, value)` (or
//! `http.Redirect(w, r, value, ...)`) without host validation; the
//! harness records a `ProbeKind::Redirect` probe whose `location`
//! points off-origin.
//!
//! 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.
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
const ALLOWLIST: &[&str] = &["example.com"];
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"https://attacker.test/",
label: "open-redirect-go-absolute",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/go/vuln.go"],
oob_nonce_slot: false,
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
benign_control: Some(PayloadRef {
label: "open-redirect-go-benign",
}),
no_benign_control_rationale: None,
},
CuratedPayload {
bytes: b"/dashboard",
label: "open-redirect-go-benign",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/go/benign.go"],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: None,
},
];

View file

@ -0,0 +1,59 @@
//! Java `Cap::OPEN_REDIRECT` payloads —
//! `HttpServletResponse.sendRedirect` off-origin redirect.
//!
//! Vuln payload: a fully-qualified attacker URL
//! (`https://attacker.test/`). Spliced into the host's
//! `response.sendRedirect(value)` call without host validation, the
//! servlet response's `Location:` header points off-origin. The
//! harness's instrumented `sendRedirect` shim records a
//! `ProbeKind::Redirect { location: <raw>, request_host:
//! "example.com" }` probe; the predicate
//! [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`] sees
//! the off-allowlist host and fires.
//!
//! Benign control: same logical entry point, but the harness's benign
//! code path redirects to the relative path `/dashboard` (no host
//! component). The captured `location` has no off-origin authority
//! and the predicate stays clear.
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
const ALLOWLIST: &[&str] = &["example.com"];
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"https://attacker.test/",
label: "open-redirect-java-absolute",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/java/Vuln.java"],
oob_nonce_slot: false,
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
benign_control: Some(PayloadRef {
label: "open-redirect-java-benign",
}),
no_benign_control_rationale: None,
},
CuratedPayload {
bytes: b"/dashboard",
label: "open-redirect-java-benign",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/java/Benign.java"],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: None,
},
];

View file

@ -0,0 +1,53 @@
//! JavaScript `Cap::OPEN_REDIRECT` payloads —
//! Express `res.redirect` off-origin redirect.
//!
//! Vuln payload: an absolute attacker URL spliced into
//! `res.redirect(value)` without host validation; the harness
//! records a `ProbeKind::Redirect` probe whose `location` points
//! off-origin.
//!
//! 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.
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
const ALLOWLIST: &[&str] = &["example.com"];
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"https://attacker.test/",
label: "open-redirect-js-absolute",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/js/vuln.js"],
oob_nonce_slot: false,
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
benign_control: Some(PayloadRef {
label: "open-redirect-js-benign",
}),
no_benign_control_rationale: None,
},
CuratedPayload {
bytes: b"/dashboard",
label: "open-redirect-js-benign",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/js/benign.js"],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: None,
},
];

View file

@ -0,0 +1,26 @@
//! Open-redirect (`Cap::OPEN_REDIRECT`) per-language payload slices.
//!
//! Phase 09 (Track J.7) carves open redirects across the seven HTTP
//! framework ecosystems Nyx supports: Java
//! (`HttpServletResponse.sendRedirect`), Python (`flask.redirect`),
//! PHP (Symfony `Response::redirect` / Slim `Response::withHeader`),
//! Ruby (`Rack::Response#redirect`), JavaScript (Express
//! `res.redirect`), Go (`gin.Context.Redirect`), Rust (`axum::response::
//! Redirect::to`). Every vuln payload binds an absolute attacker URL
//! (`https://attacker.test/`) into the response writer's redirect
//! entry point; the paired benign control redirects to a same-origin
//! path (`/dashboard`). The harness's instrumented redirect shim
//! records a [`crate::dynamic::probe::ProbeKind::Redirect { location,
//! request_host }`] probe with the unmodified location and the
//! request's origin host, and the
//! [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`]
//! predicate fires when the captured `location` resolves off-origin
//! relative to `allowlist {request_host}`.
pub mod go;
pub mod java;
pub mod js;
pub mod php;
pub mod python;
pub mod ruby;
pub mod rust;

View file

@ -0,0 +1,55 @@
//! PHP `Cap::OPEN_REDIRECT` payloads — `Response::redirect` /
//! Symfony `RedirectResponse(...)` off-origin redirect.
//!
//! Vuln payload: an absolute attacker URL passed to
//! `header("Location: $value")` or
//! `new \Symfony\Component\HttpFoundation\RedirectResponse($value)`
//! without host validation. The harness records a
//! `ProbeKind::Redirect { location, request_host }` probe and the
//! predicate fires on the off-allowlist host.
//!
//! 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.
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
const ALLOWLIST: &[&str] = &["example.com"];
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"https://attacker.test/",
label: "open-redirect-php-absolute",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/php/vuln.php"],
oob_nonce_slot: false,
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
benign_control: Some(PayloadRef {
label: "open-redirect-php-benign",
}),
no_benign_control_rationale: None,
},
CuratedPayload {
bytes: b"/dashboard",
label: "open-redirect-php-benign",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/php/benign.php"],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: None,
},
];

View file

@ -0,0 +1,54 @@
//! Python `Cap::OPEN_REDIRECT` payloads — `flask.redirect`
//! off-origin redirect.
//!
//! Vuln payload: an attacker-controlled absolute URL spliced into
//! `flask.redirect(value)` without host validation; the captured
//! `Location:` header points off-origin and the
//! [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`]
//! predicate fires.
//!
//! Benign control: same shape but redirects to the relative path
//! `/dashboard`, so the captured location has no authority component
//! and the predicate stays clear.
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
const ALLOWLIST: &[&str] = &["example.com"];
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"https://attacker.test/",
label: "open-redirect-python-absolute",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/python/vuln.py"],
oob_nonce_slot: false,
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
benign_control: Some(PayloadRef {
label: "open-redirect-python-benign",
}),
no_benign_control_rationale: None,
},
CuratedPayload {
bytes: b"/dashboard",
label: "open-redirect-python-benign",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/python/benign.py"],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: None,
},
];

View file

@ -0,0 +1,53 @@
//! Ruby `Cap::OPEN_REDIRECT` payloads —
//! `Rack::Response#redirect` off-origin redirect.
//!
//! Vuln payload: an absolute attacker URL spliced into
//! `response.redirect(value)` without host validation; the harness
//! records a `ProbeKind::Redirect` probe whose `location` points
//! off-origin.
//!
//! 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.
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
const ALLOWLIST: &[&str] = &["example.com"];
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"https://attacker.test/",
label: "open-redirect-ruby-absolute",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/ruby/vuln.rb"],
oob_nonce_slot: false,
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
benign_control: Some(PayloadRef {
label: "open-redirect-ruby-benign",
}),
no_benign_control_rationale: None,
},
CuratedPayload {
bytes: b"/dashboard",
label: "open-redirect-ruby-benign",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/ruby/benign.rb"],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: None,
},
];

View file

@ -0,0 +1,53 @@
//! Rust `Cap::OPEN_REDIRECT` payloads — `axum::response::Redirect::to`
//! off-origin redirect.
//!
//! Vuln payload: an absolute attacker URL spliced into
//! `Redirect::to(value)` without host validation; the harness
//! records a `ProbeKind::Redirect` probe whose `location` points
//! off-origin.
//!
//! 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.
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
use crate::dynamic::oracle::ProbePredicate;
const ALLOWLIST: &[&str] = &["example.com"];
pub const PAYLOADS: &[CuratedPayload] = &[
CuratedPayload {
bytes: b"https://attacker.test/",
label: "open-redirect-rust-absolute",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: false,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/rust/vuln.rs"],
oob_nonce_slot: false,
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
benign_control: Some(PayloadRef {
label: "open-redirect-rust-benign",
}),
no_benign_control_rationale: None,
},
CuratedPayload {
bytes: b"/dashboard",
label: "open-redirect-rust-benign",
oracle: Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
},
is_benign: true,
provenance: PayloadProvenance::Curated,
since_corpus_version: 13,
deprecated_at_corpus_version: None,
fixture_paths: &["tests/dynamic_fixtures/open_redirect/rust/benign.rs"],
oob_nonce_slot: false,
probe_predicates: &[],
benign_control: None,
no_benign_control_rationale: None,
},
];

View file

@ -24,8 +24,8 @@ use std::collections::HashMap;
use std::sync::OnceLock;
use super::{
cmdi, deserialize, fmt_string, header_injection, ldap, path_trav, sqli, ssrf, ssti, xpath,
xss, xxe,
cmdi, deserialize, fmt_string, header_injection, ldap, open_redirect, path_trav, sqli, ssrf,
ssti, xpath, xss, xxe,
};
use super::{CapCorpus, CuratedPayload, Oracle};
use crate::dynamic::oracle::ProbePredicate;
@ -43,7 +43,6 @@ pub const CORPUS_UNSUPPORTED_LANG_NEUTRAL: u32 = Cap::ENV_VAR.bits()
| Cap::CRYPTO.bits()
| Cap::UNAUTHORIZED_ID.bits()
| Cap::DATA_EXFIL.bits()
| Cap::OPEN_REDIRECT.bits()
| Cap::PROTOTYPE_POLLUTION.bits();
/// Flat `(Cap, Lang, slice)` table. A single cap can carry per-language
@ -83,6 +82,13 @@ const ENTRIES: &[(Cap, Lang, &[CuratedPayload])] = &[
(Cap::HEADER_INJECTION, Lang::JavaScript, header_injection::js::PAYLOADS),
(Cap::HEADER_INJECTION, Lang::Go, header_injection::go::PAYLOADS),
(Cap::HEADER_INJECTION, Lang::Rust, header_injection::rust::PAYLOADS),
(Cap::OPEN_REDIRECT, Lang::Java, open_redirect::java::PAYLOADS),
(Cap::OPEN_REDIRECT, Lang::Python, open_redirect::python::PAYLOADS),
(Cap::OPEN_REDIRECT, Lang::Php, open_redirect::php::PAYLOADS),
(Cap::OPEN_REDIRECT, Lang::Ruby, open_redirect::ruby::PAYLOADS),
(Cap::OPEN_REDIRECT, Lang::JavaScript, open_redirect::js::PAYLOADS),
(Cap::OPEN_REDIRECT, Lang::Go, open_redirect::go::PAYLOADS),
(Cap::OPEN_REDIRECT, Lang::Rust, open_redirect::rust::PAYLOADS),
];
/// Reserved for per-cap oracle defaults. Empty in Phase 02; populated by
@ -295,6 +301,7 @@ mod tests {
assert!(!payloads_for(Cap::LDAP_INJECTION).is_empty());
assert!(!payloads_for(Cap::XPATH_INJECTION).is_empty());
assert!(!payloads_for(Cap::HEADER_INJECTION).is_empty());
assert!(!payloads_for(Cap::OPEN_REDIRECT).is_empty());
}
#[test]
@ -307,7 +314,6 @@ mod tests {
Cap::CRYPTO,
Cap::UNAUTHORIZED_ID,
Cap::DATA_EXFIL,
Cap::OPEN_REDIRECT,
Cap::PROTOTYPE_POLLUTION,
];
for cap in unsupported {
@ -342,6 +348,7 @@ mod tests {
Cap::LDAP_INJECTION,
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
Cap::OPEN_REDIRECT,
] {
let has_vuln = payloads_for(cap).iter().any(|p| !p.is_benign);
assert!(has_vuln, "{cap:?} must have at least one vuln payload");
@ -394,6 +401,7 @@ mod tests {
Cap::LDAP_INJECTION,
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
Cap::OPEN_REDIRECT,
];
for cap in caps {
for p in payloads_for(cap) {
@ -421,6 +429,7 @@ mod tests {
Cap::LDAP_INJECTION,
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
Cap::OPEN_REDIRECT,
];
for cap in caps {
for p in payloads_for(cap) {
@ -535,6 +544,7 @@ mod tests {
Cap::LDAP_INJECTION,
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
Cap::OPEN_REDIRECT,
];
for cap in caps {
for p in payloads_for(cap).iter().filter(|p| p.is_benign) {

View file

@ -28,6 +28,13 @@ pub mod php_twig;
pub mod php_unserialize;
pub mod python_jinja2;
pub mod python_pickle;
pub mod redirect_go;
pub mod redirect_java;
pub mod redirect_js;
pub mod redirect_php;
pub mod redirect_python;
pub mod redirect_ruby;
pub mod redirect_rust;
pub mod ruby_erb;
pub mod ruby_marshal;
pub mod xpath_java;
@ -57,6 +64,13 @@ pub use php_twig::PhpTwigAdapter;
pub use php_unserialize::PhpUnserializeAdapter;
pub use python_jinja2::PythonJinja2Adapter;
pub use python_pickle::PythonPickleAdapter;
pub use redirect_go::RedirectGoAdapter;
pub use redirect_java::RedirectJavaAdapter;
pub use redirect_js::RedirectJsAdapter;
pub use redirect_php::RedirectPhpAdapter;
pub use redirect_python::RedirectPythonAdapter;
pub use redirect_ruby::RedirectRubyAdapter;
pub use redirect_rust::RedirectRustAdapter;
pub use ruby_erb::RubyErbAdapter;
pub use ruby_marshal::RubyMarshalAdapter;
pub use xpath_java::XpathJavaAdapter;

View file

@ -0,0 +1,104 @@
//! Go [`super::super::FrameworkAdapter`] matching HTTP-redirect sink
//! constructions (`http.Redirect`, `gin.Context.Redirect`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one of
//! the canonical Go HTTP redirect entry points and the surrounding
//! source imports `net/http` or the gin framework.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectGoAdapter;
const ADAPTER_NAME: &str = "redirect-go";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(last, "Redirect" | "Redirect302" | "Redirect301")
}
fn source_imports_go_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"net/http",
b"github.com/gin-gonic/gin",
b"github.com/labstack/echo",
b"github.com/gofiber/fiber",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectGoAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Go
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_go_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_go(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_gin_redirect() {
let src: &[u8] = b"package vuln\n\nimport (\n\t\"net/http\"\n\t\"github.com/gin-gonic/gin\"\n)\n\
func Run(c *gin.Context, v string) {\n\tc.Redirect(http.StatusFound, v)\n}\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Run".into(),
callees: vec![crate::summary::CalleeSite::bare("Redirect")],
..Default::default()
};
assert!(RedirectGoAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"package vuln\n\nfunc Add(a, b int) int { return a + b }\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Add".into(),
..Default::default()
};
assert!(RedirectGoAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,106 @@
//! Java [`super::super::FrameworkAdapter`] matching HTTP-redirect
//! sink constructions (`HttpServletResponse.sendRedirect`,
//! Spring `ResponseEntity` 302 builders).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical servlet redirect entry points and the
//! surrounding source imports a servlet API.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectJavaAdapter;
const ADAPTER_NAME: &str = "redirect-java";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(last, "sendRedirect" | "redirect")
}
fn source_imports_servlet(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"javax.servlet",
b"jakarta.servlet",
b"HttpServletResponse",
b"org.springframework.http",
b"org.springframework.web.servlet",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectJavaAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Java
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_servlet(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_java(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_send_redirect() {
let src: &[u8] = b"import javax.servlet.http.HttpServletResponse;\n\
class C { void run(HttpServletResponse r, String v) { r.sendRedirect(v); } }\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("sendRedirect")],
..Default::default()
};
assert!(RedirectJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"class C { int add(int a, int b) { return a + b; } }\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,111 @@
//! JavaScript [`super::super::FrameworkAdapter`] matching
//! HTTP-redirect sink constructions (Express `res.redirect`,
//! Koa `ctx.redirect`, raw Node `res.writeHead(302, { Location })`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical Node redirect entry points and the surrounding
//! source imports the matching framework module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectJsAdapter;
const ADAPTER_NAME: &str = "redirect-js";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(last, "redirect" | "writeHead")
}
fn source_imports_node_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"require('express')",
b"require(\"express\")",
b"from 'express'",
b"from \"express\"",
b"require('koa')",
b"require(\"koa\")",
b"require('http')",
b"require(\"http\")",
b"res.redirect",
b"ctx.redirect",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectJsAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::JavaScript
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_node_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_express_redirect() {
let src: &[u8] = b"const express = require('express');\n\
function run(req, res, v) { res.redirect(v); }\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("redirect")],
..Default::default()
};
assert!(RedirectJsAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"function add(a, b) { return a + b; }\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectJsAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,111 @@
//! PHP [`super::super::FrameworkAdapter`] matching HTTP-redirect
//! sink constructions (`header("Location: ...")`,
//! Symfony `RedirectResponse`, Slim `Response::withHeader`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical PHP redirect entry points and the surrounding
//! source imports a recognised framework / writes a `Location:`
//! header.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectPhpAdapter;
const ADAPTER_NAME: &str = "redirect-php";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
matches!(
last,
"redirect" | "withRedirect" | "RedirectResponse" | "header"
)
}
fn source_imports_php_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"Symfony\\Component\\HttpFoundation",
b"Slim\\Psr7",
b"Psr\\Http\\Message",
b"Location:",
b"RedirectResponse",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectPhpAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Php
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_php_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_php(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_header_location() {
let src: &[u8] =
b"<?php\nfunction run($v) { header(\"Location: \" . $v); exit; }\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("header")],
..Default::default()
};
assert!(RedirectPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"<?php\nfunction add($a, $b) { return $a + $b; }\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,111 @@
//! Python [`super::super::FrameworkAdapter`] matching HTTP-redirect
//! sink constructions (`flask.redirect`, Django
//! `HttpResponseRedirect`, FastAPI `RedirectResponse`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical Python web-framework redirect entry points and
//! the surrounding source imports the matching framework module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectPythonAdapter;
const ADAPTER_NAME: &str = "redirect-python";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"redirect" | "HttpResponseRedirect" | "RedirectResponse"
)
}
fn source_imports_python_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"from flask",
b"import flask",
b"from django.http",
b"from django.shortcuts",
b"from starlette",
b"from fastapi.responses",
b"from werkzeug",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectPythonAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Python
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_python_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_flask_redirect() {
let src: &[u8] = b"from flask import redirect\n\
def run(value):\n return redirect(value)\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("redirect")],
..Default::default()
};
assert!(RedirectPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"def add(a, b):\n return a + b\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,109 @@
//! Ruby [`super::super::FrameworkAdapter`] matching HTTP-redirect
//! sink constructions (Rails `redirect_to`, Sinatra `redirect`,
//! `Rack::Response#redirect`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical Ruby web-framework redirect entry points and
//! the surrounding source imports / references a recognised
//! framework module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectRubyAdapter;
const ADAPTER_NAME: &str = "redirect-ruby";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(last, "redirect" | "redirect_to" | "redirect!" )
}
fn source_imports_ruby_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"Rack::Response",
b"require 'rack",
b"require \"rack",
b"require 'sinatra",
b"require \"sinatra",
b"ActionController",
b"Rails",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectRubyAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Ruby
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_ruby_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_ruby(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_rack_redirect() {
let src: &[u8] = b"require 'rack'\n\
def run(value)\n resp = Rack::Response.new\n resp.redirect(value)\n resp\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("redirect")],
..Default::default()
};
assert!(RedirectRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"def add(a, b)\n a + b\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,110 @@
//! Rust [`super::super::FrameworkAdapter`] matching HTTP-redirect
//! sink constructions (`axum::response::Redirect::to`, actix-web
//! `HttpResponse::Found().append_header(("Location", v))`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical Rust web-framework redirect entry points and the
//! surrounding source imports the matching framework module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectRustAdapter;
const ADAPTER_NAME: &str = "redirect-rust";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
matches!(last, "to" | "redirect" | "temporary" | "permanent" | "Found")
}
fn source_imports_rust_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"use axum::",
b"axum::response::Redirect",
b"use actix_web::",
b"use rocket::",
b"use warp::",
b"Redirect::to",
b"Redirect::permanent",
b"Redirect::temporary",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectRustAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Rust
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_rust_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_rust(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_axum_redirect_to() {
let src: &[u8] =
b"use axum::response::Redirect;\n\nfn run(v: String) -> Redirect { Redirect::to(&v) }\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("to")],
..Default::default()
};
assert!(RedirectRustAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"fn add(a: i32, b: i32) -> i32 { a + b }\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectRustAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -214,20 +214,20 @@ mod tests {
}
#[test]
fn registry_baseline_after_phase_08() {
// Phase 08 (Track J.6) adds the header-injection adapter for
// every language carrying the HEADER_INJECTION corpus: Java /
fn registry_baseline_after_phase_09() {
// Phase 09 (Track J.7) adds the open-redirect adapter for
// every language carrying the OPEN_REDIRECT corpus: Java /
// Python / PHP / Ruby / JavaScript / Go / Rust. Java /
// Python / PHP each grow from 5 → 6; Ruby from 3 → 4;
// JavaScript from 2 → 3; Go from 1 → 2; Rust from 0 → 1.
// Python / PHP each grow from 6 → 7; Ruby from 4 → 5;
// JavaScript from 3 → 4; Go from 2 → 3; Rust from 1 → 2.
// C / Cpp / TypeScript still carry the Phase-01 empty
// baseline.
for lang in [Lang::Java, Lang::Python, Lang::Php] {
let registered = registry::adapters_for(lang);
assert_eq!(
registered.len(),
6,
"{:?} must have the J.1+J.2+J.3+J.4+J.5+J.6 adapters",
7,
"{:?} must have the J.1+J.2+J.3+J.4+J.5+J.6+J.7 adapters",
lang,
);
for adapter in registered {
@ -237,8 +237,8 @@ mod tests {
let ruby_registered = registry::adapters_for(Lang::Ruby);
assert_eq!(
ruby_registered.len(),
4,
"Ruby must have the J.1 + J.2 + J.3 + J.6 header adapters",
5,
"Ruby must have the J.1 + J.2 + J.3 + J.6 + J.7 adapters",
);
for adapter in ruby_registered {
assert_eq!(adapter.lang(), Lang::Ruby);
@ -246,8 +246,8 @@ mod tests {
let js_registered = registry::adapters_for(Lang::JavaScript);
assert_eq!(
js_registered.len(),
3,
"JavaScript must have J.2 Handlebars + J.5 xpath-js + J.6 header-js",
4,
"JavaScript must have J.2 + J.5 + J.6 + J.7 adapters",
);
for adapter in js_registered {
assert_eq!(adapter.lang(), Lang::JavaScript);
@ -255,8 +255,8 @@ mod tests {
let go_registered = registry::adapters_for(Lang::Go);
assert_eq!(
go_registered.len(),
2,
"Go must have J.3 xxe-go + J.6 header-go",
3,
"Go must have J.3 + J.6 + J.7 adapters",
);
for adapter in go_registered {
assert_eq!(adapter.lang(), Lang::Go);
@ -264,10 +264,12 @@ mod tests {
let rust_registered = registry::adapters_for(Lang::Rust);
assert_eq!(
rust_registered.len(),
1,
"Rust must have exactly the J.6 header-rust adapter",
2,
"Rust must have the J.6 + J.7 adapters",
);
assert_eq!(rust_registered[0].lang(), Lang::Rust);
for adapter in rust_registered {
assert_eq!(adapter.lang(), Lang::Rust);
}
for lang in [Lang::C, Lang::Cpp, Lang::TypeScript] {
assert!(
registry::adapters_for(lang).is_empty(),

View file

@ -44,7 +44,10 @@ pub fn adapters_for(lang: Lang) -> &'static [&'static dyn FrameworkAdapter] {
// listed in alphabetical order of [`FrameworkAdapter::name`] so a
// later phase that appends a new adapter cannot silently re-order
// the existing first-match.
static RUST: &[&dyn FrameworkAdapter] = &[&super::adapters::HeaderRustAdapter];
static RUST: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderRustAdapter,
&super::adapters::RedirectRustAdapter,
];
static C: &[&dyn FrameworkAdapter] = &[];
static CPP: &[&dyn FrameworkAdapter] = &[];
static JAVA: &[&dyn FrameworkAdapter] = &[
@ -52,11 +55,13 @@ static JAVA: &[&dyn FrameworkAdapter] = &[
&super::adapters::JavaDeserializeAdapter,
&super::adapters::JavaThymeleafAdapter,
&super::adapters::LdapSpringAdapter,
&super::adapters::RedirectJavaAdapter,
&super::adapters::XpathJavaAdapter,
&super::adapters::XxeJavaAdapter,
];
static GO: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderGoAdapter,
&super::adapters::RedirectGoAdapter,
&super::adapters::XxeGoAdapter,
];
static PHP: &[&dyn FrameworkAdapter] = &[
@ -64,6 +69,7 @@ static PHP: &[&dyn FrameworkAdapter] = &[
&super::adapters::LdapPhpAdapter,
&super::adapters::PhpTwigAdapter,
&super::adapters::PhpUnserializeAdapter,
&super::adapters::RedirectPhpAdapter,
&super::adapters::XpathPhpAdapter,
&super::adapters::XxePhpAdapter,
];
@ -72,11 +78,13 @@ static PYTHON: &[&dyn FrameworkAdapter] = &[
&super::adapters::LdapPythonAdapter,
&super::adapters::PythonJinja2Adapter,
&super::adapters::PythonPickleAdapter,
&super::adapters::RedirectPythonAdapter,
&super::adapters::XpathPythonAdapter,
&super::adapters::XxePythonAdapter,
];
static RUBY: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderRubyAdapter,
&super::adapters::RedirectRubyAdapter,
&super::adapters::RubyErbAdapter,
&super::adapters::RubyMarshalAdapter,
&super::adapters::XxeRubyAdapter,
@ -85,5 +93,6 @@ static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[];
static JAVASCRIPT: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderJsAdapter,
&super::adapters::JsHandlebarsAdapter,
&super::adapters::RedirectJsAdapter,
&super::adapters::XpathJsAdapter,
];

View file

@ -513,6 +513,14 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
return Ok(emit_header_injection_harness(spec));
}
// Phase 09 (Track J.7): OPEN_REDIRECT-sink short-circuit. The Go
// harness models `c.Redirect(http.StatusFound, value)` (and
// `http.Redirect`) and records the bound `Location:` value via a
// `ProbeKind::Redirect` probe.
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
return Ok(emit_open_redirect_harness(spec));
}
let entry_source = read_entry_source(&spec.entry_file);
let shape = GoShape::detect(spec, &entry_source);
let main_go = generate_main_go(spec, shape);
@ -680,6 +688,66 @@ func main() {{
}
}
/// Phase 09 — Track J.7 open-redirect harness for Go (`gin.Context.Redirect`
/// / `http.Redirect`).
///
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented redirect shim
/// that records the bound `Location:` value plus the request's
/// origin host via a `ProbeKind::Redirect` probe.
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
let shim = probe_shim();
let go_mod = generate_go_mod();
let source = format!(
r##"// Nyx dynamic harness — OPEN_REDIRECT c.Redirect (Phase 09 / Track J.7).
package main
import (
"encoding/json"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
{shim}
func nyxRedirectProbe(location, requestHost string) {{
__nyx_emit(map[string]interface{{}}{{
"sink_callee": "gin.Context.Redirect",
"args": []map[string]interface{{}}{{
{{"kind": "String", "value": location}},
}},
"captured_at_ns": uint64(time.Now().UnixNano()),
"payload_id": os.Getenv("NYX_PAYLOAD_ID"),
"kind": map[string]interface{{}}{{"kind": "Redirect", "location": location, "request_host": requestHost}},
"witness": __nyx_witness("gin.Context.Redirect", []string{{location}}),
}})
}}
func main() {{
__nyx_install_crash_guard("gin.Context.Redirect")
defer __nyx_recover_crash("gin.Context.Redirect")()
payload := os.Getenv("NYX_PAYLOAD")
requestHost := "example.com"
location := payload
nyxRedirectProbe(location, requestHost)
fmt.Println("__NYX_SINK_HIT__")
body, _ := json.Marshal(map[string]interface{{}}{{"location": location, "request_host": requestHost}})
fmt.Println(string(body))
}}
"##
);
HarnessSource {
source,
filename: "main.go".to_owned(),
command: vec!["./nyx_harness".to_owned()],
extra_files: vec![("go.mod".to_owned(), go_mod)],
entry_subpath: None,
}
}
fn generate_main_go(spec: &HarnessSpec, shape: GoShape) -> String {
let entry_fn = capitalize_first(&spec.entry_name);
let pre_call = pre_call_setup(spec);

View file

@ -570,6 +570,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
return Ok(emit_header_injection_harness(spec));
}
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
return Ok(emit_open_redirect_harness(spec));
}
let entry_source = read_entry_source(&spec.entry_file);
let shape = JavaShape::detect(spec, &entry_source);
@ -1293,6 +1296,85 @@ public class NyxHarness {{
}
}
/// Phase 09 — Track J.7 open-redirect harness for Java
/// (`HttpServletResponse.sendRedirect`).
///
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
/// `response.sendRedirect(value)` shim that records the *unmodified*
/// `Location:` value plus the request's origin host via a
/// `ProbeKind::Redirect` probe. Mirrors the synthetic-harness
/// pattern used by Phase 03 / 04 / 05 / 06 / 07 / 08.
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
let shim = probe_shim();
let source = format!(
r#"// Nyx dynamic harness — OPEN_REDIRECT HttpServletResponse.sendRedirect (Phase 09 / Track J.7).
import java.io.FileWriter;
import java.io.IOException;
public class NyxHarness {{
{shim}
static void nyxRedirectProbe(String location, String requestHost) {{
String p = System.getenv("NYX_PROBE_PATH");
if (p == null || p.isEmpty()) return;
long now = System.nanoTime();
String pid = System.getenv("NYX_PAYLOAD_ID");
if (pid == null) pid = "";
StringBuilder line = new StringBuilder(256);
line.append("{{\"sink_callee\":\"HttpServletResponse.sendRedirect\",\"args\":[");
line.append("{{\"kind\":\"String\",\"value\":\"");
nyxJsonEscape(location, line);
line.append("\"}}],");
line.append("\"captured_at_ns\":").append(now).append(',');
line.append("\"payload_id\":\"");
nyxJsonEscape(pid, line);
line.append("\",\"kind\":{{\"kind\":\"Redirect\",\"location\":\"");
nyxJsonEscape(location, line);
line.append("\",\"request_host\":\"");
nyxJsonEscape(requestHost, line);
line.append("\"}},");
line.append("\"witness\":");
line.append(nyxWitnessJson("HttpServletResponse.sendRedirect", new String[]{{location}}));
line.append("}}\n");
try (FileWriter fw = new FileWriter(p, true)) {{
fw.write(line.toString());
}} catch (IOException e) {{
// best-effort
}}
}}
public static void main(String[] args) {{
String payload = System.getenv("NYX_PAYLOAD");
if (payload == null) payload = "";
String requestHost = "example.com";
String location = payload;
nyxRedirectProbe(location, requestHost);
System.out.println("__NYX_SINK_HIT__");
StringBuilder body = new StringBuilder(64);
body.append("{{\"location\":\"");
nyxJsonEscape(location, body);
body.append("\",\"request_host\":\"");
nyxJsonEscape(requestHost, body);
body.append("\"}}");
System.out.println(body.toString());
}}
}}
"#
);
HarnessSource {
source,
filename: "NyxHarness.java".to_owned(),
command: vec![
"java".to_owned(),
"-cp".to_owned(),
".".to_owned(),
"NyxHarness".to_owned(),
],
extra_files: Vec::new(),
entry_subpath: None,
}
}
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
/// reading the entry file from disk. Exposed so test helpers can pin a
/// per-fixture shape without round-tripping through [`emit`].

View file

@ -457,6 +457,14 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result<HarnessSource, Un
return Ok(emit_header_injection_harness(spec));
}
// Phase 09 (Track J.7): OPEN_REDIRECT-sink short-circuit. The
// synthetic harness calls an instrumented `res.redirect` shim
// that records the bound `Location:` value via a
// `ProbeKind::Redirect` probe.
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
return Ok(emit_open_redirect_harness(spec));
}
let entry_source = read_entry_source(&spec.entry_file);
let shape = JsShape::detect(spec, &entry_source);
let entry_subpath = entry_subpath_for_shape(shape, is_typescript);
@ -670,6 +678,56 @@ console.log(JSON.stringify({{ name: name, value: value }}));
}
}
/// Phase 09 — Track J.7 open-redirect harness for Node (Express
/// `res.redirect`).
///
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
/// `res.redirect(value)` shim that records the bound `Location:`
/// value plus the request's origin host via a `ProbeKind::Redirect`
/// probe.
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
let shim = probe_shim();
let body = format!(
r#"// Nyx dynamic harness — OPEN_REDIRECT res.redirect (Phase 09 / Track J.7).
{shim}
function nyxRedirectProbe(location, requestHost) {{
const p = process.env.NYX_PROBE_PATH;
if (!p) return;
const rec = {{
sink_callee: 'res.redirect',
args: [
{{ kind: 'String', value: location }},
],
captured_at_ns: Number(process.hrtime.bigint()),
payload_id: process.env.NYX_PAYLOAD_ID || '',
kind: {{ kind: 'Redirect', location: location, request_host: requestHost }},
witness: __nyx_witness('res.redirect', [location]),
}};
try {{
require('fs').appendFileSync(p, JSON.stringify(rec) + '\n');
}} catch (e) {{
// best-effort
}}
}}
const payload = process.env.NYX_PAYLOAD || '';
const requestHost = 'example.com';
const location = payload;
nyxRedirectProbe(location, requestHost);
console.log('__NYX_SINK_HIT__');
console.log(JSON.stringify({{ location: location, request_host: requestHost }}));
"#
);
HarnessSource {
source: body,
filename: "harness.js".to_owned(),
command: vec!["node".to_owned(), "harness.js".to_owned()],
extra_files: Vec::new(),
entry_subpath: None,
}
}
/// Phase 26 — Node chain-step harness (shared between JS + TS emitters).
///
/// Splices the Node probe shim ([`probe_shim`]) in front of a minimal

View file

@ -436,6 +436,10 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
return Ok(emit_header_injection_harness(spec));
}
// Phase 09 (Track J.7): OPEN_REDIRECT-sink short-circuit.
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
return Ok(emit_open_redirect_harness(spec));
}
let entry_source = read_entry_source(&spec.entry_file);
let shape = PhpShape::detect(spec, &entry_source);
@ -921,6 +925,57 @@ echo json_encode(['name' => $name, 'value' => $value]) . "\n";
}
}
/// Phase 09 — Track J.7 open-redirect harness for PHP (`header("Location: …")` /
/// `Response::redirect`).
///
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented redirect shim
/// that records the bound `Location:` value plus the request's origin
/// host via a `ProbeKind::Redirect` probe. Mirrors the
/// synthetic-harness pattern used by Phase 03 / 04 / 05 / 06 / 07 / 08.
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
let shim = probe_shim();
let body = format!(
r#"<?php
// Nyx dynamic harness — OPEN_REDIRECT Response::redirect (Phase 09 / Track J.7).
{shim}
function _nyx_redirect_probe(string $location, string $requestHost): void {{
$p = getenv('NYX_PROBE_PATH');
if ($p === false || $p === '') return;
$rec = [
'sink_callee' => 'Response::redirect',
'args' => [
['kind' => 'String', 'value' => $location],
],
'captured_at_ns' => (int) hrtime(true),
'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''),
'kind' => [
'kind' => 'Redirect',
'location' => $location,
'request_host' => $requestHost,
],
'witness' => __nyx_witness('Response::redirect', [$location]),
];
@file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND);
}}
$payload = (string) (getenv('NYX_PAYLOAD') ?: '');
$requestHost = 'example.com';
$location = $payload;
_nyx_redirect_probe($location, $requestHost);
echo "__NYX_SINK_HIT__\n";
echo json_encode(['location' => $location, 'request_host' => $requestHost]) . "\n";
"#
);
HarnessSource {
source: body,
filename: "harness.php".to_owned(),
command: vec!["php".to_owned(), "harness.php".to_owned()],
extra_files: Vec::new(),
entry_subpath: None,
}
}
fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String {
let entry_fn = &spec.entry_name;
let pre_call = build_pre_call(spec, shape);

View file

@ -650,6 +650,16 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
return Ok(emit_header_injection_harness(spec));
}
// Phase 09 (Track J.7): short-circuit to the open-redirect harness
// when the spec's expected cap is OPEN_REDIRECT. The harness
// splices the payload into a synthetic `flask.redirect(value)`
// call and records the bound `Location:` value via a
// `ProbeKind::Redirect` probe consumed by the
// `RedirectHostNotIn` oracle.
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
return Ok(emit_open_redirect_harness(spec));
}
let entry_source = read_entry_source(&spec.entry_file);
let shape = PythonShape::detect(spec, &entry_source);
let body = generate_for_shape(spec, shape);
@ -1150,6 +1160,70 @@ def _nyx_run():
sys.stdout.flush()
if __name__ == "__main__":
_nyx_run()
"#
);
HarnessSource {
source: body,
filename: "harness.py".to_owned(),
command: vec!["python3".to_owned(), "harness.py".to_owned()],
extra_files: Vec::new(),
entry_subpath: None,
}
}
/// Phase 09 — Track J.7 open-redirect harness for Python
/// (`flask.redirect`).
///
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
/// `flask.redirect(value)` shim that records the bound `Location:`
/// value plus the request's origin host via a `ProbeKind::Redirect`
/// probe. A vuln payload binding `https://attacker.test/` trips the
/// [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`]
/// oracle; the paired benign control redirects to a same-origin
/// path and leaves the predicate clear.
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
let probe = probe_shim();
let body = format!(
r#"#!/usr/bin/env python3
"""Nyx dynamic harness — OPEN_REDIRECT flask.redirect (Phase 09 / Track J.7)."""
import json
import os
import sys
import time
{probe}
def _nyx_redirect_probe(location, request_host):
rec = {{
"sink_callee": "flask.redirect",
"args": [
{{"kind": "String", "value": location}},
],
"captured_at_ns": time.time_ns(),
"payload_id": os.environ.get("NYX_PAYLOAD_ID", ""),
"kind": {{
"kind": "Redirect",
"location": location,
"request_host": request_host,
}},
"witness": __nyx_witness("flask.redirect", [location]),
}}
__nyx_emit(rec)
def _nyx_run():
payload = os.environ.get("NYX_PAYLOAD", "")
request_host = "example.com"
location = payload
_nyx_redirect_probe(location, request_host)
print("__NYX_SINK_HIT__", flush=True)
sys.stdout.write(json.dumps({{"location": location, "request_host": request_host}}) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
_nyx_run()
"#

View file

@ -427,6 +427,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
return Ok(emit_header_injection_harness(spec));
}
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
return Ok(emit_open_redirect_harness(spec));
}
let entry_source = read_entry_source(&spec.entry_file);
let shape = RubyShape::detect(spec, &entry_source);
@ -670,6 +673,55 @@ STDOUT.flush
}
}
/// Phase 09 — Track J.7 open-redirect harness for Ruby
/// (`Rack::Response#redirect`).
///
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
/// `response.redirect(value)` shim that records the bound
/// `Location:` value plus the request's origin host via a
/// `ProbeKind::Redirect` probe.
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
let shim = probe_shim();
let body = format!(
r#"# Nyx dynamic harness — OPEN_REDIRECT Rack::Response#redirect (Phase 09 / Track J.7).
require 'json'
{shim}
def _nyx_redirect_probe(location, request_host)
p = ENV['NYX_PROBE_PATH']
return if p.nil? || p.empty?
rec = {{
'sink_callee' => 'Rack::Response#redirect',
'args' => [
{{ 'kind' => 'String', 'value' => location }},
],
'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond),
'payload_id' => ENV['NYX_PAYLOAD_ID'] || '',
'kind' => {{ 'kind' => 'Redirect', 'location' => location, 'request_host' => request_host }},
'witness' => __nyx_witness('Rack::Response#redirect', [location]),
}}
File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }}
end
payload = ENV['NYX_PAYLOAD'] || ''
request_host = 'example.com'
location = payload
_nyx_redirect_probe(location, request_host)
STDOUT.puts '__NYX_SINK_HIT__'
STDOUT.puts JSON.generate({{ 'location' => location, 'request_host' => request_host }})
STDOUT.flush
"#
);
HarnessSource {
source: body,
filename: "harness.rb".to_owned(),
command: vec!["ruby".to_owned(), "harness.rb".to_owned()],
extra_files: vec![],
entry_subpath: None,
}
}
fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String {
let entry_fn = &spec.entry_name;
let pre_call = build_pre_call(spec);

View file

@ -647,6 +647,93 @@ fn main() {{
}
}
/// Phase 09 — Track J.7 open-redirect harness for Rust
/// (`axum::response::Redirect::to`).
///
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
/// `Redirect::to(value)` shim that records the bound `Location:`
/// value plus the request's origin host via a `ProbeKind::Redirect`
/// probe. Std-only — no `Cargo.toml` dependencies beyond the
/// always-pinned `libc`.
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
let shim = probe_shim();
let cargo_toml = generate_cargo_toml(Cap::OPEN_REDIRECT);
let main_rs = format!(
r##"//! Nyx dynamic harness — OPEN_REDIRECT Redirect::to (Phase 09 / Track J.7).
use std::env;
use std::fs::OpenOptions;
use std::io::Write;
use std::time::{{SystemTime, UNIX_EPOCH}};
{shim}
fn nyx_json_escape(s: &str) -> String {{
let mut out = String::with_capacity(s.len() + 2);
for c in s.chars() {{
match c {{
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {{
out.push_str(&format!("\\u{{:04x}}", c as u32));
}}
c => out.push(c),
}}
}}
out
}}
fn nyx_redirect_probe(location: &str, request_host: &str) {{
let p = match env::var("NYX_PROBE_PATH") {{ Ok(s) => s, Err(_) => return }};
if p.is_empty() {{ return; }}
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
let pid = env::var("NYX_PAYLOAD_ID").unwrap_or_default();
let mut line = String::new();
line.push_str("{{\"sink_callee\":\"Redirect::to\",\"args\":[");
line.push_str("{{\"kind\":\"String\",\"value\":\"");
line.push_str(&nyx_json_escape(location));
line.push_str("\"}}],");
line.push_str("\"captured_at_ns\":");
line.push_str(&now.to_string());
line.push_str(",\"payload_id\":\"");
line.push_str(&nyx_json_escape(&pid));
line.push_str("\",\"kind\":{{\"kind\":\"Redirect\",\"location\":\"");
line.push_str(&nyx_json_escape(location));
line.push_str("\",\"request_host\":\"");
line.push_str(&nyx_json_escape(request_host));
line.push_str("\"}},\"witness\":{{}}}}\n");
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&p) {{
let _ = f.write_all(line.as_bytes());
}}
}}
fn main() {{
let payload = env::var("NYX_PAYLOAD").unwrap_or_default();
let request_host = "example.com";
let location = &payload;
nyx_redirect_probe(location, request_host);
println!("__NYX_SINK_HIT__");
let mut body = String::new();
body.push_str("{{\"location\":\"");
body.push_str(&nyx_json_escape(location));
body.push_str("\",\"request_host\":\"");
body.push_str(&nyx_json_escape(request_host));
body.push_str("\"}}");
println!("{{body}}", body = body);
}}
"##
);
HarnessSource {
source: main_rs,
filename: "src/main.rs".into(),
command: vec!["target/release/nyx_harness".into()],
extra_files: vec![("Cargo.toml".into(), cargo_toml)],
entry_subpath: Some("src/entry.rs".into()),
}
}
fn read_entry_source(entry_file: &str) -> String {
let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)];
for path in &candidates {
@ -667,6 +754,14 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
return Ok(emit_header_injection_harness(spec));
}
// Phase 09 (Track J.7): OPEN_REDIRECT-sink short-circuit. The
// Rust harness models an `axum`-style `Redirect::to(value)` shim
// that records the bound `Location:` value via a
// `ProbeKind::Redirect` probe.
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
return Ok(emit_open_redirect_harness(spec));
}
let shape = detect_shape(spec);
// Generic + LibfuzzerTarget accept Param(0)/EnvVar; richer shapes

View file

@ -265,6 +265,29 @@ pub enum ProbePredicate {
/// captured header whose value contains the CRLF pair.
header_name: &'static str,
},
/// Phase 09 (Track J.7): open-redirect predicate.
///
/// Fires when at least one drained probe carries
/// [`ProbeKind::Redirect`] whose extracted `location` host falls
/// outside `allowlist`. Same-origin redirects (the `location`
/// host equals `request_host`, or the location is a relative
/// path) never fire — they cannot leave the application origin
/// regardless of allowlist contents. Hosts are compared
/// case-insensitively against the allowlist entries; schemeless
/// `//host/...` references are parsed as off-origin.
///
/// Cross-cutting in the same sense as
/// [`Self::DeserializeGadgetInvoked`] /
/// [`Self::XxeEntityExpanded`] /
/// [`Self::HeaderInjected`] — evaluated across every drained
/// probe rather than against a single record.
RedirectHostNotIn {
/// Allowlist of origin hosts the application is willing to
/// redirect into (e.g. `&["example.com", "www.example.com"]`).
/// `request_host` is implicitly allowed even when absent
/// from this slice.
allowlist: &'static [&'static str],
},
/// Phase 06 (Track J.4) / Phase 07 (Track J.5): result-count
/// predicate shared by LDAP-filter and XPath-expression injection.
///
@ -444,6 +467,21 @@ pub fn oracle_fired_with_stubs(
if !header_injected_ok {
return false;
}
// Phase 09 (Track J.7): open-redirect cross-cutting
// predicates. Each `RedirectHostNotIn { allowlist }`
// consults the captured probe channel for a
// [`ProbeKind::Redirect`] record whose `location` host
// resolves off-origin relative to `allowlist
// {request_host}`.
let redirect_ok = cross.iter().all(|p| match p {
ProbePredicate::RedirectHostNotIn { allowlist } => {
probes_satisfy_redirect_off_origin(probes, allowlist)
}
_ => true,
});
if !redirect_ok {
return false;
}
// Phase 04 (Track J.2): SSTI render-equality cross-cutting
// predicates. Each `TemplateEvalEqual { expected }` consults
// the captured stdout body — see [`stdout_template_equals`].
@ -476,7 +514,8 @@ pub fn oracle_fired_with_stubs(
| ProbeKind::Xxe { .. }
| ProbeKind::Ldap { .. }
| ProbeKind::Xpath { .. }
| ProbeKind::HeaderEmit { .. } => false,
| ProbeKind::HeaderEmit { .. }
| ProbeKind::Redirect { .. } => false,
}),
Oracle::OutputContains(needle) => {
let nb = needle.as_bytes();
@ -504,6 +543,7 @@ fn is_cross_cutting(pred: &ProbePredicate) -> bool {
| ProbePredicate::XxeEntityExpanded { .. }
| ProbePredicate::QueryResultCountGreaterThan { .. }
| ProbePredicate::HeaderInjected { .. }
| ProbePredicate::RedirectHostNotIn { .. }
)
}
@ -532,6 +572,10 @@ fn cross_cutting_satisfied(pred: &ProbePredicate, stub_events: &[StubEvent]) ->
// rather than stub events; evaluated separately in
// [`probes_satisfy_header_injected`] below.
ProbePredicate::HeaderInjected { .. } => true,
// RedirectHostNotIn is cross-cutting against the *probe log*
// rather than stub events; evaluated separately in
// [`probes_satisfy_redirect_off_origin`] below.
ProbePredicate::RedirectHostNotIn { .. } => true,
_ => true,
}
}
@ -623,6 +667,86 @@ fn probes_satisfy_header_injected(probes: &[SinkProbe], header_name: &str) -> bo
})
}
/// True when at least one drained probe is a [`ProbeKind::Redirect`]
/// record whose extracted `location` host falls outside the
/// `allowlist {request_host}` set. Powers
/// [`ProbePredicate::RedirectHostNotIn`] (Phase 09 — Track J.7).
///
/// Same-origin redirects (relative path, or absolute URL whose host
/// equals `request_host`) never fire — they cannot leave the
/// application origin regardless of allowlist contents. Schemeless
/// `//host/...` references are parsed as off-origin.
fn probes_satisfy_redirect_off_origin(probes: &[SinkProbe], allowlist: &[&str]) -> bool {
probes.iter().any(|p| match &p.kind {
ProbeKind::Redirect { location, request_host } => {
redirect_is_off_origin(location, request_host, allowlist)
}
_ => false,
})
}
/// Returns `true` when `location` redirects to a host that is neither
/// `request_host` nor any entry of `allowlist`. Public for the
/// per-language harness shim's mirror tests; the predicate above is
/// the only production caller.
pub fn redirect_is_off_origin(
location: &str,
request_host: &str,
allowlist: &[&str],
) -> bool {
let Some(host) = extract_redirect_host(location) else {
// No host component (relative path) → same-origin → safe.
return false;
};
let host_lower = host.to_ascii_lowercase();
if !request_host.is_empty()
&& host_lower == request_host.trim().to_ascii_lowercase()
{
return false;
}
!allowlist
.iter()
.any(|h| host_lower == h.trim().to_ascii_lowercase())
}
/// Extract the host component from a `Location:` value. Returns
/// `None` for a relative path (no scheme, no leading `//`).
///
/// Recognises three shapes:
/// 1. `scheme://host/path` — yields `host`.
/// 2. `//host/path` (schemeless / protocol-relative) — yields `host`.
/// 3. `/path` or `path` — yields `None` (same-origin).
fn extract_redirect_host(location: &str) -> Option<String> {
let trimmed = location.trim();
if trimmed.is_empty() {
return None;
}
let rest = if let Some(after_scheme) = trimmed.find("://") {
&trimmed[after_scheme + 3..]
} else if let Some(stripped) = trimmed.strip_prefix("//") {
stripped
} else {
return None;
};
// Strip path / query / fragment from the host segment.
let end = rest
.find(|c: char| matches!(c, '/' | '?' | '#'))
.unwrap_or(rest.len());
let authority = &rest[..end];
// Strip userinfo + port.
let after_userinfo = authority.rsplit_once('@').map(|(_, h)| h).unwrap_or(authority);
let host_only = after_userinfo
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(after_userinfo);
let h = host_only.trim();
if h.is_empty() {
None
} else {
Some(h.to_owned())
}
}
/// Returns true when `probe` satisfies *every* predicate in `preds`.
/// An empty predicate slice satisfies vacuously — a payload that wants
/// "any probe at all" can ship an empty predicate set.
@ -657,7 +781,8 @@ fn probe_satisfies_one(probe: &SinkProbe, pred: &ProbePredicate) -> bool {
| ProbePredicate::TemplateEvalEqual { .. }
| ProbePredicate::XxeEntityExpanded { .. }
| ProbePredicate::QueryResultCountGreaterThan { .. }
| ProbePredicate::HeaderInjected { .. } => true,
| ProbePredicate::HeaderInjected { .. }
| ProbePredicate::RedirectHostNotIn { .. } => true,
}
}
@ -684,7 +809,8 @@ pub fn probe_crash_signal(probe: &SinkProbe) -> Option<Signal> {
| ProbeKind::Xxe { .. }
| ProbeKind::Ldap { .. }
| ProbeKind::Xpath { .. }
| ProbeKind::HeaderEmit { .. } => None,
| ProbeKind::HeaderEmit { .. }
| ProbeKind::Redirect { .. } => None,
}
}
@ -920,6 +1046,102 @@ mod tests {
assert!(oracle_fired(&oracle, &o, &[]));
}
fn redirect_probe(location: &str, request_host: &str) -> SinkProbe {
SinkProbe {
sink_callee: "HttpServletResponse.sendRedirect".into(),
args: vec![],
captured_at_ns: 1,
payload_id: "phase09".into(),
kind: ProbeKind::Redirect {
location: location.into(),
request_host: request_host.into(),
},
witness: ProbeWitness::empty(),
}
}
#[test]
fn redirect_off_origin_fires_when_host_outside_allowlist() {
let oracle = Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn {
allowlist: &["example.com", "www.example.com"],
}],
};
let probes = vec![redirect_probe("https://attacker.test/", "example.com")];
assert!(oracle_fired(&oracle, &outcome(), &probes));
}
#[test]
fn redirect_off_origin_clears_on_same_origin_path() {
let oracle = Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn {
allowlist: &["example.com"],
}],
};
let probes = vec![redirect_probe("/dashboard", "example.com")];
assert!(!oracle_fired(&oracle, &outcome(), &probes));
}
#[test]
fn redirect_off_origin_clears_on_allowlisted_host() {
let oracle = Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn {
allowlist: &["example.com", "cdn.example.com"],
}],
};
let probes = vec![redirect_probe("https://cdn.example.com/asset", "example.com")];
assert!(!oracle_fired(&oracle, &outcome(), &probes));
}
#[test]
fn redirect_off_origin_clears_when_host_matches_request_host() {
let oracle = Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: &[] }],
};
let probes = vec![redirect_probe("https://example.com/dashboard", "example.com")];
assert!(!oracle_fired(&oracle, &outcome(), &probes));
}
#[test]
fn redirect_off_origin_fires_on_schemeless_authority() {
let oracle = Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn {
allowlist: &["example.com"],
}],
};
let probes = vec![redirect_probe("//attacker.test/path", "example.com")];
assert!(oracle_fired(&oracle, &outcome(), &probes));
}
#[test]
fn redirect_off_origin_ignores_unrelated_probes() {
let oracle = Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn {
allowlist: &["example.com"],
}],
};
let probes = vec![probe("noop", vec![])];
assert!(!oracle_fired(&oracle, &outcome(), &probes));
}
#[test]
fn extract_redirect_host_handles_authority_variants() {
assert_eq!(
extract_redirect_host("https://attacker.test/path"),
Some("attacker.test".to_owned()),
);
assert_eq!(
extract_redirect_host("//attacker.test:8080/path"),
Some("attacker.test".to_owned()),
);
assert_eq!(
extract_redirect_host("https://user:pass@evil.example/?q=1"),
Some("evil.example".to_owned()),
);
assert_eq!(extract_redirect_host("/dashboard"), None);
assert_eq!(extract_redirect_host(""), None);
}
#[test]
fn sink_crash_without_probes_does_not_fire_even_on_process_crash() {
let mut o = outcome();

View file

@ -212,6 +212,30 @@ pub enum ProbeKind {
/// CRLF stripping; a benign host URL-encodes them (`%0d%0a`).
value: String,
},
/// Phase 09 (Track J.7) HTTP-redirect observation. Stamped by
/// the per-language harness shim's instrumented redirect entry
/// point (`HttpServletResponse.sendRedirect`, `flask.redirect`,
/// `Response::redirect`, `res.redirect`, `c.Redirect`,
/// `Redirect::to`). The shim records the raw `Location:` value
/// the host attempted to bind plus the original request host so
/// the [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`]
/// predicate can decide whether the redirect target falls outside
/// the configured allowlist. A vulnerable host concatenates the
/// attacker-controlled URL straight into the redirect; a benign
/// host either validates the host against an allowlist or scopes
/// the redirect to a same-origin path.
Redirect {
/// Raw `Location:` value the host attempted to set. May be a
/// fully-qualified URL (`https://attacker.test/`), a
/// schemeless reference (`//attacker.test/`), or a relative
/// path (`/dashboard`).
location: String,
/// Origin host the harness modelled the request as arriving
/// at. Used by the predicate to recognise schemeless or
/// same-origin redirects as benign even when the bare value
/// would otherwise resolve off-origin.
request_host: String,
},
}
impl Default for ProbeKind {

View file

@ -126,6 +126,10 @@ const PROFILE_SOURCES: &[(&str, &str)] = &[
("ssrf", include_str!("../sandbox_profiles/ssrf.sb")),
("deserialize", include_str!("../sandbox_profiles/deserialize.sb")),
("xxe", include_str!("../sandbox_profiles/xxe.sb")),
(
"open_redirect",
include_str!("../sandbox_profiles/open_redirect.sb"),
),
];
/// Cap → profile-name dispatch. The most restrictive matching profile
@ -156,10 +160,17 @@ pub fn profile_for_caps(caps: u32) -> &'static str {
const FS_SHAPED: u32 = FILE_IO | SQL_QUERY;
const NET_SHAPED: u32 =
SSRF | LDAP_INJECTION | XPATH_INJECTION | HEADER_INJECTION | OPEN_REDIRECT | UNVALIDATED_REDIRECT;
SSRF | LDAP_INJECTION | XPATH_INJECTION | HEADER_INJECTION | UNVALIDATED_REDIRECT;
const REDIRECT_SHAPED: u32 = OPEN_REDIRECT;
if caps & FS_SHAPED != 0 {
"path_traversal"
} else if caps & REDIRECT_SHAPED != 0 {
// Phase 09 (Track J.7): OPEN_REDIRECT maps to its own profile
// so the loopback-DNS-for-attacker.test addendum is visible
// at the cap → profile dispatch site instead of riding the
// SSRF profile's coat-tails.
"open_redirect"
} else if caps & NET_SHAPED != 0 {
"ssrf"
} else if caps & CODE_EXEC != 0 {
@ -470,22 +481,32 @@ mod tests {
#[test]
fn profile_for_caps_routes_outbound_network_caps_to_ssrf() {
// Outbound HTTP request sinks (HEADER_INJECTION / OPEN_REDIRECT /
// UNVALIDATED_REDIRECT) and other network-traffic injection caps
// (LDAP_INJECTION / XPATH_INJECTION) all share the SSRF shape:
// Outbound HTTP request sinks (HEADER_INJECTION /
// UNVALIDATED_REDIRECT) and other network-traffic injection
// caps (LDAP_INJECTION / XPATH_INJECTION) share the SSRF shape:
// outbound allowed, host-secret reads denied.
// Phase 09 (Track J.7) routes OPEN_REDIRECT to its own profile
// so the loopback-DNS-for-attacker.test addendum is visible at
// the cap → profile dispatch site.
const LDAP_INJECTION: u32 = 1 << 14;
const XPATH_INJECTION: u32 = 1 << 15;
const HEADER_INJECTION: u32 = 1 << 16;
const OPEN_REDIRECT: u32 = 1 << 17;
const UNVALIDATED_REDIRECT: u32 = 1 << 18;
assert_eq!(profile_for_caps(LDAP_INJECTION), "ssrf");
assert_eq!(profile_for_caps(XPATH_INJECTION), "ssrf");
assert_eq!(profile_for_caps(HEADER_INJECTION), "ssrf");
assert_eq!(profile_for_caps(OPEN_REDIRECT), "ssrf");
assert_eq!(profile_for_caps(UNVALIDATED_REDIRECT), "ssrf");
}
#[test]
fn profile_for_caps_routes_open_redirect_to_open_redirect_profile() {
// Phase 09 (Track J.7): OPEN_REDIRECT carves out of the SSRF
// bucket and into a dedicated `open_redirect.sb` profile that
// documents the loopback-DNS-for-attacker.test addendum.
const OPEN_REDIRECT: u32 = 1 << 17;
assert_eq!(profile_for_caps(OPEN_REDIRECT), "open_redirect");
}
#[test]
fn profile_for_caps_falls_back_to_base_for_unmapped_caps() {
// CRYPTO / AUTH / RACE / MEMORY_SAFETY / XSS are code-path bugs

View file

@ -0,0 +1,41 @@
;; Phase 09 (Track J.7) — OPEN_REDIRECT profile.
;;
;; Inherits the SSRF profile's outbound-allowed, secret-files-denied
;; shape — the open-redirect oracle only needs to inspect the
;; captured `Location:` header value, so no extra network reach is
;; required. The Phase 09 brief calls out loopback DNS resolution
;; for `attacker.test`: macOS sandbox-exec already permits loopback
;; via `(allow default)`, so the addendum is a documentation marker
;; rather than an enforcement change. The Linux seccomp profile
;; (see `seccomp_policy.toml::[cap.OPEN_REDIRECT]`) opens the same
;; socket / connect / sendto family the SSRF cap uses, which covers
;; the loopback resolver path on linux as well.
(version 1)
(allow default)
;; Secret-file denylist (mirrors `ssrf.sb`) so an attacker who pivots
;; from an open redirect to a host-side file read still cannot
;; exfiltrate the canonical macOS secret stores.
(deny file-read*
(literal "/etc/passwd")
(literal "/etc/master.passwd")
(literal "/etc/shadow")
(literal "/etc/sudoers")
(literal "/private/etc/passwd")
(literal "/private/etc/master.passwd")
(literal "/private/etc/shadow")
(literal "/private/etc/sudoers")
(regex #"^/Users/[^/]+/\.ssh(/|$)")
(regex #"^/Users/[^/]+/\.aws(/|$)")
(regex #"^/Users/[^/]+/\.gnupg(/|$)")
(regex #"^/Users/[^/]+/\.netrc$")
(regex #"^/Users/[^/]+/\.docker(/|$)")
(regex #"^/Users/[^/]+/\.kube(/|$)")
(regex #"^/Users/[^/]+/\.config/gh(/|$)")
(regex #"^/Users/[^/]+/Library/Keychains(/|$)")
(regex #"^/Users/[^/]+/Library/Cookies(/|$)")
(regex #"^/Users/[^/]+/Library/Mail(/|$)")
(regex #"^/Users/[^/]+/Library/Application Support/com\.apple\.TCC(/|$)")
(regex #"^/Users/[^/]+/Library/Application Support/Slack(/|$)")
(subpath "/Library/Keychains"))

View file

@ -60,7 +60,7 @@ pub const NYX_VERSION: &str = env!("CARGO_PKG_VERSION");
/// [`crate::dynamic::corpus::CORPUS_VERSION`]; the compile-time assertion
/// below + the [`corpus_version_const_matches_corpus_module`] runtime test
/// jointly guard drift.
pub const CORPUS_VERSION: &str = "12";
pub const CORPUS_VERSION: &str = "13";
/// Compile-time guard that pins [`CORPUS_VERSION`] (this module) to the
/// textual form of [`crate::dynamic::corpus::CORPUS_VERSION`]. Bumping the

View file

@ -0,0 +1,16 @@
// Phase 09 (Track J.7) — Go OPEN_REDIRECT benign control fixture.
//
// The handler ignores the attacker-supplied value and redirects to a
// same-origin path; the captured `Location:` header carries no
// off-origin authority.
package vuln
import (
"net/http"
"github.com/gin-gonic/gin"
)
func Run(c *gin.Context, value string) {
c.Redirect(http.StatusFound, "/dashboard")
}

View file

@ -0,0 +1,16 @@
// Phase 09 (Track J.7) — Go OPEN_REDIRECT vuln fixture.
//
// The gin handler splices `value` straight into
// `gin.Context.Redirect` without host validation; an attacker URL
// routes the captured `Location:` header off-origin.
package vuln
import (
"net/http"
"github.com/gin-gonic/gin"
)
func Run(c *gin.Context, value string) {
c.Redirect(http.StatusFound, value)
}

View file

@ -0,0 +1,12 @@
// Phase 09 (Track J.7) Java OPEN_REDIRECT benign control fixture.
//
// The function ignores the attacker-supplied value and always
// redirects to the same-origin path `/dashboard`, so the captured
// `Location:` header has no off-origin authority.
import javax.servlet.http.HttpServletResponse;
public class Benign {
public static void run(HttpServletResponse response, String value) throws Exception {
response.sendRedirect("/dashboard");
}
}

View file

@ -0,0 +1,13 @@
// Phase 09 (Track J.7) Java OPEN_REDIRECT vuln fixture.
//
// The function passes `value` straight into
// `HttpServletResponse.sendRedirect` without host validation. A
// payload carrying `https://attacker.test/` sends the response's
// `Location:` header off-origin.
import javax.servlet.http.HttpServletResponse;
public class Vuln {
public static void run(HttpServletResponse response, String value) throws Exception {
response.sendRedirect(value);
}
}

View file

@ -0,0 +1,13 @@
// Phase 09 (Track J.7) — JavaScript OPEN_REDIRECT benign control
// fixture.
//
// The handler ignores the attacker-supplied value and redirects to a
// same-origin path; the captured `Location:` header carries no
// off-origin authority.
const express = require('express');
function run(req, res, value) {
res.redirect('/dashboard');
}
module.exports = { run };

View file

@ -0,0 +1,12 @@
// Phase 09 (Track J.7) — JavaScript OPEN_REDIRECT vuln fixture.
//
// The Express handler splices `value` straight into `res.redirect`
// without host validation; an attacker URL routes the captured
// `Location:` header off-origin.
const express = require('express');
function run(req, res, value) {
res.redirect(value);
}
module.exports = { run };

View file

@ -0,0 +1,11 @@
<?php
// Phase 09 (Track J.7) — PHP OPEN_REDIRECT benign control fixture.
//
// The function ignores the attacker-supplied value and redirects to
// a same-origin path; the captured `Location:` header carries no
// off-origin authority.
use Symfony\Component\HttpFoundation\RedirectResponse;
function run(string $value): RedirectResponse {
return new RedirectResponse('/dashboard');
}

View file

@ -0,0 +1,11 @@
<?php
// Phase 09 (Track J.7) — PHP OPEN_REDIRECT vuln fixture.
//
// The function splices `$value` into a Symfony `RedirectResponse`
// without host validation; an attacker URL routes the
// `Location:` header off-origin.
use Symfony\Component\HttpFoundation\RedirectResponse;
function run(string $value): RedirectResponse {
return new RedirectResponse($value);
}

View file

@ -0,0 +1,10 @@
# Phase 09 (Track J.7) — Python OPEN_REDIRECT benign control fixture.
#
# The function ignores the attacker-supplied value and redirects to a
# same-origin path, so the captured `Location:` header carries no
# off-origin authority.
from flask import redirect
def run(value):
return redirect("/dashboard")

View file

@ -0,0 +1,10 @@
# Phase 09 (Track J.7) — Python OPEN_REDIRECT vuln fixture.
#
# The function passes `value` straight into `flask.redirect` without
# host validation. A payload carrying `https://attacker.test/` sends
# the response's `Location:` header off-origin.
from flask import redirect
def run(value):
return redirect(value)

View file

@ -0,0 +1,12 @@
# Phase 09 (Track J.7) — Ruby OPEN_REDIRECT benign control fixture.
#
# The function ignores the attacker-supplied value and redirects to a
# same-origin path; the captured `Location:` header carries no
# off-origin authority.
require 'rack'
def run(value)
response = Rack::Response.new
response.redirect('/dashboard')
response
end

View file

@ -0,0 +1,12 @@
# Phase 09 (Track J.7) — Ruby OPEN_REDIRECT vuln fixture.
#
# The function splices `value` straight into
# `Rack::Response#redirect` without host validation; an attacker URL
# routes the captured `Location:` header off-origin.
require 'rack'
def run(value)
response = Rack::Response.new
response.redirect(value)
response
end

View file

@ -0,0 +1,10 @@
// Phase 09 (Track J.7) — Rust OPEN_REDIRECT benign control fixture.
//
// The handler ignores the attacker-supplied value and redirects to a
// same-origin path; the captured `Location:` header carries no
// off-origin authority.
use axum::response::Redirect;
pub fn run(_value: String) -> Redirect {
Redirect::to("/dashboard")
}

View file

@ -0,0 +1,10 @@
// Phase 09 (Track J.7) — Rust OPEN_REDIRECT vuln fixture.
//
// The handler splices `value` straight into `Redirect::to` without
// host validation; an attacker URL routes the captured `Location:`
// header off-origin.
use axum::response::Redirect;
pub fn run(value: String) -> Redirect {
Redirect::to(&value)
}

View file

@ -0,0 +1,394 @@
//! Phase 09 (Track J.7) — OPEN_REDIRECT corpus acceptance.
//!
//! Asserts the new cap end-to-end: corpus slices register per-language
//! vuln/benign pairs for Java / Python / PHP / Ruby / JavaScript / Go /
//! Rust, the lang-aware resolver pairs them inside the correct slice,
//! the per-language harness emitters splice in the synthetic
//! `sendRedirect` / `redirect` shim + `Redirect` probe + sink-hit
//! sentinel, the framework adapters fire on the canonical redirect
//! call, and the `RedirectHostNotIn` predicate fires only on probes
//! whose `location` resolves off-origin against the allowlist.
//!
//! `cargo nextest run --features dynamic --test open_redirect_corpus`.
#![cfg(feature = "dynamic")]
mod common;
use nyx_scanner::dynamic::corpus::{
audit_marker_collisions, benign_payload_for_lang, payloads_for_lang,
resolve_benign_control_lang, Oracle,
};
use nyx_scanner::dynamic::framework::registry::adapters_for;
use nyx_scanner::dynamic::lang;
use nyx_scanner::dynamic::oracle::{oracle_fired, ProbePredicate};
use nyx_scanner::dynamic::probe::{ProbeKind, ProbeWitness, SinkProbe};
use nyx_scanner::dynamic::sandbox::SandboxOutcome;
use nyx_scanner::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
use nyx_scanner::labels::Cap;
use nyx_scanner::summary::FuncSummary;
use nyx_scanner::symbol::Lang;
use std::time::Duration;
const LANGS: &[Lang] = &[
Lang::Java,
Lang::Python,
Lang::Php,
Lang::Ruby,
Lang::JavaScript,
Lang::Go,
Lang::Rust,
];
fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec {
HarnessSpec {
finding_id: "phase09test0001".into(),
entry_file: entry_file.into(),
entry_name: entry_name.into(),
entry_kind: EntryKind::Function,
lang,
toolchain_id: "phase09".into(),
payload_slot: PayloadSlot::Param(0),
expected_cap: Cap::OPEN_REDIRECT,
constraint_hints: vec![],
sink_file: entry_file.into(),
sink_line: 1,
spec_hash: "phase09test0001".into(),
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
stubs_required: vec![],
framework: None,
}
}
#[test]
fn corpus_registers_open_redirect_for_every_supported_lang() {
for lang in LANGS {
let slice = payloads_for_lang(Cap::OPEN_REDIRECT, *lang);
assert!(
!slice.is_empty(),
"OPEN_REDIRECT has no payloads for {lang:?}"
);
let has_vuln = slice.iter().any(|p| !p.is_benign);
let has_benign = slice.iter().any(|p| p.is_benign);
assert!(has_vuln, "{lang:?} OPEN_REDIRECT missing vuln payload");
assert!(
has_benign,
"{lang:?} OPEN_REDIRECT missing benign control"
);
}
}
#[test]
fn open_redirect_unsupported_caps_unchanged_for_other_langs() {
for lang in [Lang::C, Lang::Cpp, Lang::TypeScript] {
assert!(
payloads_for_lang(Cap::OPEN_REDIRECT, lang).is_empty(),
"unexpected OPEN_REDIRECT payloads for {lang:?}",
);
}
}
#[test]
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();
let resolved = resolve_benign_control_lang(vuln, Cap::OPEN_REDIRECT, *lang)
.expect("paired control");
assert!(resolved.is_benign);
let direct = benign_payload_for_lang(Cap::OPEN_REDIRECT, *lang).unwrap();
assert_eq!(direct.label, resolved.label);
}
}
#[test]
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();
match &vuln.oracle {
Oracle::SinkProbe { predicates } => {
assert!(
predicates.iter().any(|p| matches!(
p,
ProbePredicate::RedirectHostNotIn { .. }
)),
"{lang:?} vuln payload missing RedirectHostNotIn predicate",
);
}
other => panic!("expected SinkProbe oracle for {lang:?}, got {other:?}"),
}
}
}
#[test]
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();
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();
assert!(
vuln_text.contains("attacker.test"),
"{lang:?} vuln payload must carry the off-origin attacker host",
);
assert!(
!benign_text.contains("://"),
"{lang:?} benign control must be a same-origin relative path",
);
assert!(
benign_text.starts_with('/'),
"{lang:?} benign control must be an absolute same-origin path",
);
}
}
#[test]
fn marker_collisions_clean_with_phase_09_additions() {
assert!(audit_marker_collisions().is_empty());
}
#[test]
fn probe_kind_redirect_serdes() {
let original = ProbeKind::Redirect {
location: "https://attacker.test/".into(),
request_host: "example.com".into(),
};
let json = serde_json::to_string(&original).unwrap();
assert!(json.contains("Redirect"));
assert!(json.contains("location"));
assert!(json.contains("request_host"));
let parsed: ProbeKind = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn redirect_host_not_in_fires_on_off_origin_location() {
let oracle = Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn {
allowlist: &["example.com"],
}],
};
let probes = vec![SinkProbe {
sink_callee: "HttpServletResponse.sendRedirect".into(),
args: vec![],
captured_at_ns: 1,
payload_id: "phase09".into(),
kind: ProbeKind::Redirect {
location: "https://attacker.test/".into(),
request_host: "example.com".into(),
},
witness: ProbeWitness::empty(),
}];
let outcome = SandboxOutcome {
exit_code: Some(0),
stdout: vec![],
stderr: vec![],
timed_out: false,
oob_callback_seen: false,
sink_hit: true,
duration: Duration::from_millis(1),
hardening_outcome: None,
};
assert!(oracle_fired(&oracle, &outcome, &probes));
}
#[test]
fn redirect_host_not_in_clear_on_same_origin_path() {
let oracle = Oracle::SinkProbe {
predicates: &[ProbePredicate::RedirectHostNotIn {
allowlist: &["example.com"],
}],
};
let probes = vec![SinkProbe {
sink_callee: "HttpServletResponse.sendRedirect".into(),
args: vec![],
captured_at_ns: 1,
payload_id: "phase09".into(),
kind: ProbeKind::Redirect {
location: "/dashboard".into(),
request_host: "example.com".into(),
},
witness: ProbeWitness::empty(),
}];
let outcome = SandboxOutcome {
exit_code: Some(0),
stdout: vec![],
stderr: vec![],
timed_out: false,
oob_callback_seen: false,
sink_hit: true,
duration: Duration::from_millis(1),
hardening_outcome: None,
};
assert!(!oracle_fired(&oracle, &outcome, &probes));
}
#[test]
fn lang_emitter_dispatches_to_open_redirect_harness() {
// Per-lang `sink_callee_marker` pins which redirect entry point
// the harness names in its probe record.
for (lang, entry_file, entry_name, sink_callee_marker) in [
(
Lang::Java,
"tests/dynamic_fixtures/open_redirect/java/Vuln.java",
"run",
"HttpServletResponse.sendRedirect",
),
(
Lang::Python,
"tests/dynamic_fixtures/open_redirect/python/vuln.py",
"run",
"flask.redirect",
),
(
Lang::Php,
"tests/dynamic_fixtures/open_redirect/php/vuln.php",
"run",
"Response::redirect",
),
(
Lang::Ruby,
"tests/dynamic_fixtures/open_redirect/ruby/vuln.rb",
"run",
"Rack::Response#redirect",
),
(
Lang::JavaScript,
"tests/dynamic_fixtures/open_redirect/js/vuln.js",
"run",
"res.redirect",
),
(
Lang::Go,
"tests/dynamic_fixtures/open_redirect/go/vuln.go",
"Run",
"gin.Context.Redirect",
),
(
Lang::Rust,
"tests/dynamic_fixtures/open_redirect/rust/vuln.rs",
"run",
"Redirect::to",
),
] {
let spec = make_spec(lang, entry_file, entry_name);
let harness = lang::emit(&spec)
.unwrap_or_else(|e| panic!("emit failed for {lang:?}: {e:?}"));
assert!(
harness.source.contains("Redirect"),
"{lang:?} redirect harness must carry the Redirect probe kind",
);
assert!(
harness.source.contains(sink_callee_marker),
"{lang:?} redirect harness must name {sink_callee_marker:?} as the sink callee",
);
assert!(
harness.source.contains("__NYX_SINK_HIT__"),
"{lang:?} redirect harness must emit the sink-hit sentinel",
);
assert!(
harness.source.contains("request_host"),
"{lang:?} redirect harness must carry the request_host field",
);
}
}
#[test]
fn framework_adapters_detect_redirect_sink() {
// Each lang registers its J.7 redirect adapter; detect_binding
// routes through the registry and stamps an
// `EntryKind::Function` binding when the fixture contains the
// canonical redirect call.
for (lang, fixture, sink_callee) in [
(
Lang::Java,
"tests/dynamic_fixtures/open_redirect/java/Vuln.java",
"sendRedirect",
),
(
Lang::Python,
"tests/dynamic_fixtures/open_redirect/python/vuln.py",
"redirect",
),
(
Lang::Php,
"tests/dynamic_fixtures/open_redirect/php/vuln.php",
"RedirectResponse",
),
(
Lang::Ruby,
"tests/dynamic_fixtures/open_redirect/ruby/vuln.rb",
"redirect",
),
(
Lang::JavaScript,
"tests/dynamic_fixtures/open_redirect/js/vuln.js",
"redirect",
),
(
Lang::Go,
"tests/dynamic_fixtures/open_redirect/go/vuln.go",
"Redirect",
),
(
Lang::Rust,
"tests/dynamic_fixtures/open_redirect/rust/vuln.rs",
"to",
),
] {
let bytes = std::fs::read(fixture).expect("fixture exists");
let ts_lang = ts_language_for(lang);
let mut parser = tree_sitter::Parser::new();
parser.set_language(&ts_lang).unwrap();
let tree = parser.parse(&bytes, None).unwrap();
let mut summary = FuncSummary {
name: "run".into(),
file_path: fixture.to_owned(),
lang: slug(lang).into(),
..Default::default()
};
summary
.callees
.push(nyx_scanner::summary::CalleeSite::bare(sink_callee));
let registry_slice = adapters_for(lang);
assert!(!registry_slice.is_empty(), "{lang:?} adapter slice empty");
let binding = nyx_scanner::dynamic::framework::detect_binding(
&summary,
tree.root_node(),
&bytes,
lang,
);
let b = binding
.unwrap_or_else(|| panic!("{lang:?} adapter must detect the redirect fixture"));
assert_eq!(b.kind, EntryKind::Function);
assert!(!b.adapter.is_empty());
}
}
fn ts_language_for(lang: Lang) -> tree_sitter::Language {
match lang {
Lang::Java => tree_sitter::Language::from(tree_sitter_java::LANGUAGE),
Lang::Python => tree_sitter::Language::from(tree_sitter_python::LANGUAGE),
Lang::Php => tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP),
Lang::Ruby => tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE),
Lang::JavaScript => tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE),
Lang::Go => tree_sitter::Language::from(tree_sitter_go::LANGUAGE),
Lang::Rust => tree_sitter::Language::from(tree_sitter_rust::LANGUAGE),
other => panic!("unsupported test lang {other:?}"),
}
}
fn slug(lang: Lang) -> &'static str {
match lang {
Lang::Java => "java",
Lang::Python => "python",
Lang::Php => "php",
Lang::Ruby => "ruby",
Lang::JavaScript => "javascript",
Lang::Go => "go",
Lang::Rust => "rust",
_ => "other",
}
}