mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-06 20:42:10 +02:00
[pitboss] phase 08: Track J.6 + Track L.6 — HEADER_INJECTION corpus + every HTTP framework
This commit is contained in:
parent
59d627cb22
commit
e0e49f65d3
45 changed files with 2552 additions and 41 deletions
|
|
@ -50,6 +50,7 @@ pub mod registry;
|
||||||
mod cmdi;
|
mod cmdi;
|
||||||
mod deserialize;
|
mod deserialize;
|
||||||
mod fmt_string;
|
mod fmt_string;
|
||||||
|
mod header_injection;
|
||||||
mod ldap;
|
mod ldap;
|
||||||
mod path_trav;
|
mod path_trav;
|
||||||
mod sqli;
|
mod sqli;
|
||||||
|
|
@ -92,7 +93,8 @@ pub use crate::dynamic::oracle::Oracle;
|
||||||
/// | 9 | 2026-05-17 | Phase 05 / Track J.3: `XXE` cap lit for Java / Python / PHP / Ruby / Go; `ProbeKind::Xxe` + `ProbePredicate::XxeEntityExpanded` |
|
/// | 9 | 2026-05-17 | Phase 05 / Track J.3: `XXE` cap lit for Java / Python / PHP / Ruby / Go; `ProbeKind::Xxe` + `ProbePredicate::XxeEntityExpanded` |
|
||||||
/// | 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 |
|
/// | 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 |
|
/// | 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 |
|
||||||
pub const CORPUS_VERSION: u32 = 11;
|
/// | 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;
|
||||||
|
|
||||||
/// Where a payload originated.
|
/// Where a payload originated.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
|
||||||
56
src/dynamic/corpus/header_injection/go.rs
Normal file
56
src/dynamic/corpus/header_injection/go.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
//! Go `Cap::HEADER_INJECTION` payloads —
|
||||||
|
//! `http.ResponseWriter.Header().Set` CRLF injection.
|
||||||
|
//!
|
||||||
|
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||||
|
//! nyx-injected=pwn`. Spliced into the host's `w.Header().Set("Set-
|
||||||
|
//! Cookie", value)` call without CRLF stripping.
|
||||||
|
//!
|
||||||
|
//! Benign control: same logical cookie value pre-encoded with
|
||||||
|
//! `net/url.QueryEscape`. Captured value carries `%0D%0A` so the
|
||||||
|
//! predicate stays clear.
|
||||||
|
|
||||||
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||||
|
label: "header-injection-go-crlf",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: false,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/go/vuln.go"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "header-injection-go-benign",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||||
|
label: "header-injection-go-benign",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: true,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/go/benign.go"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
63
src/dynamic/corpus/header_injection/java.rs
Normal file
63
src/dynamic/corpus/header_injection/java.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
//! Java `Cap::HEADER_INJECTION` payloads —
|
||||||
|
//! `HttpServletResponse.setHeader` CRLF injection.
|
||||||
|
//!
|
||||||
|
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||||
|
//! nyx-injected=pwn`. Concatenated into the host's
|
||||||
|
//! `response.setHeader("Set-Cookie", value)` call without CRLF
|
||||||
|
//! stripping, the wire response carries the attacker's second
|
||||||
|
//! header. The harness's instrumented `setHeader` records a
|
||||||
|
//! `ProbeKind::HeaderEmit { name: "Set-Cookie", value: <raw bytes> }`
|
||||||
|
//! probe with the unescaped CRLF intact.
|
||||||
|
//!
|
||||||
|
//! Benign control: same logical session-id, but the harness's
|
||||||
|
//! benign code path runs the value through `URLEncoder.encode(...,
|
||||||
|
//! "UTF-8")` so the carried bytes become
|
||||||
|
//! `nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn`. The
|
||||||
|
//! captured value has no literal `\r\n`; the
|
||||||
|
//! [`ProbePredicate::HeaderInjected`] predicate stays clear.
|
||||||
|
|
||||||
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||||
|
label: "header-injection-java-crlf",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: false,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/java/Vuln.java"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "header-injection-java-benign",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||||
|
label: "header-injection-java-benign",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: true,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/java/Benign.java"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
56
src/dynamic/corpus/header_injection/js.rs
Normal file
56
src/dynamic/corpus/header_injection/js.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
//! JavaScript `Cap::HEADER_INJECTION` payloads —
|
||||||
|
//! `http.ServerResponse#setHeader` CRLF injection.
|
||||||
|
//!
|
||||||
|
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||||
|
//! nyx-injected=pwn`. Spliced into the host's
|
||||||
|
//! `res.setHeader('Set-Cookie', value)` call without CRLF stripping.
|
||||||
|
//!
|
||||||
|
//! Benign control: same logical cookie value pre-encoded with
|
||||||
|
//! `encodeURIComponent`. Captured value carries `%0D%0A` so the
|
||||||
|
//! predicate stays clear.
|
||||||
|
|
||||||
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||||
|
label: "header-injection-js-crlf",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: false,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/js/vuln.js"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "header-injection-js-benign",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||||
|
label: "header-injection-js-benign",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: true,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/js/benign.js"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
31
src/dynamic/corpus/header_injection/mod.rs
Normal file
31
src/dynamic/corpus/header_injection/mod.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
//! HTTP response-header CRLF injection (`Cap::HEADER_INJECTION`)
|
||||||
|
//! per-language payload slices.
|
||||||
|
//!
|
||||||
|
//! Phase 08 (Track J.6) carves header injection across the seven HTTP
|
||||||
|
//! framework ecosystems Nyx supports: Java (`HttpServletResponse.
|
||||||
|
//! setHeader`), Python (`flask.Response.headers.__setitem__`), PHP
|
||||||
|
//! (`header()`), Ruby (`Rack::Response#set_header`), JavaScript
|
||||||
|
//! (`http.ServerResponse#setHeader`), Go (`http.ResponseWriter.
|
||||||
|
//! Header().Set`), Rust (`axum`-style `HeaderMap::insert`). Every
|
||||||
|
//! vuln payload appends a `\r\n` followed by an injected header line
|
||||||
|
//! (`Set-Cookie: nyx-injected=pwn`) — once the host code splices the
|
||||||
|
//! attacker bytes into the response writer's value argument the wire
|
||||||
|
//! actually carries two headers instead of one. The paired benign
|
||||||
|
//! control passes the same logical value through the per-language URL
|
||||||
|
//! encoder so the captured value carries `%0d%0a` (not the raw
|
||||||
|
//! bytes), the encoded text is preserved verbatim inside a single
|
||||||
|
//! header value, and the differential rule stays clear.
|
||||||
|
//!
|
||||||
|
//! The oracle's
|
||||||
|
//! [`crate::dynamic::oracle::ProbePredicate::HeaderInjected`] reads
|
||||||
|
//! the per-payload `ProbeKind::HeaderEmit { name, value }` records
|
||||||
|
//! and fires when the value contains a literal CRLF byte pair —
|
||||||
|
//! vuln passes, benign clears, fulfilling the §4.1 differential rule.
|
||||||
|
|
||||||
|
pub mod go;
|
||||||
|
pub mod java;
|
||||||
|
pub mod js;
|
||||||
|
pub mod php;
|
||||||
|
pub mod python;
|
||||||
|
pub mod ruby;
|
||||||
|
pub mod rust;
|
||||||
58
src/dynamic/corpus/header_injection/php.rs
Normal file
58
src/dynamic/corpus/header_injection/php.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
//! PHP `Cap::HEADER_INJECTION` payloads — `header()` CRLF injection.
|
||||||
|
//!
|
||||||
|
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||||
|
//! nyx-injected=pwn`. Concatenated into the host's `header("Set-
|
||||||
|
//! Cookie: " . $value)` call without CRLF stripping, the wire response
|
||||||
|
//! carries the attacker's second header. The harness's instrumented
|
||||||
|
//! `header()` records a `ProbeKind::HeaderEmit` probe with the
|
||||||
|
//! unescaped CRLF intact.
|
||||||
|
//!
|
||||||
|
//! Benign control: same logical cookie value pre-encoded with PHP's
|
||||||
|
//! `urlencode`. Captured value carries `%0D%0A` so the predicate
|
||||||
|
//! stays clear.
|
||||||
|
|
||||||
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||||
|
label: "header-injection-php-crlf",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: false,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/php/vuln.php"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "header-injection-php-benign",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||||
|
label: "header-injection-php-benign",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: true,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/php/benign.php"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
62
src/dynamic/corpus/header_injection/python.rs
Normal file
62
src/dynamic/corpus/header_injection/python.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
//! Python `Cap::HEADER_INJECTION` payloads —
|
||||||
|
//! `flask.Response.headers.__setitem__` CRLF injection.
|
||||||
|
//!
|
||||||
|
//! Vuln payload: a session cookie value followed by `\r\nSet-Cookie:
|
||||||
|
//! nyx-injected=pwn`. Spliced into the host's
|
||||||
|
//! `response.headers["Set-Cookie"] = value` assignment without CRLF
|
||||||
|
//! stripping, the WSGI layer carries the attacker's second header on
|
||||||
|
//! the wire. The harness's instrumented response writer records a
|
||||||
|
//! `ProbeKind::HeaderEmit { name: "Set-Cookie", value: <raw bytes> }`
|
||||||
|
//! probe with the unescaped CRLF intact.
|
||||||
|
//!
|
||||||
|
//! Benign control: same logical cookie value pre-encoded with
|
||||||
|
//! `urllib.parse.quote`. The carried bytes become
|
||||||
|
//! `nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn` — no literal
|
||||||
|
//! CRLF — and the [`ProbePredicate::HeaderInjected`] predicate stays
|
||||||
|
//! clear.
|
||||||
|
|
||||||
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||||
|
label: "header-injection-python-crlf",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: false,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/python/vuln.py"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "header-injection-python-benign",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||||
|
label: "header-injection-python-benign",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: true,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/python/benign.py"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
57
src/dynamic/corpus/header_injection/ruby.rs
Normal file
57
src/dynamic/corpus/header_injection/ruby.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
//! Ruby `Cap::HEADER_INJECTION` payloads —
|
||||||
|
//! `Rack::Response#set_header` CRLF injection.
|
||||||
|
//!
|
||||||
|
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||||
|
//! nyx-injected=pwn`. Spliced into the host's
|
||||||
|
//! `response.set_header("Set-Cookie", value)` call without CRLF
|
||||||
|
//! stripping, the wire response carries the attacker's second header.
|
||||||
|
//!
|
||||||
|
//! Benign control: same logical cookie value pre-encoded with
|
||||||
|
//! `URI.encode_www_form_component`. Captured value carries `%0D%0A`
|
||||||
|
//! so the predicate stays clear.
|
||||||
|
|
||||||
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||||
|
label: "header-injection-ruby-crlf",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: false,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/ruby/vuln.rb"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "header-injection-ruby-benign",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||||
|
label: "header-injection-ruby-benign",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: true,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/ruby/benign.rb"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
57
src/dynamic/corpus/header_injection/rust.rs
Normal file
57
src/dynamic/corpus/header_injection/rust.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
//! Rust `Cap::HEADER_INJECTION` payloads — `axum`-style
|
||||||
|
//! `HeaderMap::insert` CRLF injection.
|
||||||
|
//!
|
||||||
|
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||||
|
//! nyx-injected=pwn`. Spliced into a hand-rolled `HeaderMap` insert
|
||||||
|
//! that bypasses the `HeaderValue::from_str` validity check (e.g.
|
||||||
|
//! `HeaderValue::from_bytes(...).unwrap()` over a tainted slice).
|
||||||
|
//!
|
||||||
|
//! Benign control: same logical cookie value pre-encoded with the
|
||||||
|
//! `percent-encoding` crate. Captured value carries `%0D%0A` so the
|
||||||
|
//! predicate stays clear.
|
||||||
|
|
||||||
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||||
|
label: "header-injection-rust-crlf",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: false,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/rust/vuln.rs"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "header-injection-rust-benign",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||||
|
label: "header-injection-rust-benign",
|
||||||
|
oracle: Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
is_benign: true,
|
||||||
|
provenance: PayloadProvenance::Curated,
|
||||||
|
since_corpus_version: 12,
|
||||||
|
deprecated_at_corpus_version: None,
|
||||||
|
fixture_paths: &["tests/dynamic_fixtures/header_injection/rust/benign.rs"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -23,7 +23,10 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use super::{cmdi, deserialize, fmt_string, ldap, path_trav, sqli, ssrf, ssti, xpath, xss, xxe};
|
use super::{
|
||||||
|
cmdi, deserialize, fmt_string, header_injection, ldap, path_trav, sqli, ssrf, ssti, xpath,
|
||||||
|
xss, xxe,
|
||||||
|
};
|
||||||
use super::{CapCorpus, CuratedPayload, Oracle};
|
use super::{CapCorpus, CuratedPayload, Oracle};
|
||||||
use crate::dynamic::oracle::ProbePredicate;
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
use crate::labels::Cap;
|
use crate::labels::Cap;
|
||||||
|
|
@ -40,7 +43,6 @@ pub const CORPUS_UNSUPPORTED_LANG_NEUTRAL: u32 = Cap::ENV_VAR.bits()
|
||||||
| Cap::CRYPTO.bits()
|
| Cap::CRYPTO.bits()
|
||||||
| Cap::UNAUTHORIZED_ID.bits()
|
| Cap::UNAUTHORIZED_ID.bits()
|
||||||
| Cap::DATA_EXFIL.bits()
|
| Cap::DATA_EXFIL.bits()
|
||||||
| Cap::HEADER_INJECTION.bits()
|
|
||||||
| Cap::OPEN_REDIRECT.bits()
|
| Cap::OPEN_REDIRECT.bits()
|
||||||
| Cap::PROTOTYPE_POLLUTION.bits();
|
| Cap::PROTOTYPE_POLLUTION.bits();
|
||||||
|
|
||||||
|
|
@ -74,6 +76,13 @@ const ENTRIES: &[(Cap, Lang, &[CuratedPayload])] = &[
|
||||||
(Cap::XPATH_INJECTION, Lang::Python, xpath::python::PAYLOADS),
|
(Cap::XPATH_INJECTION, Lang::Python, xpath::python::PAYLOADS),
|
||||||
(Cap::XPATH_INJECTION, Lang::Php, xpath::php::PAYLOADS),
|
(Cap::XPATH_INJECTION, Lang::Php, xpath::php::PAYLOADS),
|
||||||
(Cap::XPATH_INJECTION, Lang::JavaScript, xpath::js::PAYLOADS),
|
(Cap::XPATH_INJECTION, Lang::JavaScript, xpath::js::PAYLOADS),
|
||||||
|
(Cap::HEADER_INJECTION, Lang::Java, header_injection::java::PAYLOADS),
|
||||||
|
(Cap::HEADER_INJECTION, Lang::Python, header_injection::python::PAYLOADS),
|
||||||
|
(Cap::HEADER_INJECTION, Lang::Php, header_injection::php::PAYLOADS),
|
||||||
|
(Cap::HEADER_INJECTION, Lang::Ruby, header_injection::ruby::PAYLOADS),
|
||||||
|
(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),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Reserved for per-cap oracle defaults. Empty in Phase 02; populated by
|
/// Reserved for per-cap oracle defaults. Empty in Phase 02; populated by
|
||||||
|
|
@ -285,6 +294,7 @@ mod tests {
|
||||||
assert!(!payloads_for(Cap::XXE).is_empty());
|
assert!(!payloads_for(Cap::XXE).is_empty());
|
||||||
assert!(!payloads_for(Cap::LDAP_INJECTION).is_empty());
|
assert!(!payloads_for(Cap::LDAP_INJECTION).is_empty());
|
||||||
assert!(!payloads_for(Cap::XPATH_INJECTION).is_empty());
|
assert!(!payloads_for(Cap::XPATH_INJECTION).is_empty());
|
||||||
|
assert!(!payloads_for(Cap::HEADER_INJECTION).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -297,7 +307,6 @@ mod tests {
|
||||||
Cap::CRYPTO,
|
Cap::CRYPTO,
|
||||||
Cap::UNAUTHORIZED_ID,
|
Cap::UNAUTHORIZED_ID,
|
||||||
Cap::DATA_EXFIL,
|
Cap::DATA_EXFIL,
|
||||||
Cap::HEADER_INJECTION,
|
|
||||||
Cap::OPEN_REDIRECT,
|
Cap::OPEN_REDIRECT,
|
||||||
Cap::PROTOTYPE_POLLUTION,
|
Cap::PROTOTYPE_POLLUTION,
|
||||||
];
|
];
|
||||||
|
|
@ -332,6 +341,7 @@ mod tests {
|
||||||
Cap::XXE,
|
Cap::XXE,
|
||||||
Cap::LDAP_INJECTION,
|
Cap::LDAP_INJECTION,
|
||||||
Cap::XPATH_INJECTION,
|
Cap::XPATH_INJECTION,
|
||||||
|
Cap::HEADER_INJECTION,
|
||||||
] {
|
] {
|
||||||
let has_vuln = payloads_for(cap).iter().any(|p| !p.is_benign);
|
let has_vuln = payloads_for(cap).iter().any(|p| !p.is_benign);
|
||||||
assert!(has_vuln, "{cap:?} must have at least one vuln payload");
|
assert!(has_vuln, "{cap:?} must have at least one vuln payload");
|
||||||
|
|
@ -383,6 +393,7 @@ mod tests {
|
||||||
Cap::XXE,
|
Cap::XXE,
|
||||||
Cap::LDAP_INJECTION,
|
Cap::LDAP_INJECTION,
|
||||||
Cap::XPATH_INJECTION,
|
Cap::XPATH_INJECTION,
|
||||||
|
Cap::HEADER_INJECTION,
|
||||||
];
|
];
|
||||||
for cap in caps {
|
for cap in caps {
|
||||||
for p in payloads_for(cap) {
|
for p in payloads_for(cap) {
|
||||||
|
|
@ -409,6 +420,7 @@ mod tests {
|
||||||
Cap::XXE,
|
Cap::XXE,
|
||||||
Cap::LDAP_INJECTION,
|
Cap::LDAP_INJECTION,
|
||||||
Cap::XPATH_INJECTION,
|
Cap::XPATH_INJECTION,
|
||||||
|
Cap::HEADER_INJECTION,
|
||||||
];
|
];
|
||||||
for cap in caps {
|
for cap in caps {
|
||||||
for p in payloads_for(cap) {
|
for p in payloads_for(cap) {
|
||||||
|
|
@ -522,6 +534,7 @@ mod tests {
|
||||||
Cap::XXE,
|
Cap::XXE,
|
||||||
Cap::LDAP_INJECTION,
|
Cap::LDAP_INJECTION,
|
||||||
Cap::XPATH_INJECTION,
|
Cap::XPATH_INJECTION,
|
||||||
|
Cap::HEADER_INJECTION,
|
||||||
];
|
];
|
||||||
for cap in caps {
|
for cap in caps {
|
||||||
for p in payloads_for(cap).iter().filter(|p| p.is_benign) {
|
for p in payloads_for(cap).iter().filter(|p| p.is_benign) {
|
||||||
|
|
@ -775,6 +788,57 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_injection_has_per_lang_slices_for_phase_08() {
|
||||||
|
// Phase 08 (Track J.6) acceptance: HEADER_INJECTION registers
|
||||||
|
// payloads in Java / Python / PHP / Ruby / JS / Go / Rust and
|
||||||
|
// the lang-aware lookup never returns empty for any of them.
|
||||||
|
for lang in [
|
||||||
|
Lang::Java,
|
||||||
|
Lang::Python,
|
||||||
|
Lang::Php,
|
||||||
|
Lang::Ruby,
|
||||||
|
Lang::JavaScript,
|
||||||
|
Lang::Go,
|
||||||
|
Lang::Rust,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!payloads_for_lang(Cap::HEADER_INJECTION, lang).is_empty(),
|
||||||
|
"HEADER_INJECTION must have at least one payload for {lang:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// C / Cpp / TypeScript not yet covered.
|
||||||
|
for lang in [Lang::C, Lang::Cpp, Lang::TypeScript] {
|
||||||
|
assert!(
|
||||||
|
payloads_for_lang(Cap::HEADER_INJECTION, lang).is_empty(),
|
||||||
|
"HEADER_INJECTION has unexpected payloads for {lang:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_injection_payloads_pair_benign_controls_per_lang() {
|
||||||
|
for lang in [
|
||||||
|
Lang::Java,
|
||||||
|
Lang::Python,
|
||||||
|
Lang::Php,
|
||||||
|
Lang::Ruby,
|
||||||
|
Lang::JavaScript,
|
||||||
|
Lang::Go,
|
||||||
|
Lang::Rust,
|
||||||
|
] {
|
||||||
|
let slice = payloads_for_lang(Cap::HEADER_INJECTION, lang);
|
||||||
|
let vuln = slice
|
||||||
|
.iter()
|
||||||
|
.find(|p| !p.is_benign)
|
||||||
|
.expect("each lang must have a HEADER_INJECTION vuln payload");
|
||||||
|
let resolved =
|
||||||
|
super::resolve_benign_control_lang(vuln, Cap::HEADER_INJECTION, lang)
|
||||||
|
.expect("lang-aware benign control must resolve");
|
||||||
|
assert!(resolved.is_benign);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deserialize_payloads_pair_benign_controls_per_lang() {
|
fn deserialize_payloads_pair_benign_controls_per_lang() {
|
||||||
// The lang-aware resolver must find the paired benign control
|
// The lang-aware resolver must find the paired benign control
|
||||||
|
|
|
||||||
110
src/dynamic/framework/adapters/header_go.rs
Normal file
110
src/dynamic/framework/adapters/header_go.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
//! Go [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||||
|
//! header CRLF-injection sink constructions
|
||||||
|
//! (`http.ResponseWriter.Header().Set` / `Add`, Gin `c.Header`,
|
||||||
|
//! Echo `c.Response().Header().Set`).
|
||||||
|
//!
|
||||||
|
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||||
|
//! of the canonical Go HTTP response writers and the surrounding
|
||||||
|
//! source imports `net/http` or one of the supported frameworks.
|
||||||
|
|
||||||
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
|
use crate::evidence::EntryKind;
|
||||||
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::symbol::Lang;
|
||||||
|
|
||||||
|
pub struct HeaderGoAdapter;
|
||||||
|
|
||||||
|
const ADAPTER_NAME: &str = "header-go";
|
||||||
|
|
||||||
|
fn callee_is_header_setter(name: &str) -> bool {
|
||||||
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
|
matches!(last, "Set" | "Add" | "Header" | "WriteHeader")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_imports_go_http(file_bytes: &[u8]) -> bool {
|
||||||
|
const NEEDLES: &[&[u8]] = &[
|
||||||
|
b"\"net/http\"",
|
||||||
|
b"net/http\"",
|
||||||
|
b"github.com/gin-gonic/gin",
|
||||||
|
b"github.com/labstack/echo",
|
||||||
|
b"github.com/gofiber/fiber",
|
||||||
|
b"github.com/go-chi/chi",
|
||||||
|
b".Header().Set",
|
||||||
|
b".Header().Add",
|
||||||
|
];
|
||||||
|
NEEDLES
|
||||||
|
.iter()
|
||||||
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameworkAdapter for HeaderGoAdapter {
|
||||||
|
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_header_setter);
|
||||||
|
let matches_source = source_imports_go_http(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_header_set() {
|
||||||
|
let src: &[u8] =
|
||||||
|
b"package x\nimport \"net/http\"\nfunc Run(w http.ResponseWriter, v string) { w.Header().Set(\"Set-Cookie\", v) }\n";
|
||||||
|
let tree = parse_go(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "Run".into(),
|
||||||
|
callees: vec![crate::summary::CalleeSite::bare("Set")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(HeaderGoAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_plain_function() {
|
||||||
|
let src: &[u8] = b"package x\nfunc Add(a, b int) int { return a + b }\n";
|
||||||
|
let tree = parse_go(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "Add".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(HeaderGoAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/dynamic/framework/adapters/header_java.rs
Normal file
106
src/dynamic/framework/adapters/header_java.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
//! Java [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||||
|
//! header CRLF-injection sink constructions
|
||||||
|
//! (`HttpServletResponse.setHeader` / `addHeader`).
|
||||||
|
//!
|
||||||
|
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||||
|
//! of the canonical servlet response-writer 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 HeaderJavaAdapter;
|
||||||
|
|
||||||
|
const ADAPTER_NAME: &str = "header-java";
|
||||||
|
|
||||||
|
fn callee_is_header_setter(name: &str) -> bool {
|
||||||
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
|
matches!(last, "setHeader" | "addHeader" | "setDateHeader" | "addDateHeader" | "setIntHeader" | "addIntHeader")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_imports_servlet(file_bytes: &[u8]) -> bool {
|
||||||
|
const NEEDLES: &[&[u8]] = &[
|
||||||
|
b"javax.servlet",
|
||||||
|
b"jakarta.servlet",
|
||||||
|
b"HttpServletResponse",
|
||||||
|
b"ServerHttpResponse",
|
||||||
|
b"org.springframework.http",
|
||||||
|
];
|
||||||
|
NEEDLES
|
||||||
|
.iter()
|
||||||
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameworkAdapter for HeaderJavaAdapter {
|
||||||
|
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_header_setter);
|
||||||
|
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_setheader() {
|
||||||
|
let src: &[u8] = b"import javax.servlet.http.HttpServletResponse;\n\
|
||||||
|
class C { void run(HttpServletResponse r, String v) { r.setHeader(\"Set-Cookie\", v); } }\n";
|
||||||
|
let tree = parse_java(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "run".into(),
|
||||||
|
callees: vec![crate::summary::CalleeSite::bare("setHeader")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(HeaderJavaAdapter
|
||||||
|
.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!(HeaderJavaAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/dynamic/framework/adapters/header_js.rs
Normal file
118
src/dynamic/framework/adapters/header_js.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
//! JavaScript [`super::super::FrameworkAdapter`] matching HTTP
|
||||||
|
//! response-header CRLF-injection sink constructions
|
||||||
|
//! (`http.ServerResponse#setHeader`, Express `res.setHeader` /
|
||||||
|
//! `res.header`, Koa `ctx.set`).
|
||||||
|
//!
|
||||||
|
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||||
|
//! of the canonical Node response writers and the surrounding source
|
||||||
|
//! imports the matching framework module or `node:http`.
|
||||||
|
|
||||||
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
|
use crate::evidence::EntryKind;
|
||||||
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::symbol::Lang;
|
||||||
|
|
||||||
|
pub struct HeaderJsAdapter;
|
||||||
|
|
||||||
|
const ADAPTER_NAME: &str = "header-js";
|
||||||
|
|
||||||
|
fn callee_is_header_setter(name: &str) -> bool {
|
||||||
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
|
matches!(last, "setHeader" | "header" | "set" | "writeHead" | "append")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_uses_node_http(file_bytes: &[u8]) -> bool {
|
||||||
|
const NEEDLES: &[&[u8]] = &[
|
||||||
|
b"require('http')",
|
||||||
|
b"require(\"http\")",
|
||||||
|
b"require('node:http')",
|
||||||
|
b"from 'http'",
|
||||||
|
b"from \"http\"",
|
||||||
|
b"require('express')",
|
||||||
|
b"require(\"express\")",
|
||||||
|
b"from 'express'",
|
||||||
|
b"from \"express\"",
|
||||||
|
b"require('koa')",
|
||||||
|
b"require(\"koa\")",
|
||||||
|
b"require('fastify')",
|
||||||
|
b"require(\"fastify\")",
|
||||||
|
b"res.setHeader",
|
||||||
|
b"res.header",
|
||||||
|
b"ctx.set(",
|
||||||
|
];
|
||||||
|
NEEDLES
|
||||||
|
.iter()
|
||||||
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameworkAdapter for HeaderJsAdapter {
|
||||||
|
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_header_setter);
|
||||||
|
let matches_source = source_uses_node_http(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_setheader() {
|
||||||
|
let src: &[u8] = b"const http = require('http');\n\
|
||||||
|
function run(res, value) { res.setHeader('Set-Cookie', value); }\n";
|
||||||
|
let tree = parse_js(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "run".into(),
|
||||||
|
callees: vec![crate::summary::CalleeSite::bare("setHeader")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(HeaderJsAdapter
|
||||||
|
.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!(HeaderJsAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/dynamic/framework/adapters/header_php.rs
Normal file
109
src/dynamic/framework/adapters/header_php.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
//! PHP [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||||
|
//! header CRLF-injection sink constructions (`header()`,
|
||||||
|
//! Symfony / Laravel `Response::headers->set`).
|
||||||
|
//!
|
||||||
|
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||||
|
//! of the canonical PHP response writers and the surrounding source
|
||||||
|
//! either references the built-in `$_SERVER` request surface or
|
||||||
|
//! imports a Symfony / Laravel response helper.
|
||||||
|
|
||||||
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
|
use crate::evidence::EntryKind;
|
||||||
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::symbol::Lang;
|
||||||
|
|
||||||
|
pub struct HeaderPhpAdapter;
|
||||||
|
|
||||||
|
const ADAPTER_NAME: &str = "header-php";
|
||||||
|
|
||||||
|
fn callee_is_header_setter(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);
|
||||||
|
let last = last.rsplit_once("->").map(|(_, s)| s).unwrap_or(last);
|
||||||
|
matches!(last, "header" | "setRawHeader" | "headers" | "set" | "add")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_uses_php_response(file_bytes: &[u8]) -> bool {
|
||||||
|
const NEEDLES: &[&[u8]] = &[
|
||||||
|
b"header(",
|
||||||
|
b"$_SERVER",
|
||||||
|
b"Symfony\\Component\\HttpFoundation",
|
||||||
|
b"Illuminate\\Http\\Response",
|
||||||
|
b"->headers->",
|
||||||
|
b"response()->header",
|
||||||
|
];
|
||||||
|
NEEDLES
|
||||||
|
.iter()
|
||||||
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameworkAdapter for HeaderPhpAdapter {
|
||||||
|
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_header_setter);
|
||||||
|
let matches_source = source_uses_php_response(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_call() {
|
||||||
|
let src: &[u8] = b"<?php\nfunction run($v) { header('Set-Cookie: ' . $v); }\n";
|
||||||
|
let tree = parse_php(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "run".into(),
|
||||||
|
callees: vec![crate::summary::CalleeSite::bare("header")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(HeaderPhpAdapter
|
||||||
|
.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!(HeaderPhpAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/dynamic/framework/adapters/header_python.rs
Normal file
112
src/dynamic/framework/adapters/header_python.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
//! Python [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||||
|
//! header CRLF-injection sink constructions
|
||||||
|
//! (`flask.Response.headers.__setitem__`, Django `HttpResponse.__setitem__`,
|
||||||
|
//! Starlette `headers.append`).
|
||||||
|
//!
|
||||||
|
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||||
|
//! of the canonical Python web framework response writers 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 HeaderPythonAdapter;
|
||||||
|
|
||||||
|
const ADAPTER_NAME: &str = "header-python";
|
||||||
|
|
||||||
|
fn callee_is_header_setter(name: &str) -> bool {
|
||||||
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
|
matches!(
|
||||||
|
last,
|
||||||
|
"__setitem__" | "set_header" | "setdefault" | "add_header" | "append"
|
||||||
|
) || matches!(name, "Response.headers.__setitem__" | "make_response" | "Response.headers.add")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_imports_python_web(file_bytes: &[u8]) -> bool {
|
||||||
|
const NEEDLES: &[&[u8]] = &[
|
||||||
|
b"from flask",
|
||||||
|
b"import flask",
|
||||||
|
b"from django.http",
|
||||||
|
b"from starlette",
|
||||||
|
b"from fastapi",
|
||||||
|
b"response.headers",
|
||||||
|
b"resp.headers",
|
||||||
|
];
|
||||||
|
NEEDLES
|
||||||
|
.iter()
|
||||||
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameworkAdapter for HeaderPythonAdapter {
|
||||||
|
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_header_setter);
|
||||||
|
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_header_assignment() {
|
||||||
|
let src: &[u8] = b"from flask import make_response\n\
|
||||||
|
def run(value):\n resp = make_response('hi')\n resp.headers['Set-Cookie'] = value\n return resp\n";
|
||||||
|
let tree = parse_python(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "run".into(),
|
||||||
|
callees: vec![crate::summary::CalleeSite::bare("__setitem__")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(HeaderPythonAdapter
|
||||||
|
.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!(HeaderPythonAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/dynamic/framework/adapters/header_ruby.rs
Normal file
111
src/dynamic/framework/adapters/header_ruby.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
//! Ruby [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||||
|
//! header CRLF-injection sink constructions
|
||||||
|
//! (`Rack::Response#set_header`, Rails `response.headers[]=`,
|
||||||
|
//! Sinatra `response['Set-Cookie']=`).
|
||||||
|
//!
|
||||||
|
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||||
|
//! of the canonical Ruby web framework response writers and the
|
||||||
|
//! surrounding source imports / mentions Rack / Rails / Sinatra.
|
||||||
|
|
||||||
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
|
use crate::evidence::EntryKind;
|
||||||
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::symbol::Lang;
|
||||||
|
|
||||||
|
pub struct HeaderRubyAdapter;
|
||||||
|
|
||||||
|
const ADAPTER_NAME: &str = "header-ruby";
|
||||||
|
|
||||||
|
fn callee_is_header_setter(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, "set_header" | "[]=" | "store" | "add_header")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_uses_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"response.headers",
|
||||||
|
b"response[",
|
||||||
|
];
|
||||||
|
NEEDLES
|
||||||
|
.iter()
|
||||||
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameworkAdapter for HeaderRubyAdapter {
|
||||||
|
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_header_setter);
|
||||||
|
let matches_source = source_uses_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_set_header() {
|
||||||
|
let src: &[u8] = b"require 'rack'\n\
|
||||||
|
def run(value)\n response = Rack::Response.new\n response.set_header('Set-Cookie', value)\nend\n";
|
||||||
|
let tree = parse_ruby(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "run".into(),
|
||||||
|
callees: vec![crate::summary::CalleeSite::bare("set_header")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(HeaderRubyAdapter
|
||||||
|
.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!(HeaderRubyAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/dynamic/framework/adapters/header_rust.rs
Normal file
112
src/dynamic/framework/adapters/header_rust.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
//! Rust [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||||
|
//! header CRLF-injection sink constructions
|
||||||
|
//! (`axum`-style `headers_mut().insert`, `actix-web` `HttpResponse::
|
||||||
|
//! insert_header`, `hyper` `Response::headers_mut().insert`).
|
||||||
|
//!
|
||||||
|
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||||
|
//! of the canonical Rust HTTP response header writers and the
|
||||||
|
//! surrounding source imports `http`, `axum`, `actix_web`, or
|
||||||
|
//! `hyper`.
|
||||||
|
|
||||||
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
|
use crate::evidence::EntryKind;
|
||||||
|
use crate::summary::FuncSummary;
|
||||||
|
use crate::symbol::Lang;
|
||||||
|
|
||||||
|
pub struct HeaderRustAdapter;
|
||||||
|
|
||||||
|
const ADAPTER_NAME: &str = "header-rust";
|
||||||
|
|
||||||
|
fn callee_is_header_setter(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, "insert" | "append" | "insert_header" | "header")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_imports_rust_http(file_bytes: &[u8]) -> bool {
|
||||||
|
const NEEDLES: &[&[u8]] = &[
|
||||||
|
b"use http::HeaderMap",
|
||||||
|
b"use http::header",
|
||||||
|
b"use axum::",
|
||||||
|
b"use actix_web",
|
||||||
|
b"use hyper::",
|
||||||
|
b"HeaderMap::new",
|
||||||
|
b"HeaderValue::from",
|
||||||
|
b"headers_mut()",
|
||||||
|
];
|
||||||
|
NEEDLES
|
||||||
|
.iter()
|
||||||
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameworkAdapter for HeaderRustAdapter {
|
||||||
|
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_header_setter);
|
||||||
|
let matches_source = source_imports_rust_http(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_headers_insert() {
|
||||||
|
let src: &[u8] = b"use axum::http::HeaderMap;\n\
|
||||||
|
fn run(headers: &mut HeaderMap, value: &str) { headers.insert(\"set-cookie\", value.parse().unwrap()); }\n";
|
||||||
|
let tree = parse_rust(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "run".into(),
|
||||||
|
callees: vec![crate::summary::CalleeSite::bare("insert")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(HeaderRustAdapter
|
||||||
|
.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!(HeaderRustAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,13 @@
|
||||||
//! the route / framework adapters; the per-cap sink adapters live
|
//! the route / framework adapters; the per-cap sink adapters live
|
||||||
//! here so the per-language verticals can ship independently.
|
//! here so the per-language verticals can ship independently.
|
||||||
|
|
||||||
|
pub mod header_go;
|
||||||
|
pub mod header_java;
|
||||||
|
pub mod header_js;
|
||||||
|
pub mod header_php;
|
||||||
|
pub mod header_python;
|
||||||
|
pub mod header_ruby;
|
||||||
|
pub mod header_rust;
|
||||||
pub mod java_deserialize;
|
pub mod java_deserialize;
|
||||||
pub mod java_thymeleaf;
|
pub mod java_thymeleaf;
|
||||||
pub mod js_handlebars;
|
pub mod js_handlebars;
|
||||||
|
|
@ -33,6 +40,13 @@ pub mod xxe_php;
|
||||||
pub mod xxe_python;
|
pub mod xxe_python;
|
||||||
pub mod xxe_ruby;
|
pub mod xxe_ruby;
|
||||||
|
|
||||||
|
pub use header_go::HeaderGoAdapter;
|
||||||
|
pub use header_java::HeaderJavaAdapter;
|
||||||
|
pub use header_js::HeaderJsAdapter;
|
||||||
|
pub use header_php::HeaderPhpAdapter;
|
||||||
|
pub use header_python::HeaderPythonAdapter;
|
||||||
|
pub use header_ruby::HeaderRubyAdapter;
|
||||||
|
pub use header_rust::HeaderRustAdapter;
|
||||||
pub use java_deserialize::JavaDeserializeAdapter;
|
pub use java_deserialize::JavaDeserializeAdapter;
|
||||||
pub use java_thymeleaf::JavaThymeleafAdapter;
|
pub use java_thymeleaf::JavaThymeleafAdapter;
|
||||||
pub use js_handlebars::JsHandlebarsAdapter;
|
pub use js_handlebars::JsHandlebarsAdapter;
|
||||||
|
|
|
||||||
|
|
@ -214,21 +214,20 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_baseline_after_phase_07() {
|
fn registry_baseline_after_phase_08() {
|
||||||
// Phase 07 (Track J.5) adds the XPath-sink adapter for Java /
|
// Phase 08 (Track J.6) adds the header-injection adapter for
|
||||||
// Python / PHP / JavaScript, layered on top of the Phase 03
|
// every language carrying the HEADER_INJECTION corpus: Java /
|
||||||
// deserialize + Phase 04 SSTI + Phase 05 XXE + Phase 06 LDAP
|
// Python / PHP / Ruby / JavaScript / Go / Rust. Java /
|
||||||
// adapters. Java / Python / PHP each grow from 4 → 5; the
|
// Python / PHP each grow from 5 → 6; Ruby from 3 → 4;
|
||||||
// JavaScript slice grows from 1 (Handlebars only) → 2. Ruby
|
// JavaScript from 2 → 3; Go from 1 → 2; Rust from 0 → 1.
|
||||||
// still carries the 03+04+05 trio (no Ruby LDAP adapter); Go
|
// C / Cpp / TypeScript still carry the Phase-01 empty
|
||||||
// still has only the XXE adapter; Rust / C / Cpp / TypeScript
|
// baseline.
|
||||||
// still carry the Phase-01 empty baseline.
|
|
||||||
for lang in [Lang::Java, Lang::Python, Lang::Php] {
|
for lang in [Lang::Java, Lang::Python, Lang::Php] {
|
||||||
let registered = registry::adapters_for(lang);
|
let registered = registry::adapters_for(lang);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registered.len(),
|
registered.len(),
|
||||||
5,
|
6,
|
||||||
"{:?} must have the J.1 deserialize + J.2 ssti + J.3 xxe + J.4 ldap + J.5 xpath adapters",
|
"{:?} must have the J.1+J.2+J.3+J.4+J.5+J.6 adapters",
|
||||||
lang,
|
lang,
|
||||||
);
|
);
|
||||||
for adapter in registered {
|
for adapter in registered {
|
||||||
|
|
@ -238,8 +237,8 @@ mod tests {
|
||||||
let ruby_registered = registry::adapters_for(Lang::Ruby);
|
let ruby_registered = registry::adapters_for(Lang::Ruby);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ruby_registered.len(),
|
ruby_registered.len(),
|
||||||
3,
|
4,
|
||||||
"Ruby must still carry the J.1 deserialize + J.2 ssti + J.3 xxe adapters",
|
"Ruby must have the J.1 + J.2 + J.3 + J.6 header adapters",
|
||||||
);
|
);
|
||||||
for adapter in ruby_registered {
|
for adapter in ruby_registered {
|
||||||
assert_eq!(adapter.lang(), Lang::Ruby);
|
assert_eq!(adapter.lang(), Lang::Ruby);
|
||||||
|
|
@ -247,8 +246,8 @@ mod tests {
|
||||||
let js_registered = registry::adapters_for(Lang::JavaScript);
|
let js_registered = registry::adapters_for(Lang::JavaScript);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
js_registered.len(),
|
js_registered.len(),
|
||||||
2,
|
3,
|
||||||
"JavaScript must have the J.2 Handlebars + J.5 xpath-js adapters",
|
"JavaScript must have J.2 Handlebars + J.5 xpath-js + J.6 header-js",
|
||||||
);
|
);
|
||||||
for adapter in js_registered {
|
for adapter in js_registered {
|
||||||
assert_eq!(adapter.lang(), Lang::JavaScript);
|
assert_eq!(adapter.lang(), Lang::JavaScript);
|
||||||
|
|
@ -256,11 +255,20 @@ mod tests {
|
||||||
let go_registered = registry::adapters_for(Lang::Go);
|
let go_registered = registry::adapters_for(Lang::Go);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
go_registered.len(),
|
go_registered.len(),
|
||||||
1,
|
2,
|
||||||
"Go must have exactly the J.3 xxe-go adapter",
|
"Go must have J.3 xxe-go + J.6 header-go",
|
||||||
);
|
);
|
||||||
assert_eq!(go_registered[0].lang(), Lang::Go);
|
for adapter in go_registered {
|
||||||
for lang in [Lang::Rust, Lang::C, Lang::Cpp, Lang::TypeScript] {
|
assert_eq!(adapter.lang(), Lang::Go);
|
||||||
|
}
|
||||||
|
let rust_registered = registry::adapters_for(Lang::Rust);
|
||||||
|
assert_eq!(
|
||||||
|
rust_registered.len(),
|
||||||
|
1,
|
||||||
|
"Rust must have exactly the J.6 header-rust adapter",
|
||||||
|
);
|
||||||
|
assert_eq!(rust_registered[0].lang(), Lang::Rust);
|
||||||
|
for lang in [Lang::C, Lang::Cpp, Lang::TypeScript] {
|
||||||
assert!(
|
assert!(
|
||||||
registry::adapters_for(lang).is_empty(),
|
registry::adapters_for(lang).is_empty(),
|
||||||
"{:?} should still have zero adapters before its Track-L phase",
|
"{:?} should still have zero adapters before its Track-L phase",
|
||||||
|
|
|
||||||
|
|
@ -44,18 +44,23 @@ pub fn adapters_for(lang: Lang) -> &'static [&'static dyn FrameworkAdapter] {
|
||||||
// listed in alphabetical order of [`FrameworkAdapter::name`] so a
|
// listed in alphabetical order of [`FrameworkAdapter::name`] so a
|
||||||
// later phase that appends a new adapter cannot silently re-order
|
// later phase that appends a new adapter cannot silently re-order
|
||||||
// the existing first-match.
|
// the existing first-match.
|
||||||
static RUST: &[&dyn FrameworkAdapter] = &[];
|
static RUST: &[&dyn FrameworkAdapter] = &[&super::adapters::HeaderRustAdapter];
|
||||||
static C: &[&dyn FrameworkAdapter] = &[];
|
static C: &[&dyn FrameworkAdapter] = &[];
|
||||||
static CPP: &[&dyn FrameworkAdapter] = &[];
|
static CPP: &[&dyn FrameworkAdapter] = &[];
|
||||||
static JAVA: &[&dyn FrameworkAdapter] = &[
|
static JAVA: &[&dyn FrameworkAdapter] = &[
|
||||||
|
&super::adapters::HeaderJavaAdapter,
|
||||||
&super::adapters::JavaDeserializeAdapter,
|
&super::adapters::JavaDeserializeAdapter,
|
||||||
&super::adapters::JavaThymeleafAdapter,
|
&super::adapters::JavaThymeleafAdapter,
|
||||||
&super::adapters::LdapSpringAdapter,
|
&super::adapters::LdapSpringAdapter,
|
||||||
&super::adapters::XpathJavaAdapter,
|
&super::adapters::XpathJavaAdapter,
|
||||||
&super::adapters::XxeJavaAdapter,
|
&super::adapters::XxeJavaAdapter,
|
||||||
];
|
];
|
||||||
static GO: &[&dyn FrameworkAdapter] = &[&super::adapters::XxeGoAdapter];
|
static GO: &[&dyn FrameworkAdapter] = &[
|
||||||
|
&super::adapters::HeaderGoAdapter,
|
||||||
|
&super::adapters::XxeGoAdapter,
|
||||||
|
];
|
||||||
static PHP: &[&dyn FrameworkAdapter] = &[
|
static PHP: &[&dyn FrameworkAdapter] = &[
|
||||||
|
&super::adapters::HeaderPhpAdapter,
|
||||||
&super::adapters::LdapPhpAdapter,
|
&super::adapters::LdapPhpAdapter,
|
||||||
&super::adapters::PhpTwigAdapter,
|
&super::adapters::PhpTwigAdapter,
|
||||||
&super::adapters::PhpUnserializeAdapter,
|
&super::adapters::PhpUnserializeAdapter,
|
||||||
|
|
@ -63,6 +68,7 @@ static PHP: &[&dyn FrameworkAdapter] = &[
|
||||||
&super::adapters::XxePhpAdapter,
|
&super::adapters::XxePhpAdapter,
|
||||||
];
|
];
|
||||||
static PYTHON: &[&dyn FrameworkAdapter] = &[
|
static PYTHON: &[&dyn FrameworkAdapter] = &[
|
||||||
|
&super::adapters::HeaderPythonAdapter,
|
||||||
&super::adapters::LdapPythonAdapter,
|
&super::adapters::LdapPythonAdapter,
|
||||||
&super::adapters::PythonJinja2Adapter,
|
&super::adapters::PythonJinja2Adapter,
|
||||||
&super::adapters::PythonPickleAdapter,
|
&super::adapters::PythonPickleAdapter,
|
||||||
|
|
@ -70,12 +76,14 @@ static PYTHON: &[&dyn FrameworkAdapter] = &[
|
||||||
&super::adapters::XxePythonAdapter,
|
&super::adapters::XxePythonAdapter,
|
||||||
];
|
];
|
||||||
static RUBY: &[&dyn FrameworkAdapter] = &[
|
static RUBY: &[&dyn FrameworkAdapter] = &[
|
||||||
|
&super::adapters::HeaderRubyAdapter,
|
||||||
&super::adapters::RubyErbAdapter,
|
&super::adapters::RubyErbAdapter,
|
||||||
&super::adapters::RubyMarshalAdapter,
|
&super::adapters::RubyMarshalAdapter,
|
||||||
&super::adapters::XxeRubyAdapter,
|
&super::adapters::XxeRubyAdapter,
|
||||||
];
|
];
|
||||||
static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[];
|
static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[];
|
||||||
static JAVASCRIPT: &[&dyn FrameworkAdapter] = &[
|
static JAVASCRIPT: &[&dyn FrameworkAdapter] = &[
|
||||||
|
&super::adapters::HeaderJsAdapter,
|
||||||
&super::adapters::JsHandlebarsAdapter,
|
&super::adapters::JsHandlebarsAdapter,
|
||||||
&super::adapters::XpathJsAdapter,
|
&super::adapters::XpathJsAdapter,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -505,6 +505,14 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
return Ok(emit_xxe_harness(spec));
|
return Ok(emit_xxe_harness(spec));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit. The
|
||||||
|
// Go harness models `w.Header().Set("Set-Cookie", value)` and
|
||||||
|
// records the unmodified value via a `ProbeKind::HeaderEmit`
|
||||||
|
// probe.
|
||||||
|
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||||
|
return Ok(emit_header_injection_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
let entry_source = read_entry_source(&spec.entry_file);
|
let entry_source = read_entry_source(&spec.entry_file);
|
||||||
let shape = GoShape::detect(spec, &entry_source);
|
let shape = GoShape::detect(spec, &entry_source);
|
||||||
let main_go = generate_main_go(spec, shape);
|
let main_go = generate_main_go(spec, shape);
|
||||||
|
|
@ -610,6 +618,68 @@ func main() {{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 08 — Track J.6 header-injection harness for Go
|
||||||
|
/// (`http.ResponseWriter.Header().Set`).
|
||||||
|
///
|
||||||
|
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented `Header.Set`
|
||||||
|
/// shim that records the *unmodified* value bytes (including any
|
||||||
|
/// embedded `\r\n`) via a `ProbeKind::HeaderEmit` probe. Mirrors
|
||||||
|
/// the synthetic-harness pattern used by Phase 05.
|
||||||
|
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||||
|
let shim = probe_shim();
|
||||||
|
let go_mod = generate_go_mod();
|
||||||
|
let source = format!(
|
||||||
|
r##"// Nyx dynamic harness — HEADER_INJECTION http.ResponseWriter.Header().Set (Phase 08 / Track J.6).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
{shim}
|
||||||
|
|
||||||
|
func nyxHeaderProbe(name, value string) {{
|
||||||
|
__nyx_emit(map[string]interface{{}}{{
|
||||||
|
"sink_callee": "http.ResponseWriter.Header.Set",
|
||||||
|
"args": []map[string]interface{{}}{{
|
||||||
|
{{"kind": "String", "value": name}},
|
||||||
|
{{"kind": "String", "value": value}},
|
||||||
|
}},
|
||||||
|
"captured_at_ns": uint64(time.Now().UnixNano()),
|
||||||
|
"payload_id": os.Getenv("NYX_PAYLOAD_ID"),
|
||||||
|
"kind": map[string]interface{{}}{{"kind": "HeaderEmit", "name": name, "value": value}},
|
||||||
|
"witness": __nyx_witness("http.ResponseWriter.Header.Set", []string{{name, value}}),
|
||||||
|
}})
|
||||||
|
}}
|
||||||
|
|
||||||
|
func main() {{
|
||||||
|
__nyx_install_crash_guard("http.ResponseWriter.Header.Set")
|
||||||
|
defer __nyx_recover_crash("http.ResponseWriter.Header.Set")()
|
||||||
|
payload := os.Getenv("NYX_PAYLOAD")
|
||||||
|
name := "Set-Cookie"
|
||||||
|
value := payload
|
||||||
|
nyxHeaderProbe(name, value)
|
||||||
|
fmt.Println("__NYX_SINK_HIT__")
|
||||||
|
body, _ := json.Marshal(map[string]interface{{}}{{"name": name, "value": value}})
|
||||||
|
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 {
|
fn generate_main_go(spec: &HarnessSpec, shape: GoShape) -> String {
|
||||||
let entry_fn = capitalize_first(&spec.entry_name);
|
let entry_fn = capitalize_first(&spec.entry_name);
|
||||||
let pre_call = pre_call_setup(spec);
|
let pre_call = pre_call_setup(spec);
|
||||||
|
|
|
||||||
|
|
@ -567,6 +567,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
if spec.expected_cap == crate::labels::Cap::XPATH_INJECTION {
|
if spec.expected_cap == crate::labels::Cap::XPATH_INJECTION {
|
||||||
return Ok(emit_xpath_harness(spec));
|
return Ok(emit_xpath_harness(spec));
|
||||||
}
|
}
|
||||||
|
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||||
|
return Ok(emit_header_injection_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
let entry_source = read_entry_source(&spec.entry_file);
|
let entry_source = read_entry_source(&spec.entry_file);
|
||||||
let shape = JavaShape::detect(spec, &entry_source);
|
let shape = JavaShape::detect(spec, &entry_source);
|
||||||
|
|
@ -1209,6 +1212,87 @@ public class NyxHarness {{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 08 — Track J.6 header-injection harness for Java
|
||||||
|
/// (`HttpServletResponse.setHeader`).
|
||||||
|
///
|
||||||
|
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||||
|
/// `response.setHeader("Set-Cookie", value)` shim that records the
|
||||||
|
/// *unmodified* value bytes (including any embedded `\r\n`) via a
|
||||||
|
/// `ProbeKind::HeaderEmit` probe. Mirrors the synthetic-harness
|
||||||
|
/// pattern used by Phase 03 / 04 / 05 / 06 / 07.
|
||||||
|
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||||
|
let shim = probe_shim();
|
||||||
|
let source = format!(
|
||||||
|
r#"// Nyx dynamic harness — HEADER_INJECTION HttpServletResponse.setHeader (Phase 08 / Track J.6).
|
||||||
|
import java.io.FileWriter;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public class NyxHarness {{
|
||||||
|
{shim}
|
||||||
|
|
||||||
|
static void nyxHeaderProbe(String name, String value) {{
|
||||||
|
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.setHeader\",\"args\":[");
|
||||||
|
line.append("{{\"kind\":\"String\",\"value\":\"");
|
||||||
|
nyxJsonEscape(name, line);
|
||||||
|
line.append("\"}},{{\"kind\":\"String\",\"value\":\"");
|
||||||
|
nyxJsonEscape(value, line);
|
||||||
|
line.append("\"}}],");
|
||||||
|
line.append("\"captured_at_ns\":").append(now).append(',');
|
||||||
|
line.append("\"payload_id\":\"");
|
||||||
|
nyxJsonEscape(pid, line);
|
||||||
|
line.append("\",\"kind\":{{\"kind\":\"HeaderEmit\",\"name\":\"");
|
||||||
|
nyxJsonEscape(name, line);
|
||||||
|
line.append("\",\"value\":\"");
|
||||||
|
nyxJsonEscape(value, line);
|
||||||
|
line.append("\"}},");
|
||||||
|
line.append("\"witness\":");
|
||||||
|
line.append(nyxWitnessJson("HttpServletResponse.setHeader", new String[]{{name, value}}));
|
||||||
|
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 name = "Set-Cookie";
|
||||||
|
String value = payload;
|
||||||
|
nyxHeaderProbe(name, value);
|
||||||
|
System.out.println("__NYX_SINK_HIT__");
|
||||||
|
StringBuilder body = new StringBuilder(64);
|
||||||
|
body.append("{{\"name\":\"");
|
||||||
|
nyxJsonEscape(name, body);
|
||||||
|
body.append("\",\"value\":\"");
|
||||||
|
nyxJsonEscape(value, 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`,
|
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
|
||||||
/// reading the entry file from disk. Exposed so test helpers can pin a
|
/// reading the entry file from disk. Exposed so test helpers can pin a
|
||||||
/// per-fixture shape without round-tripping through [`emit`].
|
/// per-fixture shape without round-tripping through [`emit`].
|
||||||
|
|
|
||||||
|
|
@ -449,6 +449,14 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result<HarnessSource, Un
|
||||||
return Ok(emit_xpath_harness(spec));
|
return Ok(emit_xpath_harness(spec));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit. The
|
||||||
|
// synthetic harness calls an instrumented `res.setHeader` shim
|
||||||
|
// that records the unmodified value bytes via a
|
||||||
|
// `ProbeKind::HeaderEmit` probe.
|
||||||
|
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||||
|
return Ok(emit_header_injection_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
let entry_source = read_entry_source(&spec.entry_file);
|
let entry_source = read_entry_source(&spec.entry_file);
|
||||||
let shape = JsShape::detect(spec, &entry_source);
|
let shape = JsShape::detect(spec, &entry_source);
|
||||||
let entry_subpath = entry_subpath_for_shape(shape, is_typescript);
|
let entry_subpath = entry_subpath_for_shape(shape, is_typescript);
|
||||||
|
|
@ -610,6 +618,58 @@ console.log(JSON.stringify({{ expr: expr, nodes_returned: nodes }}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 08 — Track J.6 header-injection harness for Node
|
||||||
|
/// (`http.ServerResponse#setHeader`).
|
||||||
|
///
|
||||||
|
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||||
|
/// `res.setHeader('Set-Cookie', value)` shim that records the
|
||||||
|
/// *unmodified* value bytes (including any embedded `\r\n`) via a
|
||||||
|
/// `ProbeKind::HeaderEmit` probe. Mirrors the synthetic-harness
|
||||||
|
/// pattern used by Phase 03 / 04 / 05 / 06 / 07.
|
||||||
|
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||||
|
let shim = probe_shim();
|
||||||
|
let body = format!(
|
||||||
|
r#"// Nyx dynamic harness — HEADER_INJECTION http.ServerResponse#setHeader (Phase 08 / Track J.6).
|
||||||
|
{shim}
|
||||||
|
|
||||||
|
function nyxHeaderProbe(name, value) {{
|
||||||
|
const p = process.env.NYX_PROBE_PATH;
|
||||||
|
if (!p) return;
|
||||||
|
const rec = {{
|
||||||
|
sink_callee: 'http.ServerResponse#setHeader',
|
||||||
|
args: [
|
||||||
|
{{ kind: 'String', value: name }},
|
||||||
|
{{ kind: 'String', value: value }},
|
||||||
|
],
|
||||||
|
captured_at_ns: Number(process.hrtime.bigint()),
|
||||||
|
payload_id: process.env.NYX_PAYLOAD_ID || '',
|
||||||
|
kind: {{ kind: 'HeaderEmit', name: name, value: value }},
|
||||||
|
witness: __nyx_witness('http.ServerResponse#setHeader', [name, value]),
|
||||||
|
}};
|
||||||
|
try {{
|
||||||
|
require('fs').appendFileSync(p, JSON.stringify(rec) + '\n');
|
||||||
|
}} catch (e) {{
|
||||||
|
// best-effort
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
const payload = process.env.NYX_PAYLOAD || '';
|
||||||
|
const name = 'Set-Cookie';
|
||||||
|
const value = payload;
|
||||||
|
nyxHeaderProbe(name, value);
|
||||||
|
console.log('__NYX_SINK_HIT__');
|
||||||
|
console.log(JSON.stringify({{ name: name, value: value }}));
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
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).
|
/// Phase 26 — Node chain-step harness (shared between JS + TS emitters).
|
||||||
///
|
///
|
||||||
/// Splices the Node probe shim ([`probe_shim`]) in front of a minimal
|
/// Splices the Node probe shim ([`probe_shim`]) in front of a minimal
|
||||||
|
|
|
||||||
|
|
@ -432,6 +432,10 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
if spec.expected_cap == crate::labels::Cap::XPATH_INJECTION {
|
if spec.expected_cap == crate::labels::Cap::XPATH_INJECTION {
|
||||||
return Ok(emit_xpath_harness(spec));
|
return Ok(emit_xpath_harness(spec));
|
||||||
}
|
}
|
||||||
|
// Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit.
|
||||||
|
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||||
|
return Ok(emit_header_injection_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
let entry_source = read_entry_source(&spec.entry_file);
|
let entry_source = read_entry_source(&spec.entry_file);
|
||||||
let shape = PhpShape::detect(spec, &entry_source);
|
let shape = PhpShape::detect(spec, &entry_source);
|
||||||
|
|
@ -869,6 +873,54 @@ echo json_encode(['expr' => $expr, 'nodes_returned' => $nodes]) . "\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 08 — Track J.6 header-injection harness for PHP (`header()`).
|
||||||
|
///
|
||||||
|
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented `header()`
|
||||||
|
/// shim that records the *unmodified* value bytes (including any
|
||||||
|
/// embedded `\r\n`) via a `ProbeKind::HeaderEmit` probe. Mirrors
|
||||||
|
/// the synthetic-harness pattern used by Phase 03 / 04 / 05 / 06 /
|
||||||
|
/// 07.
|
||||||
|
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||||
|
let shim = probe_shim();
|
||||||
|
let body = format!(
|
||||||
|
r#"<?php
|
||||||
|
// Nyx dynamic harness — HEADER_INJECTION header() (Phase 08 / Track J.6).
|
||||||
|
{shim}
|
||||||
|
|
||||||
|
function _nyx_header_probe(string $name, string $value): void {{
|
||||||
|
$p = getenv('NYX_PROBE_PATH');
|
||||||
|
if ($p === false || $p === '') return;
|
||||||
|
$rec = [
|
||||||
|
'sink_callee' => 'header()',
|
||||||
|
'args' => [
|
||||||
|
['kind' => 'String', 'value' => $name],
|
||||||
|
['kind' => 'String', 'value' => $value],
|
||||||
|
],
|
||||||
|
'captured_at_ns' => (int) hrtime(true),
|
||||||
|
'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''),
|
||||||
|
'kind' => ['kind' => 'HeaderEmit', 'name' => $name, 'value' => $value],
|
||||||
|
'witness' => __nyx_witness('header()', [$name, $value]),
|
||||||
|
];
|
||||||
|
@file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND);
|
||||||
|
}}
|
||||||
|
|
||||||
|
$payload = (string) (getenv('NYX_PAYLOAD') ?: '');
|
||||||
|
$name = 'Set-Cookie';
|
||||||
|
$value = $payload;
|
||||||
|
_nyx_header_probe($name, $value);
|
||||||
|
echo "__NYX_SINK_HIT__\n";
|
||||||
|
echo json_encode(['name' => $name, 'value' => $value]) . "\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 {
|
fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String {
|
||||||
let entry_fn = &spec.entry_name;
|
let entry_fn = &spec.entry_name;
|
||||||
let pre_call = build_pre_call(spec, shape);
|
let pre_call = build_pre_call(spec, shape);
|
||||||
|
|
|
||||||
|
|
@ -640,6 +640,16 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
return Ok(emit_xpath_harness(spec));
|
return Ok(emit_xpath_harness(spec));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 08 (Track J.6): short-circuit to the header-injection
|
||||||
|
// harness when the spec's expected cap is HEADER_INJECTION. The
|
||||||
|
// harness splices the payload into a synthetic
|
||||||
|
// `flask.Response.headers["Set-Cookie"] = value` assignment and
|
||||||
|
// records the unescaped value via a `ProbeKind::HeaderEmit`
|
||||||
|
// probe consumed by the `HeaderInjected` oracle.
|
||||||
|
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||||
|
return Ok(emit_header_injection_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
let entry_source = read_entry_source(&spec.entry_file);
|
let entry_source = read_entry_source(&spec.entry_file);
|
||||||
let shape = PythonShape::detect(spec, &entry_source);
|
let shape = PythonShape::detect(spec, &entry_source);
|
||||||
let body = generate_for_shape(spec, shape);
|
let body = generate_for_shape(spec, shape);
|
||||||
|
|
@ -1085,6 +1095,74 @@ if __name__ == "__main__":
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 08 — Track J.6 header-injection harness for Python (Flask
|
||||||
|
/// `Response.headers.__setitem__`).
|
||||||
|
///
|
||||||
|
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||||
|
/// `flask.Response.headers["Set-Cookie"] = value` assignment that
|
||||||
|
/// records the *unmodified* value bytes (including any embedded
|
||||||
|
/// `\r\n`) via a `ProbeKind::HeaderEmit` probe. A vuln payload
|
||||||
|
/// carrying raw CRLF trips the
|
||||||
|
/// [`crate::dynamic::oracle::ProbePredicate::HeaderInjected`]
|
||||||
|
/// oracle; the paired benign control passes the same logical bytes
|
||||||
|
/// pre-encoded via `urllib.parse.quote`, so the captured value
|
||||||
|
/// carries `%0D%0A` (not the raw bytes) and the predicate stays
|
||||||
|
/// clear.
|
||||||
|
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||||
|
let probe = probe_shim();
|
||||||
|
let body = format!(
|
||||||
|
r#"#!/usr/bin/env python3
|
||||||
|
"""Nyx dynamic harness — HEADER_INJECTION flask.Response.headers.__setitem__ (Phase 08 / Track J.6)."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
{probe}
|
||||||
|
|
||||||
|
|
||||||
|
def _nyx_header_probe(name, value):
|
||||||
|
rec = {{
|
||||||
|
"sink_callee": "flask.Response.headers.__setitem__",
|
||||||
|
"args": [
|
||||||
|
{{"kind": "String", "value": name}},
|
||||||
|
{{"kind": "String", "value": value}},
|
||||||
|
],
|
||||||
|
"captured_at_ns": time.time_ns(),
|
||||||
|
"payload_id": os.environ.get("NYX_PAYLOAD_ID", ""),
|
||||||
|
"kind": {{"kind": "HeaderEmit", "name": name, "value": value}},
|
||||||
|
"witness": __nyx_witness("flask.Response.headers.__setitem__", [name, value]),
|
||||||
|
}}
|
||||||
|
__nyx_emit(rec)
|
||||||
|
|
||||||
|
|
||||||
|
def _nyx_run():
|
||||||
|
payload = os.environ.get("NYX_PAYLOAD", "")
|
||||||
|
# Synthetic instrumented setter — mirrors
|
||||||
|
# `werkzeug.datastructures.Headers.__setitem__` semantics: the
|
||||||
|
# value bytes flow through unmodified, so a tainted payload that
|
||||||
|
# carries raw `\r\n` lands on the wire as a header split.
|
||||||
|
name = "Set-Cookie"
|
||||||
|
value = payload
|
||||||
|
_nyx_header_probe(name, value)
|
||||||
|
print("__NYX_SINK_HIT__", flush=True)
|
||||||
|
sys.stdout.write(json.dumps({{"name": name, "value": value}}) + "\n")
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
|
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
|
||||||
/// reading the entry file from disk. Exposed so test helpers can pin a
|
/// reading the entry file from disk. Exposed so test helpers can pin a
|
||||||
/// per-fixture shape without round-tripping through [`emit`].
|
/// per-fixture shape without round-tripping through [`emit`].
|
||||||
|
|
|
||||||
|
|
@ -424,6 +424,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
if spec.expected_cap == crate::labels::Cap::XXE {
|
if spec.expected_cap == crate::labels::Cap::XXE {
|
||||||
return Ok(emit_xxe_harness(spec));
|
return Ok(emit_xxe_harness(spec));
|
||||||
}
|
}
|
||||||
|
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||||
|
return Ok(emit_header_injection_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
let entry_source = read_entry_source(&spec.entry_file);
|
let entry_source = read_entry_source(&spec.entry_file);
|
||||||
let shape = RubyShape::detect(spec, &entry_source);
|
let shape = RubyShape::detect(spec, &entry_source);
|
||||||
|
|
@ -616,6 +619,57 @@ STDOUT.flush
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 08 — Track J.6 header-injection harness for Ruby
|
||||||
|
/// (`Rack::Response#set_header`).
|
||||||
|
///
|
||||||
|
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||||
|
/// `response.set_header('Set-Cookie', value)` shim that records the
|
||||||
|
/// *unmodified* value bytes (including any embedded `\r\n`) via a
|
||||||
|
/// `ProbeKind::HeaderEmit` probe. Mirrors the synthetic-harness
|
||||||
|
/// pattern used by Phase 03 / 04 / 05.
|
||||||
|
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||||
|
let shim = probe_shim();
|
||||||
|
let body = format!(
|
||||||
|
r#"# Nyx dynamic harness — HEADER_INJECTION Rack::Response#set_header (Phase 08 / Track J.6).
|
||||||
|
require 'json'
|
||||||
|
|
||||||
|
{shim}
|
||||||
|
|
||||||
|
def _nyx_header_probe(name, value)
|
||||||
|
p = ENV['NYX_PROBE_PATH']
|
||||||
|
return if p.nil? || p.empty?
|
||||||
|
rec = {{
|
||||||
|
'sink_callee' => 'Rack::Response#set_header',
|
||||||
|
'args' => [
|
||||||
|
{{ 'kind' => 'String', 'value' => name }},
|
||||||
|
{{ 'kind' => 'String', 'value' => value }},
|
||||||
|
],
|
||||||
|
'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond),
|
||||||
|
'payload_id' => ENV['NYX_PAYLOAD_ID'] || '',
|
||||||
|
'kind' => {{ 'kind' => 'HeaderEmit', 'name' => name, 'value' => value }},
|
||||||
|
'witness' => __nyx_witness('Rack::Response#set_header', [name, value]),
|
||||||
|
}}
|
||||||
|
File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }}
|
||||||
|
end
|
||||||
|
|
||||||
|
payload = ENV['NYX_PAYLOAD'] || ''
|
||||||
|
name = 'Set-Cookie'
|
||||||
|
value = payload
|
||||||
|
_nyx_header_probe(name, value)
|
||||||
|
STDOUT.puts '__NYX_SINK_HIT__'
|
||||||
|
STDOUT.puts JSON.generate({{ 'name' => name, 'value' => value }})
|
||||||
|
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 {
|
fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String {
|
||||||
let entry_fn = &spec.entry_name;
|
let entry_fn = &spec.entry_name;
|
||||||
let pre_call = build_pre_call(spec);
|
let pre_call = build_pre_call(spec);
|
||||||
|
|
|
||||||
|
|
@ -557,6 +557,96 @@ pub fn detect_shape(spec: &HarnessSpec) -> RustShape {
|
||||||
RustShape::detect(spec, &src)
|
RustShape::detect(spec, &src)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 08 — Track J.6 header-injection harness for Rust
|
||||||
|
/// (`axum`-style `HeaderMap::insert`).
|
||||||
|
///
|
||||||
|
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||||
|
/// `headers_mut().insert("Set-Cookie", value)` shim that records the
|
||||||
|
/// *unmodified* value bytes (including any embedded `\r\n`) via a
|
||||||
|
/// `ProbeKind::HeaderEmit` probe. Std-only — no `Cargo.toml`
|
||||||
|
/// dependencies beyond the always-pinned `libc` (used by the probe
|
||||||
|
/// shim's crash guard).
|
||||||
|
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||||
|
let shim = probe_shim();
|
||||||
|
let cargo_toml = generate_cargo_toml(Cap::HEADER_INJECTION);
|
||||||
|
let main_rs = format!(
|
||||||
|
r##"//! Nyx dynamic harness — HEADER_INJECTION HeaderMap::insert (Phase 08 / Track J.6).
|
||||||
|
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_header_probe(name: &str, value: &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\":\"HeaderMap::insert\",\"args\":[");
|
||||||
|
line.push_str("{{\"kind\":\"String\",\"value\":\"");
|
||||||
|
line.push_str(&nyx_json_escape(name));
|
||||||
|
line.push_str("\"}},{{\"kind\":\"String\",\"value\":\"");
|
||||||
|
line.push_str(&nyx_json_escape(value));
|
||||||
|
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\":\"HeaderEmit\",\"name\":\"");
|
||||||
|
line.push_str(&nyx_json_escape(name));
|
||||||
|
line.push_str("\",\"value\":\"");
|
||||||
|
line.push_str(&nyx_json_escape(value));
|
||||||
|
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 name = "Set-Cookie";
|
||||||
|
let value = &payload;
|
||||||
|
nyx_header_probe(name, value);
|
||||||
|
println!("__NYX_SINK_HIT__");
|
||||||
|
let mut body = String::new();
|
||||||
|
body.push_str("{{\"name\":\"");
|
||||||
|
body.push_str(&nyx_json_escape(name));
|
||||||
|
body.push_str("\",\"value\":\"");
|
||||||
|
body.push_str(&nyx_json_escape(value));
|
||||||
|
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 {
|
fn read_entry_source(entry_file: &str) -> String {
|
||||||
let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)];
|
let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)];
|
||||||
for path in &candidates {
|
for path in &candidates {
|
||||||
|
|
@ -569,6 +659,14 @@ fn read_entry_source(entry_file: &str) -> String {
|
||||||
|
|
||||||
/// Emit a Rust harness for `spec`.
|
/// Emit a Rust harness for `spec`.
|
||||||
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
|
// Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit. The
|
||||||
|
// Rust harness models an `axum`-style `HeaderMap::insert` shim
|
||||||
|
// that records the *unmodified* value bytes via a
|
||||||
|
// `ProbeKind::HeaderEmit` probe.
|
||||||
|
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||||
|
return Ok(emit_header_injection_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
let shape = detect_shape(spec);
|
let shape = detect_shape(spec);
|
||||||
|
|
||||||
// Generic + LibfuzzerTarget accept Param(0)/EnvVar; richer shapes
|
// Generic + LibfuzzerTarget accept Param(0)/EnvVar; richer shapes
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,32 @@ pub enum ProbePredicate {
|
||||||
/// the parser-refusal benign control still confirm.
|
/// the parser-refusal benign control still confirm.
|
||||||
require_expanded: bool,
|
require_expanded: bool,
|
||||||
},
|
},
|
||||||
|
/// Phase 08 (Track J.6): HTTP response-header CRLF-injection
|
||||||
|
/// predicate.
|
||||||
|
///
|
||||||
|
/// Fires when at least one drained probe carries
|
||||||
|
/// [`ProbeKind::HeaderEmit`] whose `name` equals `header_name` (or
|
||||||
|
/// `header_name` is the wildcard `"*"`) and whose `value` contains
|
||||||
|
/// a literal `\r\n` byte pair. The vuln payload splices `\r\n`
|
||||||
|
/// followed by an injected header line into the response writer's
|
||||||
|
/// value argument; the per-language harness's instrumented
|
||||||
|
/// `setHeader` records the unmodified bytes the host process
|
||||||
|
/// passed in. The benign control passes the same logical value
|
||||||
|
/// through `URLEncoder.encode` / `urllib.parse.quote`, so the
|
||||||
|
/// captured value carries `%0d%0a` (not the raw bytes) and the
|
||||||
|
/// predicate stays clear.
|
||||||
|
///
|
||||||
|
/// Cross-cutting in the same sense as
|
||||||
|
/// [`Self::DeserializeGadgetInvoked`] /
|
||||||
|
/// [`Self::XxeEntityExpanded`] /
|
||||||
|
/// [`Self::QueryResultCountGreaterThan`] — evaluated across every
|
||||||
|
/// drained probe rather than against a single record.
|
||||||
|
HeaderInjected {
|
||||||
|
/// Header name the malicious payload targets (e.g.
|
||||||
|
/// `"Set-Cookie"`, `"Location"`). Use `"*"` to satisfy on any
|
||||||
|
/// captured header whose value contains the CRLF pair.
|
||||||
|
header_name: &'static str,
|
||||||
|
},
|
||||||
/// Phase 06 (Track J.4) / Phase 07 (Track J.5): result-count
|
/// Phase 06 (Track J.4) / Phase 07 (Track J.5): result-count
|
||||||
/// predicate shared by LDAP-filter and XPath-expression injection.
|
/// predicate shared by LDAP-filter and XPath-expression injection.
|
||||||
///
|
///
|
||||||
|
|
@ -404,6 +430,20 @@ pub fn oracle_fired_with_stubs(
|
||||||
if !query_count_cross_ok {
|
if !query_count_cross_ok {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Phase 08 (Track J.6): header-injection cross-cutting
|
||||||
|
// predicates. Each `HeaderInjected { header_name }`
|
||||||
|
// consults the captured probe channel for a
|
||||||
|
// [`ProbeKind::HeaderEmit`] record whose `name` matches
|
||||||
|
// and whose `value` contains a literal CRLF byte pair.
|
||||||
|
let header_injected_ok = cross.iter().all(|p| match p {
|
||||||
|
ProbePredicate::HeaderInjected { header_name } => {
|
||||||
|
probes_satisfy_header_injected(probes, header_name)
|
||||||
|
}
|
||||||
|
_ => true,
|
||||||
|
});
|
||||||
|
if !header_injected_ok {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Phase 04 (Track J.2): SSTI render-equality cross-cutting
|
// Phase 04 (Track J.2): SSTI render-equality cross-cutting
|
||||||
// predicates. Each `TemplateEvalEqual { expected }` consults
|
// predicates. Each `TemplateEvalEqual { expected }` consults
|
||||||
// the captured stdout body — see [`stdout_template_equals`].
|
// the captured stdout body — see [`stdout_template_equals`].
|
||||||
|
|
@ -429,13 +469,14 @@ pub fn oracle_fired_with_stubs(
|
||||||
.any(|p| per_probe.iter().all(|pred| probe_satisfies_one(p, pred))),
|
.any(|p| per_probe.iter().all(|pred| probe_satisfies_one(p, pred))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Oracle::SinkCrash { signals } => probes.iter().any(|p| match p.kind {
|
Oracle::SinkCrash { signals } => probes.iter().any(|p| match &p.kind {
|
||||||
ProbeKind::Crash { signal } => signals.contains(signal),
|
ProbeKind::Crash { signal } => signals.contains(*signal),
|
||||||
ProbeKind::Normal
|
ProbeKind::Normal
|
||||||
| ProbeKind::Deserialize { .. }
|
| ProbeKind::Deserialize { .. }
|
||||||
| ProbeKind::Xxe { .. }
|
| ProbeKind::Xxe { .. }
|
||||||
| ProbeKind::Ldap { .. }
|
| ProbeKind::Ldap { .. }
|
||||||
| ProbeKind::Xpath { .. } => false,
|
| ProbeKind::Xpath { .. }
|
||||||
|
| ProbeKind::HeaderEmit { .. } => false,
|
||||||
}),
|
}),
|
||||||
Oracle::OutputContains(needle) => {
|
Oracle::OutputContains(needle) => {
|
||||||
let nb = needle.as_bytes();
|
let nb = needle.as_bytes();
|
||||||
|
|
@ -462,6 +503,7 @@ fn is_cross_cutting(pred: &ProbePredicate) -> bool {
|
||||||
| ProbePredicate::TemplateEvalEqual { .. }
|
| ProbePredicate::TemplateEvalEqual { .. }
|
||||||
| ProbePredicate::XxeEntityExpanded { .. }
|
| ProbePredicate::XxeEntityExpanded { .. }
|
||||||
| ProbePredicate::QueryResultCountGreaterThan { .. }
|
| ProbePredicate::QueryResultCountGreaterThan { .. }
|
||||||
|
| ProbePredicate::HeaderInjected { .. }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -486,6 +528,10 @@ fn cross_cutting_satisfied(pred: &ProbePredicate, stub_events: &[StubEvent]) ->
|
||||||
// *probe log* rather than stub events; evaluated separately
|
// *probe log* rather than stub events; evaluated separately
|
||||||
// in [`probes_satisfy_count_gt`] below.
|
// in [`probes_satisfy_count_gt`] below.
|
||||||
ProbePredicate::QueryResultCountGreaterThan { .. } => true,
|
ProbePredicate::QueryResultCountGreaterThan { .. } => true,
|
||||||
|
// HeaderInjected is cross-cutting against the *probe log*
|
||||||
|
// rather than stub events; evaluated separately in
|
||||||
|
// [`probes_satisfy_header_injected`] below.
|
||||||
|
ProbePredicate::HeaderInjected { .. } => true,
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -533,9 +579,9 @@ fn stdout_template_equals(stdout: &[u8], expected: u64) -> bool {
|
||||||
/// True when at least one drained probe is a
|
/// True when at least one drained probe is a
|
||||||
/// [`ProbeKind::Deserialize`] record matching `require_invoked`.
|
/// [`ProbeKind::Deserialize`] record matching `require_invoked`.
|
||||||
fn probes_satisfy_deserialize(probes: &[SinkProbe], require_invoked: bool) -> bool {
|
fn probes_satisfy_deserialize(probes: &[SinkProbe], require_invoked: bool) -> bool {
|
||||||
probes.iter().any(|p| match p.kind {
|
probes.iter().any(|p| match &p.kind {
|
||||||
ProbeKind::Deserialize { gadget_chain_invoked } => {
|
ProbeKind::Deserialize { gadget_chain_invoked } => {
|
||||||
gadget_chain_invoked == require_invoked
|
*gadget_chain_invoked == require_invoked
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
})
|
})
|
||||||
|
|
@ -544,8 +590,8 @@ fn probes_satisfy_deserialize(probes: &[SinkProbe], require_invoked: bool) -> bo
|
||||||
/// True when at least one drained probe is a [`ProbeKind::Xxe`]
|
/// True when at least one drained probe is a [`ProbeKind::Xxe`]
|
||||||
/// record matching `require_expanded`.
|
/// record matching `require_expanded`.
|
||||||
fn probes_satisfy_xxe(probes: &[SinkProbe], require_expanded: bool) -> bool {
|
fn probes_satisfy_xxe(probes: &[SinkProbe], require_expanded: bool) -> bool {
|
||||||
probes.iter().any(|p| match p.kind {
|
probes.iter().any(|p| match &p.kind {
|
||||||
ProbeKind::Xxe { entity_expanded } => entity_expanded == require_expanded,
|
ProbeKind::Xxe { entity_expanded } => *entity_expanded == require_expanded,
|
||||||
_ => false,
|
_ => false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -555,9 +601,24 @@ fn probes_satisfy_xxe(probes: &[SinkProbe], require_expanded: bool) -> bool {
|
||||||
/// (`entries_returned > n`) and [`ProbeKind::Xpath`]
|
/// (`entries_returned > n`) and [`ProbeKind::Xpath`]
|
||||||
/// (`nodes_returned > n`).
|
/// (`nodes_returned > n`).
|
||||||
fn probes_satisfy_count_gt(probes: &[SinkProbe], n: u32) -> bool {
|
fn probes_satisfy_count_gt(probes: &[SinkProbe], n: u32) -> bool {
|
||||||
probes.iter().any(|p| match p.kind {
|
probes.iter().any(|p| match &p.kind {
|
||||||
ProbeKind::Ldap { entries_returned } => entries_returned > n,
|
ProbeKind::Ldap { entries_returned } => *entries_returned > n,
|
||||||
ProbeKind::Xpath { nodes_returned } => nodes_returned > n,
|
ProbeKind::Xpath { nodes_returned } => *nodes_returned > n,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when at least one drained probe is a
|
||||||
|
/// [`ProbeKind::HeaderEmit`] record whose `name` matches `header_name`
|
||||||
|
/// (or `header_name == "*"`) and whose `value` contains a literal
|
||||||
|
/// `\r\n` byte pair. Powers
|
||||||
|
/// [`ProbePredicate::HeaderInjected`] (Phase 08 — Track J.6).
|
||||||
|
fn probes_satisfy_header_injected(probes: &[SinkProbe], header_name: &str) -> bool {
|
||||||
|
probes.iter().any(|p| match &p.kind {
|
||||||
|
ProbeKind::HeaderEmit { name, value } => {
|
||||||
|
(header_name == "*" || name.eq_ignore_ascii_case(header_name))
|
||||||
|
&& value.contains("\r\n")
|
||||||
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -595,7 +656,8 @@ fn probe_satisfies_one(probe: &SinkProbe, pred: &ProbePredicate) -> bool {
|
||||||
| ProbePredicate::DeserializeGadgetInvoked { .. }
|
| ProbePredicate::DeserializeGadgetInvoked { .. }
|
||||||
| ProbePredicate::TemplateEvalEqual { .. }
|
| ProbePredicate::TemplateEvalEqual { .. }
|
||||||
| ProbePredicate::XxeEntityExpanded { .. }
|
| ProbePredicate::XxeEntityExpanded { .. }
|
||||||
| ProbePredicate::QueryResultCountGreaterThan { .. } => true,
|
| ProbePredicate::QueryResultCountGreaterThan { .. }
|
||||||
|
| ProbePredicate::HeaderInjected { .. } => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -615,13 +677,14 @@ fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
|
||||||
/// `Inconclusive(UnrelatedCrash)`) from "process crashed and a sink-site
|
/// `Inconclusive(UnrelatedCrash)`) from "process crashed and a sink-site
|
||||||
/// probe matched" (→ `Confirmed` via `Oracle::SinkCrash`).
|
/// probe matched" (→ `Confirmed` via `Oracle::SinkCrash`).
|
||||||
pub fn probe_crash_signal(probe: &SinkProbe) -> Option<Signal> {
|
pub fn probe_crash_signal(probe: &SinkProbe) -> Option<Signal> {
|
||||||
match probe.kind {
|
match &probe.kind {
|
||||||
ProbeKind::Crash { signal } => Some(signal),
|
ProbeKind::Crash { signal } => Some(*signal),
|
||||||
ProbeKind::Normal
|
ProbeKind::Normal
|
||||||
| ProbeKind::Deserialize { .. }
|
| ProbeKind::Deserialize { .. }
|
||||||
| ProbeKind::Xxe { .. }
|
| ProbeKind::Xxe { .. }
|
||||||
| ProbeKind::Ldap { .. }
|
| ProbeKind::Ldap { .. }
|
||||||
| ProbeKind::Xpath { .. } => None,
|
| ProbeKind::Xpath { .. }
|
||||||
|
| ProbeKind::HeaderEmit { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ impl ProbeArg {
|
||||||
/// [`crate::dynamic::oracle::Oracle::SinkCrash`] variant ignores anything
|
/// [`crate::dynamic::oracle::Oracle::SinkCrash`] variant ignores anything
|
||||||
/// other than `Crash { signal }`, so a process-level abort outside the
|
/// other than `Crash { signal }`, so a process-level abort outside the
|
||||||
/// sink no longer satisfies the oracle.
|
/// sink no longer satisfies the oracle.
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(tag = "kind")]
|
#[serde(tag = "kind")]
|
||||||
pub enum ProbeKind {
|
pub enum ProbeKind {
|
||||||
/// Standard sink observation: arguments were captured before the sink
|
/// Standard sink observation: arguments were captured before the sink
|
||||||
|
|
@ -190,6 +190,28 @@ pub enum ProbeKind {
|
||||||
/// payload's XPath expression.
|
/// payload's XPath expression.
|
||||||
nodes_returned: u32,
|
nodes_returned: u32,
|
||||||
},
|
},
|
||||||
|
/// Phase 08 (Track J.6) HTTP-response-header-write observation.
|
||||||
|
/// Stamped by the per-language harness shim's instrumented header
|
||||||
|
/// setter (`HttpServletResponse.setHeader`,
|
||||||
|
/// `flask.Response.headers.__setitem__`, `header(...)`,
|
||||||
|
/// `Rack::Response#set_header`, `res.setHeader`, `w.Header().Set`,
|
||||||
|
/// `HeaderMap::insert`). The shim records exactly one probe per
|
||||||
|
/// `setHeader(name, value)` call carrying the raw bytes the host
|
||||||
|
/// process emitted — the
|
||||||
|
/// [`crate::dynamic::oracle::ProbePredicate::HeaderInjected`]
|
||||||
|
/// predicate scans `value` for an embedded `\r\n` byte pair, which
|
||||||
|
/// is the signal that the attacker payload split one header into
|
||||||
|
/// two on the wire.
|
||||||
|
HeaderEmit {
|
||||||
|
/// Header name the host attempted to set (e.g. `"Set-Cookie"`,
|
||||||
|
/// `"Location"`). Echoed verbatim so the predicate can pin
|
||||||
|
/// per-header expectations without name normalisation.
|
||||||
|
name: String,
|
||||||
|
/// Raw header value the host attempted to set. A vulnerable
|
||||||
|
/// host concatenates attacker bytes into this string without
|
||||||
|
/// CRLF stripping; a benign host URL-encodes them (`%0d%0a`).
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ProbeKind {
|
impl Default for ProbeKind {
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ pub const NYX_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
/// [`crate::dynamic::corpus::CORPUS_VERSION`]; the compile-time assertion
|
/// [`crate::dynamic::corpus::CORPUS_VERSION`]; the compile-time assertion
|
||||||
/// below + the [`corpus_version_const_matches_corpus_module`] runtime test
|
/// below + the [`corpus_version_const_matches_corpus_module`] runtime test
|
||||||
/// jointly guard drift.
|
/// jointly guard drift.
|
||||||
pub const CORPUS_VERSION: &str = "11";
|
pub const CORPUS_VERSION: &str = "12";
|
||||||
|
|
||||||
/// Compile-time guard that pins [`CORPUS_VERSION`] (this module) to the
|
/// Compile-time guard that pins [`CORPUS_VERSION`] (this module) to the
|
||||||
/// textual form of [`crate::dynamic::corpus::CORPUS_VERSION`]. Bumping the
|
/// textual form of [`crate::dynamic::corpus::CORPUS_VERSION`]. Bumping the
|
||||||
|
|
|
||||||
15
tests/dynamic_fixtures/header_injection/go/benign.go
Normal file
15
tests/dynamic_fixtures/header_injection/go/benign.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Phase 08 (Track J.6) — Go HEADER_INJECTION benign control fixture.
|
||||||
|
//
|
||||||
|
// Same shape as `vuln.go` but URL-encodes the value via
|
||||||
|
// `net/url.QueryEscape` before the header set, so CRLF bytes land as
|
||||||
|
// `%0D%0A` and the wire keeps a single header.
|
||||||
|
package benign
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(w http.ResponseWriter, value string) {
|
||||||
|
w.Header().Set("Set-Cookie", url.QueryEscape(value))
|
||||||
|
}
|
||||||
13
tests/dynamic_fixtures/header_injection/go/vuln.go
Normal file
13
tests/dynamic_fixtures/header_injection/go/vuln.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Phase 08 (Track J.6) — Go HEADER_INJECTION vuln fixture.
|
||||||
|
//
|
||||||
|
// The function assigns the attacker-controlled `value` directly into a
|
||||||
|
// `Set-Cookie` header via `http.ResponseWriter.Header().Set`. A
|
||||||
|
// payload carrying `\r\nSet-Cookie: nyx-injected=pwn` splits the
|
||||||
|
// single header into two on the wire.
|
||||||
|
package vuln
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func Run(w http.ResponseWriter, value string) {
|
||||||
|
w.Header().Set("Set-Cookie", value)
|
||||||
|
}
|
||||||
16
tests/dynamic_fixtures/header_injection/java/Benign.java
Normal file
16
tests/dynamic_fixtures/header_injection/java/Benign.java
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Phase 08 (Track J.6) — Java HEADER_INJECTION benign control fixture.
|
||||||
|
//
|
||||||
|
// Same shape as `Vuln.java` but URL-encodes the value via
|
||||||
|
// `URLEncoder.encode` (the OWASP-recommended defence), so any CRLF
|
||||||
|
// bytes in the value land as `%0D%0A` and the wire keeps a single
|
||||||
|
// header.
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
public class Benign {
|
||||||
|
public static void run(HttpServletResponse response, String value) {
|
||||||
|
String encoded = URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||||
|
response.setHeader("Set-Cookie", encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tests/dynamic_fixtures/header_injection/java/Vuln.java
Normal file
13
tests/dynamic_fixtures/header_injection/java/Vuln.java
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Phase 08 (Track J.6) — Java HEADER_INJECTION vuln fixture.
|
||||||
|
//
|
||||||
|
// The function string-concatenates the attacker-controlled `value`
|
||||||
|
// directly into a `Set-Cookie` header set via
|
||||||
|
// `HttpServletResponse.setHeader`. A payload carrying `\r\nSet-Cookie:
|
||||||
|
// nyx-injected=pwn` splits the single header into two on the wire.
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
public class Vuln {
|
||||||
|
public static void run(HttpServletResponse response, String value) {
|
||||||
|
response.setHeader("Set-Cookie", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tests/dynamic_fixtures/header_injection/js/benign.js
Normal file
13
tests/dynamic_fixtures/header_injection/js/benign.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Phase 08 (Track J.6) — JavaScript HEADER_INJECTION benign control
|
||||||
|
// fixture.
|
||||||
|
//
|
||||||
|
// Same shape as `vuln.js` but URL-encodes the value first via
|
||||||
|
// `encodeURIComponent`, so CRLF bytes land as `%0D%0A` and the wire
|
||||||
|
// keeps a single header.
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
function run(res, value) {
|
||||||
|
res.setHeader('Set-Cookie', encodeURIComponent(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { run };
|
||||||
13
tests/dynamic_fixtures/header_injection/js/vuln.js
Normal file
13
tests/dynamic_fixtures/header_injection/js/vuln.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Phase 08 (Track J.6) — JavaScript HEADER_INJECTION vuln fixture.
|
||||||
|
//
|
||||||
|
// The function assigns the attacker-controlled `value` directly into a
|
||||||
|
// Node response's `Set-Cookie` header via `http.ServerResponse
|
||||||
|
// #setHeader`. A payload carrying `\r\nSet-Cookie: nyx-injected=pwn`
|
||||||
|
// splits the single header into two on the wire.
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
function run(res, value) {
|
||||||
|
res.setHeader('Set-Cookie', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { run };
|
||||||
9
tests/dynamic_fixtures/header_injection/php/benign.php
Normal file
9
tests/dynamic_fixtures/header_injection/php/benign.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
// Phase 08 (Track J.6) — PHP HEADER_INJECTION benign control fixture.
|
||||||
|
//
|
||||||
|
// Same shape as `vuln.php` but URL-encodes the value first via
|
||||||
|
// `urlencode`, so CRLF bytes land as `%0D%0A` and the wire keeps a
|
||||||
|
// single header.
|
||||||
|
function run($value) {
|
||||||
|
header("Set-Cookie: " . urlencode($value));
|
||||||
|
}
|
||||||
10
tests/dynamic_fixtures/header_injection/php/vuln.php
Normal file
10
tests/dynamic_fixtures/header_injection/php/vuln.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
// Phase 08 (Track J.6) — PHP HEADER_INJECTION vuln fixture.
|
||||||
|
//
|
||||||
|
// The function concatenates the attacker-controlled `$value` directly
|
||||||
|
// into a `Set-Cookie` header set via the built-in `header()` function.
|
||||||
|
// A payload carrying `\r\nSet-Cookie: nyx-injected=pwn` splits the
|
||||||
|
// single header into two on the wire.
|
||||||
|
function run($value) {
|
||||||
|
header("Set-Cookie: " . $value);
|
||||||
|
}
|
||||||
13
tests/dynamic_fixtures/header_injection/python/benign.py
Normal file
13
tests/dynamic_fixtures/header_injection/python/benign.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Phase 08 (Track J.6) — Python HEADER_INJECTION benign control fixture.
|
||||||
|
#
|
||||||
|
# Same shape as `vuln.py` but URL-encodes the value via
|
||||||
|
# `urllib.parse.quote` first, so CRLF bytes land as `%0D%0A` and the
|
||||||
|
# wire keeps a single header.
|
||||||
|
from urllib.parse import quote
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
|
||||||
|
def run(value):
|
||||||
|
response = Response("ok")
|
||||||
|
response.headers["Set-Cookie"] = quote(value, safe="")
|
||||||
|
return response
|
||||||
13
tests/dynamic_fixtures/header_injection/python/vuln.py
Normal file
13
tests/dynamic_fixtures/header_injection/python/vuln.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Phase 08 (Track J.6) — Python HEADER_INJECTION vuln fixture.
|
||||||
|
#
|
||||||
|
# The function assigns the attacker-controlled `value` directly into
|
||||||
|
# a Flask response's `Set-Cookie` header via `Response.headers
|
||||||
|
# .__setitem__`. A payload carrying `\r\nSet-Cookie: nyx-injected=pwn`
|
||||||
|
# splits the single header into two on the wire.
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
|
||||||
|
def run(value):
|
||||||
|
response = Response("ok")
|
||||||
|
response.headers["Set-Cookie"] = value
|
||||||
|
return response
|
||||||
13
tests/dynamic_fixtures/header_injection/ruby/benign.rb
Normal file
13
tests/dynamic_fixtures/header_injection/ruby/benign.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Phase 08 (Track J.6) — Ruby HEADER_INJECTION benign control fixture.
|
||||||
|
#
|
||||||
|
# Same shape as `vuln.rb` but URL-encodes the value first via
|
||||||
|
# `URI.encode_www_form_component`, so CRLF bytes land as `%0D%0A` and
|
||||||
|
# the wire keeps a single header.
|
||||||
|
require 'rack'
|
||||||
|
require 'uri'
|
||||||
|
|
||||||
|
def run(value)
|
||||||
|
response = Rack::Response.new
|
||||||
|
response.set_header('Set-Cookie', URI.encode_www_form_component(value))
|
||||||
|
response
|
||||||
|
end
|
||||||
13
tests/dynamic_fixtures/header_injection/ruby/vuln.rb
Normal file
13
tests/dynamic_fixtures/header_injection/ruby/vuln.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Phase 08 (Track J.6) — Ruby HEADER_INJECTION vuln fixture.
|
||||||
|
#
|
||||||
|
# The function assigns the attacker-controlled `value` directly into a
|
||||||
|
# Rack response's `Set-Cookie` header via `Rack::Response#set_header`.
|
||||||
|
# A payload carrying `\r\nSet-Cookie: nyx-injected=pwn` splits the
|
||||||
|
# single header into two on the wire.
|
||||||
|
require 'rack'
|
||||||
|
|
||||||
|
def run(value)
|
||||||
|
response = Rack::Response.new
|
||||||
|
response.set_header('Set-Cookie', value)
|
||||||
|
response
|
||||||
|
end
|
||||||
16
tests/dynamic_fixtures/header_injection/rust/benign.rs
Normal file
16
tests/dynamic_fixtures/header_injection/rust/benign.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Phase 08 (Track J.6) — Rust HEADER_INJECTION benign control fixture.
|
||||||
|
//
|
||||||
|
// Same shape as `vuln.rs` but routes the value through the
|
||||||
|
// `percent-encoding` crate first, so CRLF bytes land as `%0D%0A` and
|
||||||
|
// the wire keeps a single header.
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||||
|
|
||||||
|
pub fn run(headers: &mut HeaderMap, value: &str) {
|
||||||
|
let encoded: String = utf8_percent_encode(value, NON_ALPHANUMERIC).collect();
|
||||||
|
headers.insert(
|
||||||
|
"set-cookie",
|
||||||
|
HeaderValue::from_str(&encoded).unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
17
tests/dynamic_fixtures/header_injection/rust/vuln.rs
Normal file
17
tests/dynamic_fixtures/header_injection/rust/vuln.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Phase 08 (Track J.6) — Rust HEADER_INJECTION vuln fixture.
|
||||||
|
//
|
||||||
|
// The function inserts the attacker-controlled `value` into an axum
|
||||||
|
// `HeaderMap` via `headers_mut().insert`, bypassing
|
||||||
|
// `HeaderValue::from_str`'s newline check by passing the tainted
|
||||||
|
// bytes through `HeaderValue::from_bytes(...).unwrap()`. A payload
|
||||||
|
// carrying `\r\nSet-Cookie: nyx-injected=pwn` splits the single
|
||||||
|
// header into two on the wire.
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
|
||||||
|
pub fn run(headers: &mut HeaderMap, value: &str) {
|
||||||
|
headers.insert(
|
||||||
|
"set-cookie",
|
||||||
|
HeaderValue::from_bytes(value.as_bytes()).unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
429
tests/header_injection_corpus.rs
Normal file
429
tests/header_injection_corpus.rs
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
//! Phase 08 (Track J.6) — HEADER_INJECTION 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
|
||||||
|
//! `setHeader` shim + `HeaderEmit` probe + sink-hit sentinel, the
|
||||||
|
//! framework adapters fire on the canonical sink call, and the
|
||||||
|
//! `HeaderInjected` predicate fires only on probes whose value
|
||||||
|
//! carries a literal `\r\n` byte pair.
|
||||||
|
//!
|
||||||
|
//! `cargo nextest run --features dynamic --test header_injection_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: "phase08test0001".into(),
|
||||||
|
entry_file: entry_file.into(),
|
||||||
|
entry_name: entry_name.into(),
|
||||||
|
entry_kind: EntryKind::Function,
|
||||||
|
lang,
|
||||||
|
toolchain_id: "phase08".into(),
|
||||||
|
payload_slot: PayloadSlot::Param(0),
|
||||||
|
expected_cap: Cap::HEADER_INJECTION,
|
||||||
|
constraint_hints: vec![],
|
||||||
|
sink_file: entry_file.into(),
|
||||||
|
sink_line: 1,
|
||||||
|
spec_hash: "phase08test0001".into(),
|
||||||
|
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
||||||
|
stubs_required: vec![],
|
||||||
|
framework: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corpus_registers_header_injection_for_every_supported_lang() {
|
||||||
|
for lang in LANGS {
|
||||||
|
let slice = payloads_for_lang(Cap::HEADER_INJECTION, *lang);
|
||||||
|
assert!(
|
||||||
|
!slice.is_empty(),
|
||||||
|
"HEADER_INJECTION 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:?} HEADER_INJECTION missing vuln payload");
|
||||||
|
assert!(
|
||||||
|
has_benign,
|
||||||
|
"{lang:?} HEADER_INJECTION missing benign control"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_injection_unsupported_caps_unchanged_for_other_langs() {
|
||||||
|
for lang in [Lang::C, Lang::Cpp, Lang::TypeScript] {
|
||||||
|
assert!(
|
||||||
|
payloads_for_lang(Cap::HEADER_INJECTION, lang).is_empty(),
|
||||||
|
"unexpected HEADER_INJECTION payloads for {lang:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn benign_control_resolves_within_lang_slice() {
|
||||||
|
for lang in LANGS {
|
||||||
|
let slice = payloads_for_lang(Cap::HEADER_INJECTION, *lang);
|
||||||
|
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||||
|
let resolved = resolve_benign_control_lang(vuln, Cap::HEADER_INJECTION, *lang)
|
||||||
|
.expect("paired control");
|
||||||
|
assert!(resolved.is_benign);
|
||||||
|
let direct = benign_payload_for_lang(Cap::HEADER_INJECTION, *lang).unwrap();
|
||||||
|
assert_eq!(direct.label, resolved.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_oracle_carries_header_injected_predicate() {
|
||||||
|
for lang in LANGS {
|
||||||
|
let slice = payloads_for_lang(Cap::HEADER_INJECTION, *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::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie"
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
"{lang:?} vuln payload missing HeaderInjected predicate",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected SinkProbe oracle for {lang:?}, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vuln_payload_bytes_carry_crlf_benign_bytes_do_not() {
|
||||||
|
// Vuln payload carries raw `\r\n`; benign control carries the
|
||||||
|
// URL-encoded `%0D%0A` form instead.
|
||||||
|
for lang in LANGS {
|
||||||
|
let slice = payloads_for_lang(Cap::HEADER_INJECTION, *lang);
|
||||||
|
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||||
|
let benign = slice.iter().find(|p| p.is_benign).unwrap();
|
||||||
|
assert!(
|
||||||
|
vuln.bytes.windows(2).any(|w| w == b"\r\n"),
|
||||||
|
"{lang:?} vuln payload must carry a raw CRLF pair",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!benign.bytes.windows(2).any(|w| w == b"\r\n"),
|
||||||
|
"{lang:?} benign control must NOT carry a raw CRLF pair",
|
||||||
|
);
|
||||||
|
let benign_text = std::str::from_utf8(benign.bytes).unwrap();
|
||||||
|
assert!(
|
||||||
|
benign_text.contains("%0D%0A") || benign_text.contains("%0d%0a"),
|
||||||
|
"{lang:?} benign control must URL-encode the CRLF as %0D%0A",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marker_collisions_clean_with_phase_08_additions() {
|
||||||
|
assert!(audit_marker_collisions().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_kind_header_emit_serdes() {
|
||||||
|
let original = ProbeKind::HeaderEmit {
|
||||||
|
name: "Set-Cookie".into(),
|
||||||
|
value: "nyx-session\r\nSet-Cookie: nyx-injected=pwn".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&original).unwrap();
|
||||||
|
assert!(json.contains("HeaderEmit"));
|
||||||
|
assert!(json.contains("name"));
|
||||||
|
assert!(json.contains("value"));
|
||||||
|
let parsed: ProbeKind = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_injected_predicate_fires_on_crlf_value() {
|
||||||
|
let oracle = Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let probes = vec![SinkProbe {
|
||||||
|
sink_callee: "HttpServletResponse.setHeader".into(),
|
||||||
|
args: vec![],
|
||||||
|
captured_at_ns: 1,
|
||||||
|
payload_id: "phase08".into(),
|
||||||
|
kind: ProbeKind::HeaderEmit {
|
||||||
|
name: "Set-Cookie".into(),
|
||||||
|
value: "nyx-session\r\nSet-Cookie: nyx-injected=pwn".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 header_injected_predicate_clear_when_value_is_url_encoded() {
|
||||||
|
let oracle = Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let probes = vec![SinkProbe {
|
||||||
|
sink_callee: "HttpServletResponse.setHeader".into(),
|
||||||
|
args: vec![],
|
||||||
|
captured_at_ns: 1,
|
||||||
|
payload_id: "phase08".into(),
|
||||||
|
kind: ProbeKind::HeaderEmit {
|
||||||
|
name: "Set-Cookie".into(),
|
||||||
|
value: "nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn".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 header_injected_predicate_clear_on_unrelated_header() {
|
||||||
|
// Predicate pins `Set-Cookie`; a CRLF-carrying value emitted on a
|
||||||
|
// different header name must not satisfy.
|
||||||
|
let oracle = Oracle::SinkProbe {
|
||||||
|
predicates: &[ProbePredicate::HeaderInjected {
|
||||||
|
header_name: "Set-Cookie",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let probes = vec![SinkProbe {
|
||||||
|
sink_callee: "HttpServletResponse.setHeader".into(),
|
||||||
|
args: vec![],
|
||||||
|
captured_at_ns: 1,
|
||||||
|
payload_id: "phase08".into(),
|
||||||
|
kind: ProbeKind::HeaderEmit {
|
||||||
|
name: "X-Trace-Id".into(),
|
||||||
|
value: "trace\r\nX-Injected: 1".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_header_injection_harness() {
|
||||||
|
// Per-lang `sink_callee_marker` pins which response writer the
|
||||||
|
// harness names in its probe record.
|
||||||
|
for (lang, entry_file, entry_name, sink_callee_marker) in [
|
||||||
|
(
|
||||||
|
Lang::Java,
|
||||||
|
"tests/dynamic_fixtures/header_injection/java/Vuln.java",
|
||||||
|
"run",
|
||||||
|
"HttpServletResponse.setHeader",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Python,
|
||||||
|
"tests/dynamic_fixtures/header_injection/python/vuln.py",
|
||||||
|
"run",
|
||||||
|
"flask.Response.headers.__setitem__",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Php,
|
||||||
|
"tests/dynamic_fixtures/header_injection/php/vuln.php",
|
||||||
|
"run",
|
||||||
|
"header()",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Ruby,
|
||||||
|
"tests/dynamic_fixtures/header_injection/ruby/vuln.rb",
|
||||||
|
"run",
|
||||||
|
"Rack::Response#set_header",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::JavaScript,
|
||||||
|
"tests/dynamic_fixtures/header_injection/js/vuln.js",
|
||||||
|
"run",
|
||||||
|
"http.ServerResponse#setHeader",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Go,
|
||||||
|
"tests/dynamic_fixtures/header_injection/go/vuln.go",
|
||||||
|
"Run",
|
||||||
|
"http.ResponseWriter.Header.Set",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Rust,
|
||||||
|
"tests/dynamic_fixtures/header_injection/rust/vuln.rs",
|
||||||
|
"run",
|
||||||
|
"HeaderMap::insert",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
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("HeaderEmit"),
|
||||||
|
"{lang:?} header harness must carry the HeaderEmit probe kind",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
harness.source.contains(sink_callee_marker),
|
||||||
|
"{lang:?} header harness must name {sink_callee_marker:?} as the sink callee",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
harness.source.contains("__NYX_SINK_HIT__"),
|
||||||
|
"{lang:?} header harness must emit the sink-hit sentinel",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
harness.source.contains("Set-Cookie"),
|
||||||
|
"{lang:?} header harness must set the Set-Cookie header",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn framework_adapters_detect_header_sink() {
|
||||||
|
// Each lang registers its J.6 header adapter; detect_binding routes
|
||||||
|
// through the registry and stamps an EntryKind::Function binding
|
||||||
|
// when the fixture contains the canonical sink call.
|
||||||
|
for (lang, fixture, sink_callee) in [
|
||||||
|
(
|
||||||
|
Lang::Java,
|
||||||
|
"tests/dynamic_fixtures/header_injection/java/Vuln.java",
|
||||||
|
"setHeader",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Python,
|
||||||
|
"tests/dynamic_fixtures/header_injection/python/vuln.py",
|
||||||
|
"__setitem__",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Php,
|
||||||
|
"tests/dynamic_fixtures/header_injection/php/vuln.php",
|
||||||
|
"header",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Ruby,
|
||||||
|
"tests/dynamic_fixtures/header_injection/ruby/vuln.rb",
|
||||||
|
"set_header",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::JavaScript,
|
||||||
|
"tests/dynamic_fixtures/header_injection/js/vuln.js",
|
||||||
|
"setHeader",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Go,
|
||||||
|
"tests/dynamic_fixtures/header_injection/go/vuln.go",
|
||||||
|
"Set",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Lang::Rust,
|
||||||
|
"tests/dynamic_fixtures/header_injection/rust/vuln.rs",
|
||||||
|
"insert",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
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 header 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