mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 09: Track J.7 + Track L.7 — OPEN_REDIRECT corpus + redirect-aware adapters
This commit is contained in:
parent
5697763f28
commit
b881af5d93
47 changed files with 2592 additions and 32 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
54
src/dynamic/corpus/open_redirect/go.rs
Normal file
54
src/dynamic/corpus/open_redirect/go.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! Go `Cap::OPEN_REDIRECT` payloads — `gin.Context.Redirect` /
|
||||
//! `http.Redirect` off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an absolute attacker URL spliced into
|
||||
//! `c.Redirect(http.StatusFound, value)` (or
|
||||
//! `http.Redirect(w, r, value, ...)`) without host validation; the
|
||||
//! harness records a `ProbeKind::Redirect` probe whose `location`
|
||||
//! points off-origin.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the same-origin path
|
||||
//! `/dashboard`, so the captured `location` has no authority
|
||||
//! component and the predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-go-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/go/vuln.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-go-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-go-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/go/benign.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
59
src/dynamic/corpus/open_redirect/java.rs
Normal file
59
src/dynamic/corpus/open_redirect/java.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
//! Java `Cap::OPEN_REDIRECT` payloads —
|
||||
//! `HttpServletResponse.sendRedirect` off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: a fully-qualified attacker URL
|
||||
//! (`https://attacker.test/`). Spliced into the host's
|
||||
//! `response.sendRedirect(value)` call without host validation, the
|
||||
//! servlet response's `Location:` header points off-origin. The
|
||||
//! harness's instrumented `sendRedirect` shim records a
|
||||
//! `ProbeKind::Redirect { location: <raw>, request_host:
|
||||
//! "example.com" }` probe; the predicate
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`] sees
|
||||
//! the off-allowlist host and fires.
|
||||
//!
|
||||
//! Benign control: same logical entry point, but the harness's benign
|
||||
//! code path redirects to the relative path `/dashboard` (no host
|
||||
//! component). The captured `location` has no off-origin authority
|
||||
//! and the predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-java-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/java/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-java-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-java-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/java/Benign.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
53
src/dynamic/corpus/open_redirect/js.rs
Normal file
53
src/dynamic/corpus/open_redirect/js.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
//! JavaScript `Cap::OPEN_REDIRECT` payloads —
|
||||
//! Express `res.redirect` off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an absolute attacker URL spliced into
|
||||
//! `res.redirect(value)` without host validation; the harness
|
||||
//! records a `ProbeKind::Redirect` probe whose `location` points
|
||||
//! off-origin.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the same-origin path
|
||||
//! `/dashboard`, so the captured `location` has no authority
|
||||
//! component and the predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-js-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/js/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-js-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-js-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/js/benign.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
26
src/dynamic/corpus/open_redirect/mod.rs
Normal file
26
src/dynamic/corpus/open_redirect/mod.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//! Open-redirect (`Cap::OPEN_REDIRECT`) per-language payload slices.
|
||||
//!
|
||||
//! Phase 09 (Track J.7) carves open redirects across the seven HTTP
|
||||
//! framework ecosystems Nyx supports: Java
|
||||
//! (`HttpServletResponse.sendRedirect`), Python (`flask.redirect`),
|
||||
//! PHP (Symfony `Response::redirect` / Slim `Response::withHeader`),
|
||||
//! Ruby (`Rack::Response#redirect`), JavaScript (Express
|
||||
//! `res.redirect`), Go (`gin.Context.Redirect`), Rust (`axum::response::
|
||||
//! Redirect::to`). Every vuln payload binds an absolute attacker URL
|
||||
//! (`https://attacker.test/`) into the response writer's redirect
|
||||
//! entry point; the paired benign control redirects to a same-origin
|
||||
//! path (`/dashboard`). The harness's instrumented redirect shim
|
||||
//! records a [`crate::dynamic::probe::ProbeKind::Redirect { location,
|
||||
//! request_host }`] probe with the unmodified location and the
|
||||
//! request's origin host, and the
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`]
|
||||
//! predicate fires when the captured `location` resolves off-origin
|
||||
//! relative to `allowlist ∪ {request_host}`.
|
||||
|
||||
pub mod go;
|
||||
pub mod java;
|
||||
pub mod js;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
pub mod ruby;
|
||||
pub mod rust;
|
||||
55
src/dynamic/corpus/open_redirect/php.rs
Normal file
55
src/dynamic/corpus/open_redirect/php.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
//! PHP `Cap::OPEN_REDIRECT` payloads — `Response::redirect` /
|
||||
//! Symfony `RedirectResponse(...)` off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an absolute attacker URL passed to
|
||||
//! `header("Location: $value")` or
|
||||
//! `new \Symfony\Component\HttpFoundation\RedirectResponse($value)`
|
||||
//! without host validation. The harness records a
|
||||
//! `ProbeKind::Redirect { location, request_host }` probe and the
|
||||
//! predicate fires on the off-allowlist host.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the same-origin path
|
||||
//! `/dashboard`, so the captured `location` has no authority
|
||||
//! component and the predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-php-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/php/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-php-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-php-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/php/benign.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
54
src/dynamic/corpus/open_redirect/python.rs
Normal file
54
src/dynamic/corpus/open_redirect/python.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! Python `Cap::OPEN_REDIRECT` payloads — `flask.redirect`
|
||||
//! off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an attacker-controlled absolute URL spliced into
|
||||
//! `flask.redirect(value)` without host validation; the captured
|
||||
//! `Location:` header points off-origin and the
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`]
|
||||
//! predicate fires.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the relative path
|
||||
//! `/dashboard`, so the captured location has no authority component
|
||||
//! and the predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-python-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-python-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-python-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/python/benign.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
53
src/dynamic/corpus/open_redirect/ruby.rs
Normal file
53
src/dynamic/corpus/open_redirect/ruby.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
//! Ruby `Cap::OPEN_REDIRECT` payloads —
|
||||
//! `Rack::Response#redirect` off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an absolute attacker URL spliced into
|
||||
//! `response.redirect(value)` without host validation; the harness
|
||||
//! records a `ProbeKind::Redirect` probe whose `location` points
|
||||
//! off-origin.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the same-origin path
|
||||
//! `/dashboard`, so the captured `location` has no authority
|
||||
//! component and the predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-ruby-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/ruby/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-ruby-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-ruby-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/ruby/benign.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
53
src/dynamic/corpus/open_redirect/rust.rs
Normal file
53
src/dynamic/corpus/open_redirect/rust.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
//! Rust `Cap::OPEN_REDIRECT` payloads — `axum::response::Redirect::to`
|
||||
//! off-origin redirect.
|
||||
//!
|
||||
//! Vuln payload: an absolute attacker URL spliced into
|
||||
//! `Redirect::to(value)` without host validation; the harness
|
||||
//! records a `ProbeKind::Redirect` probe whose `location` points
|
||||
//! off-origin.
|
||||
//!
|
||||
//! Benign control: same shape but redirects to the same-origin path
|
||||
//! `/dashboard`, so the captured `location` has no authority
|
||||
//! component and the predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
const ALLOWLIST: &[&str] = &["example.com"];
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"https://attacker.test/",
|
||||
label: "open-redirect-rust-absolute",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/rust/vuln.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "open-redirect-rust-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"/dashboard",
|
||||
label: "open-redirect-rust-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: ALLOWLIST }],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 13,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/open_redirect/rust/benign.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
104
src/dynamic/framework/adapters/redirect_go.rs
Normal file
104
src/dynamic/framework/adapters/redirect_go.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
//! Go [`super::super::FrameworkAdapter`] matching HTTP-redirect sink
|
||||
//! constructions (`http.Redirect`, `gin.Context.Redirect`).
|
||||
//!
|
||||
//! Phase 09 (Track J.7). Fires when the function body invokes one of
|
||||
//! the canonical Go HTTP redirect entry points and the surrounding
|
||||
//! source imports `net/http` or the gin framework.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct RedirectGoAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "redirect-go";
|
||||
|
||||
fn callee_is_redirect(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "Redirect" | "Redirect302" | "Redirect301")
|
||||
}
|
||||
|
||||
fn source_imports_go_web(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"net/http",
|
||||
b"github.com/gin-gonic/gin",
|
||||
b"github.com/labstack/echo",
|
||||
b"github.com/gofiber/fiber",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for RedirectGoAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Go
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
|
||||
let matches_source = source_imports_go_web(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_go(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_gin_redirect() {
|
||||
let src: &[u8] = b"package vuln\n\nimport (\n\t\"net/http\"\n\t\"github.com/gin-gonic/gin\"\n)\n\
|
||||
func Run(c *gin.Context, v string) {\n\tc.Redirect(http.StatusFound, v)\n}\n";
|
||||
let tree = parse_go(src);
|
||||
let summary = FuncSummary {
|
||||
name: "Run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("Redirect")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectGoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"package vuln\n\nfunc Add(a, b int) int { return a + b }\n";
|
||||
let tree = parse_go(src);
|
||||
let summary = FuncSummary {
|
||||
name: "Add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectGoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
106
src/dynamic/framework/adapters/redirect_java.rs
Normal file
106
src/dynamic/framework/adapters/redirect_java.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! Java [`super::super::FrameworkAdapter`] matching HTTP-redirect
|
||||
//! sink constructions (`HttpServletResponse.sendRedirect`,
|
||||
//! Spring `ResponseEntity` 302 builders).
|
||||
//!
|
||||
//! Phase 09 (Track J.7). Fires when the function body invokes one
|
||||
//! of the canonical servlet redirect entry points and the
|
||||
//! surrounding source imports a servlet API.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct RedirectJavaAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "redirect-java";
|
||||
|
||||
fn callee_is_redirect(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "sendRedirect" | "redirect")
|
||||
}
|
||||
|
||||
fn source_imports_servlet(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"javax.servlet",
|
||||
b"jakarta.servlet",
|
||||
b"HttpServletResponse",
|
||||
b"org.springframework.http",
|
||||
b"org.springframework.web.servlet",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for RedirectJavaAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Java
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
|
||||
let matches_source = source_imports_servlet(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_java(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_send_redirect() {
|
||||
let src: &[u8] = b"import javax.servlet.http.HttpServletResponse;\n\
|
||||
class C { void run(HttpServletResponse r, String v) { r.sendRedirect(v); } }\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("sendRedirect")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectJavaAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"class C { int add(int a, int b) { return a + b; } }\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectJavaAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
111
src/dynamic/framework/adapters/redirect_js.rs
Normal file
111
src/dynamic/framework/adapters/redirect_js.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! JavaScript [`super::super::FrameworkAdapter`] matching
|
||||
//! HTTP-redirect sink constructions (Express `res.redirect`,
|
||||
//! Koa `ctx.redirect`, raw Node `res.writeHead(302, { Location })`).
|
||||
//!
|
||||
//! Phase 09 (Track J.7). Fires when the function body invokes one
|
||||
//! of the canonical Node redirect entry points and the surrounding
|
||||
//! source imports the matching framework module.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct RedirectJsAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "redirect-js";
|
||||
|
||||
fn callee_is_redirect(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "redirect" | "writeHead")
|
||||
}
|
||||
|
||||
fn source_imports_node_web(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"require('express')",
|
||||
b"require(\"express\")",
|
||||
b"from 'express'",
|
||||
b"from \"express\"",
|
||||
b"require('koa')",
|
||||
b"require(\"koa\")",
|
||||
b"require('http')",
|
||||
b"require(\"http\")",
|
||||
b"res.redirect",
|
||||
b"ctx.redirect",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for RedirectJsAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::JavaScript
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
|
||||
let matches_source = source_imports_node_web(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_express_redirect() {
|
||||
let src: &[u8] = b"const express = require('express');\n\
|
||||
function run(req, res, v) { res.redirect(v); }\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("redirect")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectJsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"function add(a, b) { return a + b; }\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectJsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
111
src/dynamic/framework/adapters/redirect_php.rs
Normal file
111
src/dynamic/framework/adapters/redirect_php.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! PHP [`super::super::FrameworkAdapter`] matching HTTP-redirect
|
||||
//! sink constructions (`header("Location: ...")`,
|
||||
//! Symfony `RedirectResponse`, Slim `Response::withHeader`).
|
||||
//!
|
||||
//! Phase 09 (Track J.7). Fires when the function body invokes one
|
||||
//! of the canonical PHP redirect entry points and the surrounding
|
||||
//! source imports a recognised framework / writes a `Location:`
|
||||
//! header.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct RedirectPhpAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "redirect-php";
|
||||
|
||||
fn callee_is_redirect(name: &str) -> bool {
|
||||
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
|
||||
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
|
||||
matches!(
|
||||
last,
|
||||
"redirect" | "withRedirect" | "RedirectResponse" | "header"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_php_web(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"Symfony\\Component\\HttpFoundation",
|
||||
b"Slim\\Psr7",
|
||||
b"Psr\\Http\\Message",
|
||||
b"Location:",
|
||||
b"RedirectResponse",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for RedirectPhpAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Php
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
|
||||
let matches_source = source_imports_php_web(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_php(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_header_location() {
|
||||
let src: &[u8] =
|
||||
b"<?php\nfunction run($v) { header(\"Location: \" . $v); exit; }\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("header")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"<?php\nfunction add($a, $b) { return $a + $b; }\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
111
src/dynamic/framework/adapters/redirect_python.rs
Normal file
111
src/dynamic/framework/adapters/redirect_python.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! Python [`super::super::FrameworkAdapter`] matching HTTP-redirect
|
||||
//! sink constructions (`flask.redirect`, Django
|
||||
//! `HttpResponseRedirect`, FastAPI `RedirectResponse`).
|
||||
//!
|
||||
//! Phase 09 (Track J.7). Fires when the function body invokes one
|
||||
//! of the canonical Python web-framework redirect entry points and
|
||||
//! the surrounding source imports the matching framework module.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct RedirectPythonAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "redirect-python";
|
||||
|
||||
fn callee_is_redirect(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"redirect" | "HttpResponseRedirect" | "RedirectResponse"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_python_web(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"from flask",
|
||||
b"import flask",
|
||||
b"from django.http",
|
||||
b"from django.shortcuts",
|
||||
b"from starlette",
|
||||
b"from fastapi.responses",
|
||||
b"from werkzeug",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for RedirectPythonAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Python
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
|
||||
let matches_source = source_imports_python_web(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_flask_redirect() {
|
||||
let src: &[u8] = b"from flask import redirect\n\
|
||||
def run(value):\n return redirect(value)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("redirect")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectPythonAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"def add(a, b):\n return a + b\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectPythonAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
109
src/dynamic/framework/adapters/redirect_ruby.rs
Normal file
109
src/dynamic/framework/adapters/redirect_ruby.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! Ruby [`super::super::FrameworkAdapter`] matching HTTP-redirect
|
||||
//! sink constructions (Rails `redirect_to`, Sinatra `redirect`,
|
||||
//! `Rack::Response#redirect`).
|
||||
//!
|
||||
//! Phase 09 (Track J.7). Fires when the function body invokes one
|
||||
//! of the canonical Ruby web-framework redirect entry points and
|
||||
//! the surrounding source imports / references a recognised
|
||||
//! framework module.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct RedirectRubyAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "redirect-ruby";
|
||||
|
||||
fn callee_is_redirect(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "redirect" | "redirect_to" | "redirect!" )
|
||||
}
|
||||
|
||||
fn source_imports_ruby_web(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"Rack::Response",
|
||||
b"require 'rack",
|
||||
b"require \"rack",
|
||||
b"require 'sinatra",
|
||||
b"require \"sinatra",
|
||||
b"ActionController",
|
||||
b"Rails",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for RedirectRubyAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Ruby
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
|
||||
let matches_source = source_imports_ruby_web(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_ruby(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_rack_redirect() {
|
||||
let src: &[u8] = b"require 'rack'\n\
|
||||
def run(value)\n resp = Rack::Response.new\n resp.redirect(value)\n resp\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("redirect")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"def add(a, b)\n a + b\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
110
src/dynamic/framework/adapters/redirect_rust.rs
Normal file
110
src/dynamic/framework/adapters/redirect_rust.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//! Rust [`super::super::FrameworkAdapter`] matching HTTP-redirect
|
||||
//! sink constructions (`axum::response::Redirect::to`, actix-web
|
||||
//! `HttpResponse::Found().append_header(("Location", v))`).
|
||||
//!
|
||||
//! Phase 09 (Track J.7). Fires when the function body invokes one
|
||||
//! of the canonical Rust web-framework redirect entry points and the
|
||||
//! surrounding source imports the matching framework module.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct RedirectRustAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "redirect-rust";
|
||||
|
||||
fn callee_is_redirect(name: &str) -> bool {
|
||||
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
|
||||
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
|
||||
matches!(last, "to" | "redirect" | "temporary" | "permanent" | "Found")
|
||||
}
|
||||
|
||||
fn source_imports_rust_web(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"use axum::",
|
||||
b"axum::response::Redirect",
|
||||
b"use actix_web::",
|
||||
b"use rocket::",
|
||||
b"use warp::",
|
||||
b"Redirect::to",
|
||||
b"Redirect::permanent",
|
||||
b"Redirect::temporary",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for RedirectRustAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Rust
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
|
||||
let matches_source = source_imports_rust_web(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_rust(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_axum_redirect_to() {
|
||||
let src: &[u8] =
|
||||
b"use axum::response::Redirect;\n\nfn run(v: String) -> Redirect { Redirect::to(&v) }\n";
|
||||
let tree = parse_rust(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("to")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectRustAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"fn add(a: i32, b: i32) -> i32 { a + b }\n";
|
||||
let tree = parse_rust(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RedirectRustAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -513,6 +513,14 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
|
||||
// Phase 09 (Track J.7): OPEN_REDIRECT-sink short-circuit. The Go
|
||||
// harness models `c.Redirect(http.StatusFound, value)` (and
|
||||
// `http.Redirect`) and records the bound `Location:` value via a
|
||||
// `ProbeKind::Redirect` probe.
|
||||
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
|
||||
return Ok(emit_open_redirect_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = GoShape::detect(spec, &entry_source);
|
||||
let main_go = generate_main_go(spec, shape);
|
||||
|
|
@ -680,6 +688,66 @@ func main() {{
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 09 — Track J.7 open-redirect harness for Go (`gin.Context.Redirect`
|
||||
/// / `http.Redirect`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented redirect shim
|
||||
/// that records the bound `Location:` value plus the request's
|
||||
/// origin host via a `ProbeKind::Redirect` probe.
|
||||
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let go_mod = generate_go_mod();
|
||||
let source = format!(
|
||||
r##"// Nyx dynamic harness — OPEN_REDIRECT c.Redirect (Phase 09 / Track J.7).
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
{shim}
|
||||
|
||||
func nyxRedirectProbe(location, requestHost string) {{
|
||||
__nyx_emit(map[string]interface{{}}{{
|
||||
"sink_callee": "gin.Context.Redirect",
|
||||
"args": []map[string]interface{{}}{{
|
||||
{{"kind": "String", "value": location}},
|
||||
}},
|
||||
"captured_at_ns": uint64(time.Now().UnixNano()),
|
||||
"payload_id": os.Getenv("NYX_PAYLOAD_ID"),
|
||||
"kind": map[string]interface{{}}{{"kind": "Redirect", "location": location, "request_host": requestHost}},
|
||||
"witness": __nyx_witness("gin.Context.Redirect", []string{{location}}),
|
||||
}})
|
||||
}}
|
||||
|
||||
func main() {{
|
||||
__nyx_install_crash_guard("gin.Context.Redirect")
|
||||
defer __nyx_recover_crash("gin.Context.Redirect")()
|
||||
payload := os.Getenv("NYX_PAYLOAD")
|
||||
requestHost := "example.com"
|
||||
location := payload
|
||||
nyxRedirectProbe(location, requestHost)
|
||||
fmt.Println("__NYX_SINK_HIT__")
|
||||
body, _ := json.Marshal(map[string]interface{{}}{{"location": location, "request_host": requestHost}})
|
||||
fmt.Println(string(body))
|
||||
}}
|
||||
"##
|
||||
);
|
||||
HarnessSource {
|
||||
source,
|
||||
filename: "main.go".to_owned(),
|
||||
command: vec!["./nyx_harness".to_owned()],
|
||||
extra_files: vec![("go.mod".to_owned(), go_mod)],
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_main_go(spec: &HarnessSpec, shape: GoShape) -> String {
|
||||
let entry_fn = capitalize_first(&spec.entry_name);
|
||||
let pre_call = pre_call_setup(spec);
|
||||
|
|
|
|||
|
|
@ -570,6 +570,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
|
||||
return Ok(emit_open_redirect_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = JavaShape::detect(spec, &entry_source);
|
||||
|
|
@ -1293,6 +1296,85 @@ public class NyxHarness {{
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 09 — Track J.7 open-redirect harness for Java
|
||||
/// (`HttpServletResponse.sendRedirect`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||
/// `response.sendRedirect(value)` shim that records the *unmodified*
|
||||
/// `Location:` value plus the request's origin host via a
|
||||
/// `ProbeKind::Redirect` probe. Mirrors the synthetic-harness
|
||||
/// pattern used by Phase 03 / 04 / 05 / 06 / 07 / 08.
|
||||
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let source = format!(
|
||||
r#"// Nyx dynamic harness — OPEN_REDIRECT HttpServletResponse.sendRedirect (Phase 09 / Track J.7).
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
|
||||
public class NyxHarness {{
|
||||
{shim}
|
||||
|
||||
static void nyxRedirectProbe(String location, String requestHost) {{
|
||||
String p = System.getenv("NYX_PROBE_PATH");
|
||||
if (p == null || p.isEmpty()) return;
|
||||
long now = System.nanoTime();
|
||||
String pid = System.getenv("NYX_PAYLOAD_ID");
|
||||
if (pid == null) pid = "";
|
||||
StringBuilder line = new StringBuilder(256);
|
||||
line.append("{{\"sink_callee\":\"HttpServletResponse.sendRedirect\",\"args\":[");
|
||||
line.append("{{\"kind\":\"String\",\"value\":\"");
|
||||
nyxJsonEscape(location, line);
|
||||
line.append("\"}}],");
|
||||
line.append("\"captured_at_ns\":").append(now).append(',');
|
||||
line.append("\"payload_id\":\"");
|
||||
nyxJsonEscape(pid, line);
|
||||
line.append("\",\"kind\":{{\"kind\":\"Redirect\",\"location\":\"");
|
||||
nyxJsonEscape(location, line);
|
||||
line.append("\",\"request_host\":\"");
|
||||
nyxJsonEscape(requestHost, line);
|
||||
line.append("\"}},");
|
||||
line.append("\"witness\":");
|
||||
line.append(nyxWitnessJson("HttpServletResponse.sendRedirect", new String[]{{location}}));
|
||||
line.append("}}\n");
|
||||
try (FileWriter fw = new FileWriter(p, true)) {{
|
||||
fw.write(line.toString());
|
||||
}} catch (IOException e) {{
|
||||
// best-effort
|
||||
}}
|
||||
}}
|
||||
|
||||
public static void main(String[] args) {{
|
||||
String payload = System.getenv("NYX_PAYLOAD");
|
||||
if (payload == null) payload = "";
|
||||
String requestHost = "example.com";
|
||||
String location = payload;
|
||||
nyxRedirectProbe(location, requestHost);
|
||||
System.out.println("__NYX_SINK_HIT__");
|
||||
StringBuilder body = new StringBuilder(64);
|
||||
body.append("{{\"location\":\"");
|
||||
nyxJsonEscape(location, body);
|
||||
body.append("\",\"request_host\":\"");
|
||||
nyxJsonEscape(requestHost, body);
|
||||
body.append("\"}}");
|
||||
System.out.println(body.toString());
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source,
|
||||
filename: "NyxHarness.java".to_owned(),
|
||||
command: vec![
|
||||
"java".to_owned(),
|
||||
"-cp".to_owned(),
|
||||
".".to_owned(),
|
||||
"NyxHarness".to_owned(),
|
||||
],
|
||||
extra_files: Vec::new(),
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
|
||||
/// reading the entry file from disk. Exposed so test helpers can pin a
|
||||
/// per-fixture shape without round-tripping through [`emit`].
|
||||
|
|
|
|||
|
|
@ -457,6 +457,14 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result<HarnessSource, Un
|
|||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
|
||||
// Phase 09 (Track J.7): OPEN_REDIRECT-sink short-circuit. The
|
||||
// synthetic harness calls an instrumented `res.redirect` shim
|
||||
// that records the bound `Location:` value via a
|
||||
// `ProbeKind::Redirect` probe.
|
||||
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
|
||||
return Ok(emit_open_redirect_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = JsShape::detect(spec, &entry_source);
|
||||
let entry_subpath = entry_subpath_for_shape(shape, is_typescript);
|
||||
|
|
@ -670,6 +678,56 @@ console.log(JSON.stringify({{ name: name, value: value }}));
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 09 — Track J.7 open-redirect harness for Node (Express
|
||||
/// `res.redirect`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||
/// `res.redirect(value)` shim that records the bound `Location:`
|
||||
/// value plus the request's origin host via a `ProbeKind::Redirect`
|
||||
/// probe.
|
||||
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let body = format!(
|
||||
r#"// Nyx dynamic harness — OPEN_REDIRECT res.redirect (Phase 09 / Track J.7).
|
||||
{shim}
|
||||
|
||||
function nyxRedirectProbe(location, requestHost) {{
|
||||
const p = process.env.NYX_PROBE_PATH;
|
||||
if (!p) return;
|
||||
const rec = {{
|
||||
sink_callee: 'res.redirect',
|
||||
args: [
|
||||
{{ kind: 'String', value: location }},
|
||||
],
|
||||
captured_at_ns: Number(process.hrtime.bigint()),
|
||||
payload_id: process.env.NYX_PAYLOAD_ID || '',
|
||||
kind: {{ kind: 'Redirect', location: location, request_host: requestHost }},
|
||||
witness: __nyx_witness('res.redirect', [location]),
|
||||
}};
|
||||
try {{
|
||||
require('fs').appendFileSync(p, JSON.stringify(rec) + '\n');
|
||||
}} catch (e) {{
|
||||
// best-effort
|
||||
}}
|
||||
}}
|
||||
|
||||
const payload = process.env.NYX_PAYLOAD || '';
|
||||
const requestHost = 'example.com';
|
||||
const location = payload;
|
||||
nyxRedirectProbe(location, requestHost);
|
||||
console.log('__NYX_SINK_HIT__');
|
||||
console.log(JSON.stringify({{ location: location, request_host: requestHost }}));
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.js".to_owned(),
|
||||
command: vec!["node".to_owned(), "harness.js".to_owned()],
|
||||
extra_files: Vec::new(),
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 26 — Node chain-step harness (shared between JS + TS emitters).
|
||||
///
|
||||
/// Splices the Node probe shim ([`probe_shim`]) in front of a minimal
|
||||
|
|
|
|||
|
|
@ -436,6 +436,10 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
// Phase 09 (Track J.7): OPEN_REDIRECT-sink short-circuit.
|
||||
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
|
||||
return Ok(emit_open_redirect_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = PhpShape::detect(spec, &entry_source);
|
||||
|
|
@ -921,6 +925,57 @@ echo json_encode(['name' => $name, 'value' => $value]) . "\n";
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 09 — Track J.7 open-redirect harness for PHP (`header("Location: …")` /
|
||||
/// `Response::redirect`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented redirect shim
|
||||
/// that records the bound `Location:` value plus the request's origin
|
||||
/// host via a `ProbeKind::Redirect` probe. Mirrors the
|
||||
/// synthetic-harness pattern used by Phase 03 / 04 / 05 / 06 / 07 / 08.
|
||||
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let body = format!(
|
||||
r#"<?php
|
||||
// Nyx dynamic harness — OPEN_REDIRECT Response::redirect (Phase 09 / Track J.7).
|
||||
{shim}
|
||||
|
||||
function _nyx_redirect_probe(string $location, string $requestHost): void {{
|
||||
$p = getenv('NYX_PROBE_PATH');
|
||||
if ($p === false || $p === '') return;
|
||||
$rec = [
|
||||
'sink_callee' => 'Response::redirect',
|
||||
'args' => [
|
||||
['kind' => 'String', 'value' => $location],
|
||||
],
|
||||
'captured_at_ns' => (int) hrtime(true),
|
||||
'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''),
|
||||
'kind' => [
|
||||
'kind' => 'Redirect',
|
||||
'location' => $location,
|
||||
'request_host' => $requestHost,
|
||||
],
|
||||
'witness' => __nyx_witness('Response::redirect', [$location]),
|
||||
];
|
||||
@file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND);
|
||||
}}
|
||||
|
||||
$payload = (string) (getenv('NYX_PAYLOAD') ?: '');
|
||||
$requestHost = 'example.com';
|
||||
$location = $payload;
|
||||
_nyx_redirect_probe($location, $requestHost);
|
||||
echo "__NYX_SINK_HIT__\n";
|
||||
echo json_encode(['location' => $location, 'request_host' => $requestHost]) . "\n";
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.php".to_owned(),
|
||||
command: vec!["php".to_owned(), "harness.php".to_owned()],
|
||||
extra_files: Vec::new(),
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String {
|
||||
let entry_fn = &spec.entry_name;
|
||||
let pre_call = build_pre_call(spec, shape);
|
||||
|
|
|
|||
|
|
@ -650,6 +650,16 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
|
||||
// Phase 09 (Track J.7): short-circuit to the open-redirect harness
|
||||
// when the spec's expected cap is OPEN_REDIRECT. The harness
|
||||
// splices the payload into a synthetic `flask.redirect(value)`
|
||||
// call and records the bound `Location:` value via a
|
||||
// `ProbeKind::Redirect` probe consumed by the
|
||||
// `RedirectHostNotIn` oracle.
|
||||
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
|
||||
return Ok(emit_open_redirect_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = PythonShape::detect(spec, &entry_source);
|
||||
let body = generate_for_shape(spec, shape);
|
||||
|
|
@ -1150,6 +1160,70 @@ def _nyx_run():
|
|||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_nyx_run()
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.py".to_owned(),
|
||||
command: vec!["python3".to_owned(), "harness.py".to_owned()],
|
||||
extra_files: Vec::new(),
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 09 — Track J.7 open-redirect harness for Python
|
||||
/// (`flask.redirect`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||
/// `flask.redirect(value)` shim that records the bound `Location:`
|
||||
/// value plus the request's origin host via a `ProbeKind::Redirect`
|
||||
/// probe. A vuln payload binding `https://attacker.test/` trips the
|
||||
/// [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`]
|
||||
/// oracle; the paired benign control redirects to a same-origin
|
||||
/// path and leaves the predicate clear.
|
||||
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let probe = probe_shim();
|
||||
let body = format!(
|
||||
r#"#!/usr/bin/env python3
|
||||
"""Nyx dynamic harness — OPEN_REDIRECT flask.redirect (Phase 09 / Track J.7)."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
{probe}
|
||||
|
||||
|
||||
def _nyx_redirect_probe(location, request_host):
|
||||
rec = {{
|
||||
"sink_callee": "flask.redirect",
|
||||
"args": [
|
||||
{{"kind": "String", "value": location}},
|
||||
],
|
||||
"captured_at_ns": time.time_ns(),
|
||||
"payload_id": os.environ.get("NYX_PAYLOAD_ID", ""),
|
||||
"kind": {{
|
||||
"kind": "Redirect",
|
||||
"location": location,
|
||||
"request_host": request_host,
|
||||
}},
|
||||
"witness": __nyx_witness("flask.redirect", [location]),
|
||||
}}
|
||||
__nyx_emit(rec)
|
||||
|
||||
|
||||
def _nyx_run():
|
||||
payload = os.environ.get("NYX_PAYLOAD", "")
|
||||
request_host = "example.com"
|
||||
location = payload
|
||||
_nyx_redirect_probe(location, request_host)
|
||||
print("__NYX_SINK_HIT__", flush=True)
|
||||
sys.stdout.write(json.dumps({{"location": location, "request_host": request_host}}) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_nyx_run()
|
||||
"#
|
||||
|
|
|
|||
|
|
@ -427,6 +427,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
|
||||
return Ok(emit_open_redirect_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = RubyShape::detect(spec, &entry_source);
|
||||
|
|
@ -670,6 +673,55 @@ STDOUT.flush
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 09 — Track J.7 open-redirect harness for Ruby
|
||||
/// (`Rack::Response#redirect`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||
/// `response.redirect(value)` shim that records the bound
|
||||
/// `Location:` value plus the request's origin host via a
|
||||
/// `ProbeKind::Redirect` probe.
|
||||
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let body = format!(
|
||||
r#"# Nyx dynamic harness — OPEN_REDIRECT Rack::Response#redirect (Phase 09 / Track J.7).
|
||||
require 'json'
|
||||
|
||||
{shim}
|
||||
|
||||
def _nyx_redirect_probe(location, request_host)
|
||||
p = ENV['NYX_PROBE_PATH']
|
||||
return if p.nil? || p.empty?
|
||||
rec = {{
|
||||
'sink_callee' => 'Rack::Response#redirect',
|
||||
'args' => [
|
||||
{{ 'kind' => 'String', 'value' => location }},
|
||||
],
|
||||
'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond),
|
||||
'payload_id' => ENV['NYX_PAYLOAD_ID'] || '',
|
||||
'kind' => {{ 'kind' => 'Redirect', 'location' => location, 'request_host' => request_host }},
|
||||
'witness' => __nyx_witness('Rack::Response#redirect', [location]),
|
||||
}}
|
||||
File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }}
|
||||
end
|
||||
|
||||
payload = ENV['NYX_PAYLOAD'] || ''
|
||||
request_host = 'example.com'
|
||||
location = payload
|
||||
_nyx_redirect_probe(location, request_host)
|
||||
STDOUT.puts '__NYX_SINK_HIT__'
|
||||
STDOUT.puts JSON.generate({{ 'location' => location, 'request_host' => request_host }})
|
||||
STDOUT.flush
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.rb".to_owned(),
|
||||
command: vec!["ruby".to_owned(), "harness.rb".to_owned()],
|
||||
extra_files: vec![],
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String {
|
||||
let entry_fn = &spec.entry_name;
|
||||
let pre_call = build_pre_call(spec);
|
||||
|
|
|
|||
|
|
@ -647,6 +647,93 @@ fn main() {{
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 09 — Track J.7 open-redirect harness for Rust
|
||||
/// (`axum::response::Redirect::to`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||
/// `Redirect::to(value)` shim that records the bound `Location:`
|
||||
/// value plus the request's origin host via a `ProbeKind::Redirect`
|
||||
/// probe. Std-only — no `Cargo.toml` dependencies beyond the
|
||||
/// always-pinned `libc`.
|
||||
pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let cargo_toml = generate_cargo_toml(Cap::OPEN_REDIRECT);
|
||||
let main_rs = format!(
|
||||
r##"//! Nyx dynamic harness — OPEN_REDIRECT Redirect::to (Phase 09 / Track J.7).
|
||||
use std::env;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::time::{{SystemTime, UNIX_EPOCH}};
|
||||
|
||||
{shim}
|
||||
|
||||
fn nyx_json_escape(s: &str) -> String {{
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
for c in s.chars() {{
|
||||
match c {{
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => {{
|
||||
out.push_str(&format!("\\u{{:04x}}", c as u32));
|
||||
}}
|
||||
c => out.push(c),
|
||||
}}
|
||||
}}
|
||||
out
|
||||
}}
|
||||
|
||||
fn nyx_redirect_probe(location: &str, request_host: &str) {{
|
||||
let p = match env::var("NYX_PROBE_PATH") {{ Ok(s) => s, Err(_) => return }};
|
||||
if p.is_empty() {{ return; }}
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
|
||||
let pid = env::var("NYX_PAYLOAD_ID").unwrap_or_default();
|
||||
let mut line = String::new();
|
||||
line.push_str("{{\"sink_callee\":\"Redirect::to\",\"args\":[");
|
||||
line.push_str("{{\"kind\":\"String\",\"value\":\"");
|
||||
line.push_str(&nyx_json_escape(location));
|
||||
line.push_str("\"}}],");
|
||||
line.push_str("\"captured_at_ns\":");
|
||||
line.push_str(&now.to_string());
|
||||
line.push_str(",\"payload_id\":\"");
|
||||
line.push_str(&nyx_json_escape(&pid));
|
||||
line.push_str("\",\"kind\":{{\"kind\":\"Redirect\",\"location\":\"");
|
||||
line.push_str(&nyx_json_escape(location));
|
||||
line.push_str("\",\"request_host\":\"");
|
||||
line.push_str(&nyx_json_escape(request_host));
|
||||
line.push_str("\"}},\"witness\":{{}}}}\n");
|
||||
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&p) {{
|
||||
let _ = f.write_all(line.as_bytes());
|
||||
}}
|
||||
}}
|
||||
|
||||
fn main() {{
|
||||
let payload = env::var("NYX_PAYLOAD").unwrap_or_default();
|
||||
let request_host = "example.com";
|
||||
let location = &payload;
|
||||
nyx_redirect_probe(location, request_host);
|
||||
println!("__NYX_SINK_HIT__");
|
||||
let mut body = String::new();
|
||||
body.push_str("{{\"location\":\"");
|
||||
body.push_str(&nyx_json_escape(location));
|
||||
body.push_str("\",\"request_host\":\"");
|
||||
body.push_str(&nyx_json_escape(request_host));
|
||||
body.push_str("\"}}");
|
||||
println!("{{body}}", body = body);
|
||||
}}
|
||||
"##
|
||||
);
|
||||
HarnessSource {
|
||||
source: main_rs,
|
||||
filename: "src/main.rs".into(),
|
||||
command: vec!["target/release/nyx_harness".into()],
|
||||
extra_files: vec![("Cargo.toml".into(), cargo_toml)],
|
||||
entry_subpath: Some("src/entry.rs".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_entry_source(entry_file: &str) -> String {
|
||||
let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)];
|
||||
for path in &candidates {
|
||||
|
|
@ -667,6 +754,14 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
|
||||
// Phase 09 (Track J.7): OPEN_REDIRECT-sink short-circuit. The
|
||||
// Rust harness models an `axum`-style `Redirect::to(value)` shim
|
||||
// that records the bound `Location:` value via a
|
||||
// `ProbeKind::Redirect` probe.
|
||||
if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT {
|
||||
return Ok(emit_open_redirect_harness(spec));
|
||||
}
|
||||
|
||||
let shape = detect_shape(spec);
|
||||
|
||||
// Generic + LibfuzzerTarget accept Param(0)/EnvVar; richer shapes
|
||||
|
|
|
|||
|
|
@ -265,6 +265,29 @@ pub enum ProbePredicate {
|
|||
/// captured header whose value contains the CRLF pair.
|
||||
header_name: &'static str,
|
||||
},
|
||||
/// Phase 09 (Track J.7): open-redirect predicate.
|
||||
///
|
||||
/// Fires when at least one drained probe carries
|
||||
/// [`ProbeKind::Redirect`] whose extracted `location` host falls
|
||||
/// outside `allowlist`. Same-origin redirects (the `location`
|
||||
/// host equals `request_host`, or the location is a relative
|
||||
/// path) never fire — they cannot leave the application origin
|
||||
/// regardless of allowlist contents. Hosts are compared
|
||||
/// case-insensitively against the allowlist entries; schemeless
|
||||
/// `//host/...` references are parsed as off-origin.
|
||||
///
|
||||
/// Cross-cutting in the same sense as
|
||||
/// [`Self::DeserializeGadgetInvoked`] /
|
||||
/// [`Self::XxeEntityExpanded`] /
|
||||
/// [`Self::HeaderInjected`] — evaluated across every drained
|
||||
/// probe rather than against a single record.
|
||||
RedirectHostNotIn {
|
||||
/// Allowlist of origin hosts the application is willing to
|
||||
/// redirect into (e.g. `&["example.com", "www.example.com"]`).
|
||||
/// `request_host` is implicitly allowed even when absent
|
||||
/// from this slice.
|
||||
allowlist: &'static [&'static str],
|
||||
},
|
||||
/// Phase 06 (Track J.4) / Phase 07 (Track J.5): result-count
|
||||
/// predicate shared by LDAP-filter and XPath-expression injection.
|
||||
///
|
||||
|
|
@ -444,6 +467,21 @@ pub fn oracle_fired_with_stubs(
|
|||
if !header_injected_ok {
|
||||
return false;
|
||||
}
|
||||
// Phase 09 (Track J.7): open-redirect cross-cutting
|
||||
// predicates. Each `RedirectHostNotIn { allowlist }`
|
||||
// consults the captured probe channel for a
|
||||
// [`ProbeKind::Redirect`] record whose `location` host
|
||||
// resolves off-origin relative to `allowlist ∪
|
||||
// {request_host}`.
|
||||
let redirect_ok = cross.iter().all(|p| match p {
|
||||
ProbePredicate::RedirectHostNotIn { allowlist } => {
|
||||
probes_satisfy_redirect_off_origin(probes, allowlist)
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
if !redirect_ok {
|
||||
return false;
|
||||
}
|
||||
// Phase 04 (Track J.2): SSTI render-equality cross-cutting
|
||||
// predicates. Each `TemplateEvalEqual { expected }` consults
|
||||
// the captured stdout body — see [`stdout_template_equals`].
|
||||
|
|
@ -476,7 +514,8 @@ pub fn oracle_fired_with_stubs(
|
|||
| ProbeKind::Xxe { .. }
|
||||
| ProbeKind::Ldap { .. }
|
||||
| ProbeKind::Xpath { .. }
|
||||
| ProbeKind::HeaderEmit { .. } => false,
|
||||
| ProbeKind::HeaderEmit { .. }
|
||||
| ProbeKind::Redirect { .. } => false,
|
||||
}),
|
||||
Oracle::OutputContains(needle) => {
|
||||
let nb = needle.as_bytes();
|
||||
|
|
@ -504,6 +543,7 @@ fn is_cross_cutting(pred: &ProbePredicate) -> bool {
|
|||
| ProbePredicate::XxeEntityExpanded { .. }
|
||||
| ProbePredicate::QueryResultCountGreaterThan { .. }
|
||||
| ProbePredicate::HeaderInjected { .. }
|
||||
| ProbePredicate::RedirectHostNotIn { .. }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -532,6 +572,10 @@ fn cross_cutting_satisfied(pred: &ProbePredicate, stub_events: &[StubEvent]) ->
|
|||
// rather than stub events; evaluated separately in
|
||||
// [`probes_satisfy_header_injected`] below.
|
||||
ProbePredicate::HeaderInjected { .. } => true,
|
||||
// RedirectHostNotIn is cross-cutting against the *probe log*
|
||||
// rather than stub events; evaluated separately in
|
||||
// [`probes_satisfy_redirect_off_origin`] below.
|
||||
ProbePredicate::RedirectHostNotIn { .. } => true,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -623,6 +667,86 @@ fn probes_satisfy_header_injected(probes: &[SinkProbe], header_name: &str) -> bo
|
|||
})
|
||||
}
|
||||
|
||||
/// True when at least one drained probe is a [`ProbeKind::Redirect`]
|
||||
/// record whose extracted `location` host falls outside the
|
||||
/// `allowlist ∪ {request_host}` set. Powers
|
||||
/// [`ProbePredicate::RedirectHostNotIn`] (Phase 09 — Track J.7).
|
||||
///
|
||||
/// Same-origin redirects (relative path, or absolute URL whose host
|
||||
/// equals `request_host`) never fire — they cannot leave the
|
||||
/// application origin regardless of allowlist contents. Schemeless
|
||||
/// `//host/...` references are parsed as off-origin.
|
||||
fn probes_satisfy_redirect_off_origin(probes: &[SinkProbe], allowlist: &[&str]) -> bool {
|
||||
probes.iter().any(|p| match &p.kind {
|
||||
ProbeKind::Redirect { location, request_host } => {
|
||||
redirect_is_off_origin(location, request_host, allowlist)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `true` when `location` redirects to a host that is neither
|
||||
/// `request_host` nor any entry of `allowlist`. Public for the
|
||||
/// per-language harness shim's mirror tests; the predicate above is
|
||||
/// the only production caller.
|
||||
pub fn redirect_is_off_origin(
|
||||
location: &str,
|
||||
request_host: &str,
|
||||
allowlist: &[&str],
|
||||
) -> bool {
|
||||
let Some(host) = extract_redirect_host(location) else {
|
||||
// No host component (relative path) → same-origin → safe.
|
||||
return false;
|
||||
};
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
if !request_host.is_empty()
|
||||
&& host_lower == request_host.trim().to_ascii_lowercase()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
!allowlist
|
||||
.iter()
|
||||
.any(|h| host_lower == h.trim().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Extract the host component from a `Location:` value. Returns
|
||||
/// `None` for a relative path (no scheme, no leading `//`).
|
||||
///
|
||||
/// Recognises three shapes:
|
||||
/// 1. `scheme://host/path` — yields `host`.
|
||||
/// 2. `//host/path` (schemeless / protocol-relative) — yields `host`.
|
||||
/// 3. `/path` or `path` — yields `None` (same-origin).
|
||||
fn extract_redirect_host(location: &str) -> Option<String> {
|
||||
let trimmed = location.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let rest = if let Some(after_scheme) = trimmed.find("://") {
|
||||
&trimmed[after_scheme + 3..]
|
||||
} else if let Some(stripped) = trimmed.strip_prefix("//") {
|
||||
stripped
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
// Strip path / query / fragment from the host segment.
|
||||
let end = rest
|
||||
.find(|c: char| matches!(c, '/' | '?' | '#'))
|
||||
.unwrap_or(rest.len());
|
||||
let authority = &rest[..end];
|
||||
// Strip userinfo + port.
|
||||
let after_userinfo = authority.rsplit_once('@').map(|(_, h)| h).unwrap_or(authority);
|
||||
let host_only = after_userinfo
|
||||
.rsplit_once(':')
|
||||
.map(|(h, _)| h)
|
||||
.unwrap_or(after_userinfo);
|
||||
let h = host_only.trim();
|
||||
if h.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(h.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when `probe` satisfies *every* predicate in `preds`.
|
||||
/// An empty predicate slice satisfies vacuously — a payload that wants
|
||||
/// "any probe at all" can ship an empty predicate set.
|
||||
|
|
@ -657,7 +781,8 @@ fn probe_satisfies_one(probe: &SinkProbe, pred: &ProbePredicate) -> bool {
|
|||
| ProbePredicate::TemplateEvalEqual { .. }
|
||||
| ProbePredicate::XxeEntityExpanded { .. }
|
||||
| ProbePredicate::QueryResultCountGreaterThan { .. }
|
||||
| ProbePredicate::HeaderInjected { .. } => true,
|
||||
| ProbePredicate::HeaderInjected { .. }
|
||||
| ProbePredicate::RedirectHostNotIn { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -684,7 +809,8 @@ pub fn probe_crash_signal(probe: &SinkProbe) -> Option<Signal> {
|
|||
| ProbeKind::Xxe { .. }
|
||||
| ProbeKind::Ldap { .. }
|
||||
| ProbeKind::Xpath { .. }
|
||||
| ProbeKind::HeaderEmit { .. } => None,
|
||||
| ProbeKind::HeaderEmit { .. }
|
||||
| ProbeKind::Redirect { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -920,6 +1046,102 @@ mod tests {
|
|||
assert!(oracle_fired(&oracle, &o, &[]));
|
||||
}
|
||||
|
||||
fn redirect_probe(location: &str, request_host: &str) -> SinkProbe {
|
||||
SinkProbe {
|
||||
sink_callee: "HttpServletResponse.sendRedirect".into(),
|
||||
args: vec![],
|
||||
captured_at_ns: 1,
|
||||
payload_id: "phase09".into(),
|
||||
kind: ProbeKind::Redirect {
|
||||
location: location.into(),
|
||||
request_host: request_host.into(),
|
||||
},
|
||||
witness: ProbeWitness::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_off_origin_fires_when_host_outside_allowlist() {
|
||||
let oracle = Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: &["example.com", "www.example.com"],
|
||||
}],
|
||||
};
|
||||
let probes = vec![redirect_probe("https://attacker.test/", "example.com")];
|
||||
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_off_origin_clears_on_same_origin_path() {
|
||||
let oracle = Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: &["example.com"],
|
||||
}],
|
||||
};
|
||||
let probes = vec![redirect_probe("/dashboard", "example.com")];
|
||||
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_off_origin_clears_on_allowlisted_host() {
|
||||
let oracle = Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: &["example.com", "cdn.example.com"],
|
||||
}],
|
||||
};
|
||||
let probes = vec![redirect_probe("https://cdn.example.com/asset", "example.com")];
|
||||
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_off_origin_clears_when_host_matches_request_host() {
|
||||
let oracle = Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn { allowlist: &[] }],
|
||||
};
|
||||
let probes = vec![redirect_probe("https://example.com/dashboard", "example.com")];
|
||||
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_off_origin_fires_on_schemeless_authority() {
|
||||
let oracle = Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: &["example.com"],
|
||||
}],
|
||||
};
|
||||
let probes = vec![redirect_probe("//attacker.test/path", "example.com")];
|
||||
assert!(oracle_fired(&oracle, &outcome(), &probes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_off_origin_ignores_unrelated_probes() {
|
||||
let oracle = Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: &["example.com"],
|
||||
}],
|
||||
};
|
||||
let probes = vec![probe("noop", vec![])];
|
||||
assert!(!oracle_fired(&oracle, &outcome(), &probes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_redirect_host_handles_authority_variants() {
|
||||
assert_eq!(
|
||||
extract_redirect_host("https://attacker.test/path"),
|
||||
Some("attacker.test".to_owned()),
|
||||
);
|
||||
assert_eq!(
|
||||
extract_redirect_host("//attacker.test:8080/path"),
|
||||
Some("attacker.test".to_owned()),
|
||||
);
|
||||
assert_eq!(
|
||||
extract_redirect_host("https://user:pass@evil.example/?q=1"),
|
||||
Some("evil.example".to_owned()),
|
||||
);
|
||||
assert_eq!(extract_redirect_host("/dashboard"), None);
|
||||
assert_eq!(extract_redirect_host(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sink_crash_without_probes_does_not_fire_even_on_process_crash() {
|
||||
let mut o = outcome();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
41
src/dynamic/sandbox_profiles/open_redirect.sb
Normal file
41
src/dynamic/sandbox_profiles/open_redirect.sb
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
;; Phase 09 (Track J.7) — OPEN_REDIRECT profile.
|
||||
;;
|
||||
;; Inherits the SSRF profile's outbound-allowed, secret-files-denied
|
||||
;; shape — the open-redirect oracle only needs to inspect the
|
||||
;; captured `Location:` header value, so no extra network reach is
|
||||
;; required. The Phase 09 brief calls out loopback DNS resolution
|
||||
;; for `attacker.test`: macOS sandbox-exec already permits loopback
|
||||
;; via `(allow default)`, so the addendum is a documentation marker
|
||||
;; rather than an enforcement change. The Linux seccomp profile
|
||||
;; (see `seccomp_policy.toml::[cap.OPEN_REDIRECT]`) opens the same
|
||||
;; socket / connect / sendto family the SSRF cap uses, which covers
|
||||
;; the loopback resolver path on linux as well.
|
||||
|
||||
(version 1)
|
||||
(allow default)
|
||||
|
||||
;; Secret-file denylist (mirrors `ssrf.sb`) so an attacker who pivots
|
||||
;; from an open redirect to a host-side file read still cannot
|
||||
;; exfiltrate the canonical macOS secret stores.
|
||||
(deny file-read*
|
||||
(literal "/etc/passwd")
|
||||
(literal "/etc/master.passwd")
|
||||
(literal "/etc/shadow")
|
||||
(literal "/etc/sudoers")
|
||||
(literal "/private/etc/passwd")
|
||||
(literal "/private/etc/master.passwd")
|
||||
(literal "/private/etc/shadow")
|
||||
(literal "/private/etc/sudoers")
|
||||
(regex #"^/Users/[^/]+/\.ssh(/|$)")
|
||||
(regex #"^/Users/[^/]+/\.aws(/|$)")
|
||||
(regex #"^/Users/[^/]+/\.gnupg(/|$)")
|
||||
(regex #"^/Users/[^/]+/\.netrc$")
|
||||
(regex #"^/Users/[^/]+/\.docker(/|$)")
|
||||
(regex #"^/Users/[^/]+/\.kube(/|$)")
|
||||
(regex #"^/Users/[^/]+/\.config/gh(/|$)")
|
||||
(regex #"^/Users/[^/]+/Library/Keychains(/|$)")
|
||||
(regex #"^/Users/[^/]+/Library/Cookies(/|$)")
|
||||
(regex #"^/Users/[^/]+/Library/Mail(/|$)")
|
||||
(regex #"^/Users/[^/]+/Library/Application Support/com\.apple\.TCC(/|$)")
|
||||
(regex #"^/Users/[^/]+/Library/Application Support/Slack(/|$)")
|
||||
(subpath "/Library/Keychains"))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
16
tests/dynamic_fixtures/open_redirect/go/benign.go
Normal file
16
tests/dynamic_fixtures/open_redirect/go/benign.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Phase 09 (Track J.7) — Go OPEN_REDIRECT benign control fixture.
|
||||
//
|
||||
// The handler ignores the attacker-supplied value and redirects to a
|
||||
// same-origin path; the captured `Location:` header carries no
|
||||
// off-origin authority.
|
||||
package vuln
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Run(c *gin.Context, value string) {
|
||||
c.Redirect(http.StatusFound, "/dashboard")
|
||||
}
|
||||
16
tests/dynamic_fixtures/open_redirect/go/vuln.go
Normal file
16
tests/dynamic_fixtures/open_redirect/go/vuln.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Phase 09 (Track J.7) — Go OPEN_REDIRECT vuln fixture.
|
||||
//
|
||||
// The gin handler splices `value` straight into
|
||||
// `gin.Context.Redirect` without host validation; an attacker URL
|
||||
// routes the captured `Location:` header off-origin.
|
||||
package vuln
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Run(c *gin.Context, value string) {
|
||||
c.Redirect(http.StatusFound, value)
|
||||
}
|
||||
12
tests/dynamic_fixtures/open_redirect/java/Benign.java
Normal file
12
tests/dynamic_fixtures/open_redirect/java/Benign.java
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Phase 09 (Track J.7) — Java OPEN_REDIRECT benign control fixture.
|
||||
//
|
||||
// The function ignores the attacker-supplied value and always
|
||||
// redirects to the same-origin path `/dashboard`, so the captured
|
||||
// `Location:` header has no off-origin authority.
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
public class Benign {
|
||||
public static void run(HttpServletResponse response, String value) throws Exception {
|
||||
response.sendRedirect("/dashboard");
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/open_redirect/java/Vuln.java
Normal file
13
tests/dynamic_fixtures/open_redirect/java/Vuln.java
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Phase 09 (Track J.7) — Java OPEN_REDIRECT vuln fixture.
|
||||
//
|
||||
// The function passes `value` straight into
|
||||
// `HttpServletResponse.sendRedirect` without host validation. A
|
||||
// payload carrying `https://attacker.test/` sends the response's
|
||||
// `Location:` header off-origin.
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
public class Vuln {
|
||||
public static void run(HttpServletResponse response, String value) throws Exception {
|
||||
response.sendRedirect(value);
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/open_redirect/js/benign.js
Normal file
13
tests/dynamic_fixtures/open_redirect/js/benign.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Phase 09 (Track J.7) — JavaScript OPEN_REDIRECT benign control
|
||||
// fixture.
|
||||
//
|
||||
// The handler ignores the attacker-supplied value and redirects to a
|
||||
// same-origin path; the captured `Location:` header carries no
|
||||
// off-origin authority.
|
||||
const express = require('express');
|
||||
|
||||
function run(req, res, value) {
|
||||
res.redirect('/dashboard');
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
12
tests/dynamic_fixtures/open_redirect/js/vuln.js
Normal file
12
tests/dynamic_fixtures/open_redirect/js/vuln.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Phase 09 (Track J.7) — JavaScript OPEN_REDIRECT vuln fixture.
|
||||
//
|
||||
// The Express handler splices `value` straight into `res.redirect`
|
||||
// without host validation; an attacker URL routes the captured
|
||||
// `Location:` header off-origin.
|
||||
const express = require('express');
|
||||
|
||||
function run(req, res, value) {
|
||||
res.redirect(value);
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
11
tests/dynamic_fixtures/open_redirect/php/benign.php
Normal file
11
tests/dynamic_fixtures/open_redirect/php/benign.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
// Phase 09 (Track J.7) — PHP OPEN_REDIRECT benign control fixture.
|
||||
//
|
||||
// The function ignores the attacker-supplied value and redirects to
|
||||
// a same-origin path; the captured `Location:` header carries no
|
||||
// off-origin authority.
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
function run(string $value): RedirectResponse {
|
||||
return new RedirectResponse('/dashboard');
|
||||
}
|
||||
11
tests/dynamic_fixtures/open_redirect/php/vuln.php
Normal file
11
tests/dynamic_fixtures/open_redirect/php/vuln.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
// Phase 09 (Track J.7) — PHP OPEN_REDIRECT vuln fixture.
|
||||
//
|
||||
// The function splices `$value` into a Symfony `RedirectResponse`
|
||||
// without host validation; an attacker URL routes the
|
||||
// `Location:` header off-origin.
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
function run(string $value): RedirectResponse {
|
||||
return new RedirectResponse($value);
|
||||
}
|
||||
10
tests/dynamic_fixtures/open_redirect/python/benign.py
Normal file
10
tests/dynamic_fixtures/open_redirect/python/benign.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Phase 09 (Track J.7) — Python OPEN_REDIRECT benign control fixture.
|
||||
#
|
||||
# The function ignores the attacker-supplied value and redirects to a
|
||||
# same-origin path, so the captured `Location:` header carries no
|
||||
# off-origin authority.
|
||||
from flask import redirect
|
||||
|
||||
|
||||
def run(value):
|
||||
return redirect("/dashboard")
|
||||
10
tests/dynamic_fixtures/open_redirect/python/vuln.py
Normal file
10
tests/dynamic_fixtures/open_redirect/python/vuln.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Phase 09 (Track J.7) — Python OPEN_REDIRECT vuln fixture.
|
||||
#
|
||||
# The function passes `value` straight into `flask.redirect` without
|
||||
# host validation. A payload carrying `https://attacker.test/` sends
|
||||
# the response's `Location:` header off-origin.
|
||||
from flask import redirect
|
||||
|
||||
|
||||
def run(value):
|
||||
return redirect(value)
|
||||
12
tests/dynamic_fixtures/open_redirect/ruby/benign.rb
Normal file
12
tests/dynamic_fixtures/open_redirect/ruby/benign.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Phase 09 (Track J.7) — Ruby OPEN_REDIRECT benign control fixture.
|
||||
#
|
||||
# The function ignores the attacker-supplied value and redirects to a
|
||||
# same-origin path; the captured `Location:` header carries no
|
||||
# off-origin authority.
|
||||
require 'rack'
|
||||
|
||||
def run(value)
|
||||
response = Rack::Response.new
|
||||
response.redirect('/dashboard')
|
||||
response
|
||||
end
|
||||
12
tests/dynamic_fixtures/open_redirect/ruby/vuln.rb
Normal file
12
tests/dynamic_fixtures/open_redirect/ruby/vuln.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Phase 09 (Track J.7) — Ruby OPEN_REDIRECT vuln fixture.
|
||||
#
|
||||
# The function splices `value` straight into
|
||||
# `Rack::Response#redirect` without host validation; an attacker URL
|
||||
# routes the captured `Location:` header off-origin.
|
||||
require 'rack'
|
||||
|
||||
def run(value)
|
||||
response = Rack::Response.new
|
||||
response.redirect(value)
|
||||
response
|
||||
end
|
||||
10
tests/dynamic_fixtures/open_redirect/rust/benign.rs
Normal file
10
tests/dynamic_fixtures/open_redirect/rust/benign.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Phase 09 (Track J.7) — Rust OPEN_REDIRECT benign control fixture.
|
||||
//
|
||||
// The handler ignores the attacker-supplied value and redirects to a
|
||||
// same-origin path; the captured `Location:` header carries no
|
||||
// off-origin authority.
|
||||
use axum::response::Redirect;
|
||||
|
||||
pub fn run(_value: String) -> Redirect {
|
||||
Redirect::to("/dashboard")
|
||||
}
|
||||
10
tests/dynamic_fixtures/open_redirect/rust/vuln.rs
Normal file
10
tests/dynamic_fixtures/open_redirect/rust/vuln.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Phase 09 (Track J.7) — Rust OPEN_REDIRECT vuln fixture.
|
||||
//
|
||||
// The handler splices `value` straight into `Redirect::to` without
|
||||
// host validation; an attacker URL routes the captured `Location:`
|
||||
// header off-origin.
|
||||
use axum::response::Redirect;
|
||||
|
||||
pub fn run(value: String) -> Redirect {
|
||||
Redirect::to(&value)
|
||||
}
|
||||
394
tests/open_redirect_corpus.rs
Normal file
394
tests/open_redirect_corpus.rs
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
//! Phase 09 (Track J.7) — OPEN_REDIRECT corpus acceptance.
|
||||
//!
|
||||
//! Asserts the new cap end-to-end: corpus slices register per-language
|
||||
//! vuln/benign pairs for Java / Python / PHP / Ruby / JavaScript / Go /
|
||||
//! Rust, the lang-aware resolver pairs them inside the correct slice,
|
||||
//! the per-language harness emitters splice in the synthetic
|
||||
//! `sendRedirect` / `redirect` shim + `Redirect` probe + sink-hit
|
||||
//! sentinel, the framework adapters fire on the canonical redirect
|
||||
//! call, and the `RedirectHostNotIn` predicate fires only on probes
|
||||
//! whose `location` resolves off-origin against the allowlist.
|
||||
//!
|
||||
//! `cargo nextest run --features dynamic --test open_redirect_corpus`.
|
||||
|
||||
#![cfg(feature = "dynamic")]
|
||||
|
||||
mod common;
|
||||
|
||||
use nyx_scanner::dynamic::corpus::{
|
||||
audit_marker_collisions, benign_payload_for_lang, payloads_for_lang,
|
||||
resolve_benign_control_lang, Oracle,
|
||||
};
|
||||
use nyx_scanner::dynamic::framework::registry::adapters_for;
|
||||
use nyx_scanner::dynamic::lang;
|
||||
use nyx_scanner::dynamic::oracle::{oracle_fired, ProbePredicate};
|
||||
use nyx_scanner::dynamic::probe::{ProbeKind, ProbeWitness, SinkProbe};
|
||||
use nyx_scanner::dynamic::sandbox::SandboxOutcome;
|
||||
use nyx_scanner::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
|
||||
use nyx_scanner::labels::Cap;
|
||||
use nyx_scanner::summary::FuncSummary;
|
||||
use nyx_scanner::symbol::Lang;
|
||||
use std::time::Duration;
|
||||
|
||||
const LANGS: &[Lang] = &[
|
||||
Lang::Java,
|
||||
Lang::Python,
|
||||
Lang::Php,
|
||||
Lang::Ruby,
|
||||
Lang::JavaScript,
|
||||
Lang::Go,
|
||||
Lang::Rust,
|
||||
];
|
||||
|
||||
fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec {
|
||||
HarnessSpec {
|
||||
finding_id: "phase09test0001".into(),
|
||||
entry_file: entry_file.into(),
|
||||
entry_name: entry_name.into(),
|
||||
entry_kind: EntryKind::Function,
|
||||
lang,
|
||||
toolchain_id: "phase09".into(),
|
||||
payload_slot: PayloadSlot::Param(0),
|
||||
expected_cap: Cap::OPEN_REDIRECT,
|
||||
constraint_hints: vec![],
|
||||
sink_file: entry_file.into(),
|
||||
sink_line: 1,
|
||||
spec_hash: "phase09test0001".into(),
|
||||
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
||||
stubs_required: vec![],
|
||||
framework: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_registers_open_redirect_for_every_supported_lang() {
|
||||
for lang in LANGS {
|
||||
let slice = payloads_for_lang(Cap::OPEN_REDIRECT, *lang);
|
||||
assert!(
|
||||
!slice.is_empty(),
|
||||
"OPEN_REDIRECT has no payloads for {lang:?}"
|
||||
);
|
||||
let has_vuln = slice.iter().any(|p| !p.is_benign);
|
||||
let has_benign = slice.iter().any(|p| p.is_benign);
|
||||
assert!(has_vuln, "{lang:?} OPEN_REDIRECT missing vuln payload");
|
||||
assert!(
|
||||
has_benign,
|
||||
"{lang:?} OPEN_REDIRECT missing benign control"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_redirect_unsupported_caps_unchanged_for_other_langs() {
|
||||
for lang in [Lang::C, Lang::Cpp, Lang::TypeScript] {
|
||||
assert!(
|
||||
payloads_for_lang(Cap::OPEN_REDIRECT, lang).is_empty(),
|
||||
"unexpected OPEN_REDIRECT payloads for {lang:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn benign_control_resolves_within_lang_slice() {
|
||||
for lang in LANGS {
|
||||
let slice = payloads_for_lang(Cap::OPEN_REDIRECT, *lang);
|
||||
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||
let resolved = resolve_benign_control_lang(vuln, Cap::OPEN_REDIRECT, *lang)
|
||||
.expect("paired control");
|
||||
assert!(resolved.is_benign);
|
||||
let direct = benign_payload_for_lang(Cap::OPEN_REDIRECT, *lang).unwrap();
|
||||
assert_eq!(direct.label, resolved.label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_oracle_carries_redirect_host_not_in_predicate() {
|
||||
for lang in LANGS {
|
||||
let slice = payloads_for_lang(Cap::OPEN_REDIRECT, *lang);
|
||||
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||
match &vuln.oracle {
|
||||
Oracle::SinkProbe { predicates } => {
|
||||
assert!(
|
||||
predicates.iter().any(|p| matches!(
|
||||
p,
|
||||
ProbePredicate::RedirectHostNotIn { .. }
|
||||
)),
|
||||
"{lang:?} vuln payload missing RedirectHostNotIn predicate",
|
||||
);
|
||||
}
|
||||
other => panic!("expected SinkProbe oracle for {lang:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vuln_payload_bytes_carry_off_origin_url_benign_bytes_do_not() {
|
||||
for lang in LANGS {
|
||||
let slice = payloads_for_lang(Cap::OPEN_REDIRECT, *lang);
|
||||
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||
let benign = slice.iter().find(|p| p.is_benign).unwrap();
|
||||
let vuln_text = std::str::from_utf8(vuln.bytes).unwrap();
|
||||
let benign_text = std::str::from_utf8(benign.bytes).unwrap();
|
||||
assert!(
|
||||
vuln_text.contains("attacker.test"),
|
||||
"{lang:?} vuln payload must carry the off-origin attacker host",
|
||||
);
|
||||
assert!(
|
||||
!benign_text.contains("://"),
|
||||
"{lang:?} benign control must be a same-origin relative path",
|
||||
);
|
||||
assert!(
|
||||
benign_text.starts_with('/'),
|
||||
"{lang:?} benign control must be an absolute same-origin path",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_collisions_clean_with_phase_09_additions() {
|
||||
assert!(audit_marker_collisions().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_kind_redirect_serdes() {
|
||||
let original = ProbeKind::Redirect {
|
||||
location: "https://attacker.test/".into(),
|
||||
request_host: "example.com".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
assert!(json.contains("Redirect"));
|
||||
assert!(json.contains("location"));
|
||||
assert!(json.contains("request_host"));
|
||||
let parsed: ProbeKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_host_not_in_fires_on_off_origin_location() {
|
||||
let oracle = Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: &["example.com"],
|
||||
}],
|
||||
};
|
||||
let probes = vec![SinkProbe {
|
||||
sink_callee: "HttpServletResponse.sendRedirect".into(),
|
||||
args: vec![],
|
||||
captured_at_ns: 1,
|
||||
payload_id: "phase09".into(),
|
||||
kind: ProbeKind::Redirect {
|
||||
location: "https://attacker.test/".into(),
|
||||
request_host: "example.com".into(),
|
||||
},
|
||||
witness: ProbeWitness::empty(),
|
||||
}];
|
||||
let outcome = SandboxOutcome {
|
||||
exit_code: Some(0),
|
||||
stdout: vec![],
|
||||
stderr: vec![],
|
||||
timed_out: false,
|
||||
oob_callback_seen: false,
|
||||
sink_hit: true,
|
||||
duration: Duration::from_millis(1),
|
||||
hardening_outcome: None,
|
||||
};
|
||||
assert!(oracle_fired(&oracle, &outcome, &probes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_host_not_in_clear_on_same_origin_path() {
|
||||
let oracle = Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::RedirectHostNotIn {
|
||||
allowlist: &["example.com"],
|
||||
}],
|
||||
};
|
||||
let probes = vec![SinkProbe {
|
||||
sink_callee: "HttpServletResponse.sendRedirect".into(),
|
||||
args: vec![],
|
||||
captured_at_ns: 1,
|
||||
payload_id: "phase09".into(),
|
||||
kind: ProbeKind::Redirect {
|
||||
location: "/dashboard".into(),
|
||||
request_host: "example.com".into(),
|
||||
},
|
||||
witness: ProbeWitness::empty(),
|
||||
}];
|
||||
let outcome = SandboxOutcome {
|
||||
exit_code: Some(0),
|
||||
stdout: vec![],
|
||||
stderr: vec![],
|
||||
timed_out: false,
|
||||
oob_callback_seen: false,
|
||||
sink_hit: true,
|
||||
duration: Duration::from_millis(1),
|
||||
hardening_outcome: None,
|
||||
};
|
||||
assert!(!oracle_fired(&oracle, &outcome, &probes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_emitter_dispatches_to_open_redirect_harness() {
|
||||
// Per-lang `sink_callee_marker` pins which redirect entry point
|
||||
// the harness names in its probe record.
|
||||
for (lang, entry_file, entry_name, sink_callee_marker) in [
|
||||
(
|
||||
Lang::Java,
|
||||
"tests/dynamic_fixtures/open_redirect/java/Vuln.java",
|
||||
"run",
|
||||
"HttpServletResponse.sendRedirect",
|
||||
),
|
||||
(
|
||||
Lang::Python,
|
||||
"tests/dynamic_fixtures/open_redirect/python/vuln.py",
|
||||
"run",
|
||||
"flask.redirect",
|
||||
),
|
||||
(
|
||||
Lang::Php,
|
||||
"tests/dynamic_fixtures/open_redirect/php/vuln.php",
|
||||
"run",
|
||||
"Response::redirect",
|
||||
),
|
||||
(
|
||||
Lang::Ruby,
|
||||
"tests/dynamic_fixtures/open_redirect/ruby/vuln.rb",
|
||||
"run",
|
||||
"Rack::Response#redirect",
|
||||
),
|
||||
(
|
||||
Lang::JavaScript,
|
||||
"tests/dynamic_fixtures/open_redirect/js/vuln.js",
|
||||
"run",
|
||||
"res.redirect",
|
||||
),
|
||||
(
|
||||
Lang::Go,
|
||||
"tests/dynamic_fixtures/open_redirect/go/vuln.go",
|
||||
"Run",
|
||||
"gin.Context.Redirect",
|
||||
),
|
||||
(
|
||||
Lang::Rust,
|
||||
"tests/dynamic_fixtures/open_redirect/rust/vuln.rs",
|
||||
"run",
|
||||
"Redirect::to",
|
||||
),
|
||||
] {
|
||||
let spec = make_spec(lang, entry_file, entry_name);
|
||||
let harness = lang::emit(&spec)
|
||||
.unwrap_or_else(|e| panic!("emit failed for {lang:?}: {e:?}"));
|
||||
assert!(
|
||||
harness.source.contains("Redirect"),
|
||||
"{lang:?} redirect harness must carry the Redirect probe kind",
|
||||
);
|
||||
assert!(
|
||||
harness.source.contains(sink_callee_marker),
|
||||
"{lang:?} redirect harness must name {sink_callee_marker:?} as the sink callee",
|
||||
);
|
||||
assert!(
|
||||
harness.source.contains("__NYX_SINK_HIT__"),
|
||||
"{lang:?} redirect harness must emit the sink-hit sentinel",
|
||||
);
|
||||
assert!(
|
||||
harness.source.contains("request_host"),
|
||||
"{lang:?} redirect harness must carry the request_host field",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framework_adapters_detect_redirect_sink() {
|
||||
// Each lang registers its J.7 redirect adapter; detect_binding
|
||||
// routes through the registry and stamps an
|
||||
// `EntryKind::Function` binding when the fixture contains the
|
||||
// canonical redirect call.
|
||||
for (lang, fixture, sink_callee) in [
|
||||
(
|
||||
Lang::Java,
|
||||
"tests/dynamic_fixtures/open_redirect/java/Vuln.java",
|
||||
"sendRedirect",
|
||||
),
|
||||
(
|
||||
Lang::Python,
|
||||
"tests/dynamic_fixtures/open_redirect/python/vuln.py",
|
||||
"redirect",
|
||||
),
|
||||
(
|
||||
Lang::Php,
|
||||
"tests/dynamic_fixtures/open_redirect/php/vuln.php",
|
||||
"RedirectResponse",
|
||||
),
|
||||
(
|
||||
Lang::Ruby,
|
||||
"tests/dynamic_fixtures/open_redirect/ruby/vuln.rb",
|
||||
"redirect",
|
||||
),
|
||||
(
|
||||
Lang::JavaScript,
|
||||
"tests/dynamic_fixtures/open_redirect/js/vuln.js",
|
||||
"redirect",
|
||||
),
|
||||
(
|
||||
Lang::Go,
|
||||
"tests/dynamic_fixtures/open_redirect/go/vuln.go",
|
||||
"Redirect",
|
||||
),
|
||||
(
|
||||
Lang::Rust,
|
||||
"tests/dynamic_fixtures/open_redirect/rust/vuln.rs",
|
||||
"to",
|
||||
),
|
||||
] {
|
||||
let bytes = std::fs::read(fixture).expect("fixture exists");
|
||||
let ts_lang = ts_language_for(lang);
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
parser.set_language(&ts_lang).unwrap();
|
||||
let tree = parser.parse(&bytes, None).unwrap();
|
||||
let mut summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
file_path: fixture.to_owned(),
|
||||
lang: slug(lang).into(),
|
||||
..Default::default()
|
||||
};
|
||||
summary
|
||||
.callees
|
||||
.push(nyx_scanner::summary::CalleeSite::bare(sink_callee));
|
||||
let registry_slice = adapters_for(lang);
|
||||
assert!(!registry_slice.is_empty(), "{lang:?} adapter slice empty");
|
||||
let binding = nyx_scanner::dynamic::framework::detect_binding(
|
||||
&summary,
|
||||
tree.root_node(),
|
||||
&bytes,
|
||||
lang,
|
||||
);
|
||||
let b = binding
|
||||
.unwrap_or_else(|| panic!("{lang:?} adapter must detect the redirect fixture"));
|
||||
assert_eq!(b.kind, EntryKind::Function);
|
||||
assert!(!b.adapter.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_language_for(lang: Lang) -> tree_sitter::Language {
|
||||
match lang {
|
||||
Lang::Java => tree_sitter::Language::from(tree_sitter_java::LANGUAGE),
|
||||
Lang::Python => tree_sitter::Language::from(tree_sitter_python::LANGUAGE),
|
||||
Lang::Php => tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP),
|
||||
Lang::Ruby => tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE),
|
||||
Lang::JavaScript => tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE),
|
||||
Lang::Go => tree_sitter::Language::from(tree_sitter_go::LANGUAGE),
|
||||
Lang::Rust => tree_sitter::Language::from(tree_sitter_rust::LANGUAGE),
|
||||
other => panic!("unsupported test lang {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn slug(lang: Lang) -> &'static str {
|
||||
match lang {
|
||||
Lang::Java => "java",
|
||||
Lang::Python => "python",
|
||||
Lang::Php => "php",
|
||||
Lang::Ruby => "ruby",
|
||||
Lang::JavaScript => "javascript",
|
||||
Lang::Go => "go",
|
||||
Lang::Rust => "rust",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue