mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-21 20:18:06 +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
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue