diff --git a/src/dynamic/corpus.rs b/src/dynamic/corpus.rs index 06e73366..33c78f61 100644 --- a/src/dynamic/corpus.rs +++ b/src/dynamic/corpus.rs @@ -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)] diff --git a/src/dynamic/corpus/open_redirect/go.rs b/src/dynamic/corpus/open_redirect/go.rs new file mode 100644 index 00000000..28b1edcf --- /dev/null +++ b/src/dynamic/corpus/open_redirect/go.rs @@ -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, + }, +]; diff --git a/src/dynamic/corpus/open_redirect/java.rs b/src/dynamic/corpus/open_redirect/java.rs new file mode 100644 index 00000000..c9c468be --- /dev/null +++ b/src/dynamic/corpus/open_redirect/java.rs @@ -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: , 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, + }, +]; diff --git a/src/dynamic/corpus/open_redirect/js.rs b/src/dynamic/corpus/open_redirect/js.rs new file mode 100644 index 00000000..8d13ac43 --- /dev/null +++ b/src/dynamic/corpus/open_redirect/js.rs @@ -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, + }, +]; diff --git a/src/dynamic/corpus/open_redirect/mod.rs b/src/dynamic/corpus/open_redirect/mod.rs new file mode 100644 index 00000000..d33bd2af --- /dev/null +++ b/src/dynamic/corpus/open_redirect/mod.rs @@ -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; diff --git a/src/dynamic/corpus/open_redirect/php.rs b/src/dynamic/corpus/open_redirect/php.rs new file mode 100644 index 00000000..504d65aa --- /dev/null +++ b/src/dynamic/corpus/open_redirect/php.rs @@ -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, + }, +]; diff --git a/src/dynamic/corpus/open_redirect/python.rs b/src/dynamic/corpus/open_redirect/python.rs new file mode 100644 index 00000000..ecd8ae4c --- /dev/null +++ b/src/dynamic/corpus/open_redirect/python.rs @@ -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, + }, +]; diff --git a/src/dynamic/corpus/open_redirect/ruby.rs b/src/dynamic/corpus/open_redirect/ruby.rs new file mode 100644 index 00000000..5a504bcb --- /dev/null +++ b/src/dynamic/corpus/open_redirect/ruby.rs @@ -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, + }, +]; diff --git a/src/dynamic/corpus/open_redirect/rust.rs b/src/dynamic/corpus/open_redirect/rust.rs new file mode 100644 index 00000000..4f649596 --- /dev/null +++ b/src/dynamic/corpus/open_redirect/rust.rs @@ -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, + }, +]; diff --git a/src/dynamic/corpus/registry.rs b/src/dynamic/corpus/registry.rs index 433799be..fad2736e 100644 --- a/src/dynamic/corpus/registry.rs +++ b/src/dynamic/corpus/registry.rs @@ -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) { diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index 247042c9..6a1c5a8b 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -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; diff --git a/src/dynamic/framework/adapters/redirect_go.rs b/src/dynamic/framework/adapters/redirect_go.rs new file mode 100644 index 00000000..ddfbba37 --- /dev/null +++ b/src/dynamic/framework/adapters/redirect_go.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/redirect_java.rs b/src/dynamic/framework/adapters/redirect_java.rs new file mode 100644 index 00000000..1ba3c36a --- /dev/null +++ b/src/dynamic/framework/adapters/redirect_java.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/redirect_js.rs b/src/dynamic/framework/adapters/redirect_js.rs new file mode 100644 index 00000000..a87e00e9 --- /dev/null +++ b/src/dynamic/framework/adapters/redirect_js.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/redirect_php.rs b/src/dynamic/framework/adapters/redirect_php.rs new file mode 100644 index 00000000..bfa56562 --- /dev/null +++ b/src/dynamic/framework/adapters/redirect_php.rs @@ -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 { + 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" 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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/redirect_ruby.rs b/src/dynamic/framework/adapters/redirect_ruby.rs new file mode 100644 index 00000000..ac2d944b --- /dev/null +++ b/src/dynamic/framework/adapters/redirect_ruby.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/redirect_rust.rs b/src/dynamic/framework/adapters/redirect_rust.rs new file mode 100644 index 00000000..2ec10425 --- /dev/null +++ b/src/dynamic/framework/adapters/redirect_rust.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index ebfdeffa..dcbe3158 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -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(), diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs index 7531840a..fbaf7a56 100644 --- a/src/dynamic/framework/registry.rs +++ b/src/dynamic/framework/registry.rs @@ -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, ]; diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 8b0917bb..84603b7c 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -513,6 +513,14 @@ pub fn emit(spec: &HarnessSpec) -> Result { 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); diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 05757e11..ff065b52 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -570,6 +570,9 @@ pub fn emit(spec: &HarnessSpec) -> Result { 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`]. diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index a48bd763..0af145e7 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -457,6 +457,14 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result 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 diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 6f540175..6220c800 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -436,6 +436,10 @@ pub fn emit(spec: &HarnessSpec) -> Result { 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#" '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); diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 55aa2502..ebb79009 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -650,6 +650,16 @@ pub fn emit(spec: &HarnessSpec) -> Result { 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() "# diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index c5b38025..1d90b5b9 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -427,6 +427,9 @@ pub fn emit(spec: &HarnessSpec) -> Result { 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); diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index 3f9f9e87..c2504941 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -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 { 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 diff --git a/src/dynamic/oracle.rs b/src/dynamic/oracle.rs index 494ec844..c925c4e1 100644 --- a/src/dynamic/oracle.rs +++ b/src/dynamic/oracle.rs @@ -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 { + 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 { | 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(); diff --git a/src/dynamic/probe.rs b/src/dynamic/probe.rs index d8fa82ae..393485f9 100644 --- a/src/dynamic/probe.rs +++ b/src/dynamic/probe.rs @@ -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 { diff --git a/src/dynamic/sandbox/process_macos.rs b/src/dynamic/sandbox/process_macos.rs index 9f544011..704e0f3a 100644 --- a/src/dynamic/sandbox/process_macos.rs +++ b/src/dynamic/sandbox/process_macos.rs @@ -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 diff --git a/src/dynamic/sandbox_profiles/open_redirect.sb b/src/dynamic/sandbox_profiles/open_redirect.sb new file mode 100644 index 00000000..fe9ea782 --- /dev/null +++ b/src/dynamic/sandbox_profiles/open_redirect.sb @@ -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")) diff --git a/src/dynamic/telemetry.rs b/src/dynamic/telemetry.rs index 9d2942e2..453d5490 100644 --- a/src/dynamic/telemetry.rs +++ b/src/dynamic/telemetry.rs @@ -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 diff --git a/tests/dynamic_fixtures/open_redirect/go/benign.go b/tests/dynamic_fixtures/open_redirect/go/benign.go new file mode 100644 index 00000000..83df90a3 --- /dev/null +++ b/tests/dynamic_fixtures/open_redirect/go/benign.go @@ -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") +} diff --git a/tests/dynamic_fixtures/open_redirect/go/vuln.go b/tests/dynamic_fixtures/open_redirect/go/vuln.go new file mode 100644 index 00000000..6f7b21c5 --- /dev/null +++ b/tests/dynamic_fixtures/open_redirect/go/vuln.go @@ -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) +} diff --git a/tests/dynamic_fixtures/open_redirect/java/Benign.java b/tests/dynamic_fixtures/open_redirect/java/Benign.java new file mode 100644 index 00000000..e0eeb95e --- /dev/null +++ b/tests/dynamic_fixtures/open_redirect/java/Benign.java @@ -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"); + } +} diff --git a/tests/dynamic_fixtures/open_redirect/java/Vuln.java b/tests/dynamic_fixtures/open_redirect/java/Vuln.java new file mode 100644 index 00000000..be1b9409 --- /dev/null +++ b/tests/dynamic_fixtures/open_redirect/java/Vuln.java @@ -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); + } +} diff --git a/tests/dynamic_fixtures/open_redirect/js/benign.js b/tests/dynamic_fixtures/open_redirect/js/benign.js new file mode 100644 index 00000000..5ee7c1a9 --- /dev/null +++ b/tests/dynamic_fixtures/open_redirect/js/benign.js @@ -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 }; diff --git a/tests/dynamic_fixtures/open_redirect/js/vuln.js b/tests/dynamic_fixtures/open_redirect/js/vuln.js new file mode 100644 index 00000000..8a5cdcc5 --- /dev/null +++ b/tests/dynamic_fixtures/open_redirect/js/vuln.js @@ -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 }; diff --git a/tests/dynamic_fixtures/open_redirect/php/benign.php b/tests/dynamic_fixtures/open_redirect/php/benign.php new file mode 100644 index 00000000..35f86416 --- /dev/null +++ b/tests/dynamic_fixtures/open_redirect/php/benign.php @@ -0,0 +1,11 @@ + Redirect { + Redirect::to("/dashboard") +} diff --git a/tests/dynamic_fixtures/open_redirect/rust/vuln.rs b/tests/dynamic_fixtures/open_redirect/rust/vuln.rs new file mode 100644 index 00000000..a3f1d446 --- /dev/null +++ b/tests/dynamic_fixtures/open_redirect/rust/vuln.rs @@ -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) +} diff --git a/tests/open_redirect_corpus.rs b/tests/open_redirect_corpus.rs new file mode 100644 index 00000000..92c6f307 --- /dev/null +++ b/tests/open_redirect_corpus.rs @@ -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", + } +}