[pitboss] phase 08: Track J.6 + Track L.6 — HEADER_INJECTION corpus + every HTTP framework

This commit is contained in:
pitboss 2026-05-18 01:08:32 -05:00
parent 59d627cb22
commit e0e49f65d3
45 changed files with 2552 additions and 41 deletions

View file

@ -50,6 +50,7 @@ pub mod registry;
mod cmdi;
mod deserialize;
mod fmt_string;
mod header_injection;
mod ldap;
mod path_trav;
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` |
/// | 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 |
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View 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,
},
];

View 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,
},
];

View 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,
},
];

View 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;

View 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,
},
];

View 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,
},
];

View 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,
},
];

View 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,
},
];

View file

@ -23,7 +23,10 @@
use std::collections::HashMap;
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 crate::dynamic::oracle::ProbePredicate;
use crate::labels::Cap;
@ -40,7 +43,6 @@ pub const CORPUS_UNSUPPORTED_LANG_NEUTRAL: u32 = Cap::ENV_VAR.bits()
| Cap::CRYPTO.bits()
| Cap::UNAUTHORIZED_ID.bits()
| Cap::DATA_EXFIL.bits()
| Cap::HEADER_INJECTION.bits()
| Cap::OPEN_REDIRECT.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::Php, xpath::php::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
@ -285,6 +294,7 @@ mod tests {
assert!(!payloads_for(Cap::XXE).is_empty());
assert!(!payloads_for(Cap::LDAP_INJECTION).is_empty());
assert!(!payloads_for(Cap::XPATH_INJECTION).is_empty());
assert!(!payloads_for(Cap::HEADER_INJECTION).is_empty());
}
#[test]
@ -297,7 +307,6 @@ mod tests {
Cap::CRYPTO,
Cap::UNAUTHORIZED_ID,
Cap::DATA_EXFIL,
Cap::HEADER_INJECTION,
Cap::OPEN_REDIRECT,
Cap::PROTOTYPE_POLLUTION,
];
@ -332,6 +341,7 @@ mod tests {
Cap::XXE,
Cap::LDAP_INJECTION,
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
] {
let has_vuln = payloads_for(cap).iter().any(|p| !p.is_benign);
assert!(has_vuln, "{cap:?} must have at least one vuln payload");
@ -383,6 +393,7 @@ mod tests {
Cap::XXE,
Cap::LDAP_INJECTION,
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
];
for cap in caps {
for p in payloads_for(cap) {
@ -409,6 +420,7 @@ mod tests {
Cap::XXE,
Cap::LDAP_INJECTION,
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
];
for cap in caps {
for p in payloads_for(cap) {
@ -522,6 +534,7 @@ mod tests {
Cap::XXE,
Cap::LDAP_INJECTION,
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
];
for cap in caps {
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]
fn deserialize_payloads_pair_benign_controls_per_lang() {
// The lang-aware resolver must find the paired benign control

View 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());
}
}

View 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());
}
}

View 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());
}
}

View 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());
}
}

View 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());
}
}

View 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());
}
}

View 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());
}
}

View file

@ -11,6 +11,13 @@
//! the route / framework adapters; the per-cap sink adapters live
//! 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_thymeleaf;
pub mod js_handlebars;
@ -33,6 +40,13 @@ pub mod xxe_php;
pub mod xxe_python;
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_thymeleaf::JavaThymeleafAdapter;
pub use js_handlebars::JsHandlebarsAdapter;

View file

@ -214,21 +214,20 @@ mod tests {
}
#[test]
fn registry_baseline_after_phase_07() {
// Phase 07 (Track J.5) adds the XPath-sink adapter for Java /
// Python / PHP / JavaScript, layered on top of the Phase 03
// deserialize + Phase 04 SSTI + Phase 05 XXE + Phase 06 LDAP
// adapters. Java / Python / PHP each grow from 4 → 5; the
// JavaScript slice grows from 1 (Handlebars only) → 2. Ruby
// still carries the 03+04+05 trio (no Ruby LDAP adapter); Go
// still has only the XXE adapter; Rust / C / Cpp / TypeScript
// still carry the Phase-01 empty baseline.
fn registry_baseline_after_phase_08() {
// Phase 08 (Track J.6) adds the header-injection adapter for
// every language carrying the HEADER_INJECTION corpus: Java /
// Python / PHP / Ruby / JavaScript / Go / Rust. Java /
// Python / PHP each grow from 5 → 6; Ruby from 3 → 4;
// JavaScript from 2 → 3; Go from 1 → 2; Rust from 0 → 1.
// C / Cpp / TypeScript still carry the Phase-01 empty
// baseline.
for lang in [Lang::Java, Lang::Python, Lang::Php] {
let registered = registry::adapters_for(lang);
assert_eq!(
registered.len(),
5,
"{:?} must have the J.1 deserialize + J.2 ssti + J.3 xxe + J.4 ldap + J.5 xpath adapters",
6,
"{:?} must have the J.1+J.2+J.3+J.4+J.5+J.6 adapters",
lang,
);
for adapter in registered {
@ -238,8 +237,8 @@ mod tests {
let ruby_registered = registry::adapters_for(Lang::Ruby);
assert_eq!(
ruby_registered.len(),
3,
"Ruby must still carry the J.1 deserialize + J.2 ssti + J.3 xxe adapters",
4,
"Ruby must have the J.1 + J.2 + J.3 + J.6 header adapters",
);
for adapter in ruby_registered {
assert_eq!(adapter.lang(), Lang::Ruby);
@ -247,8 +246,8 @@ mod tests {
let js_registered = registry::adapters_for(Lang::JavaScript);
assert_eq!(
js_registered.len(),
2,
"JavaScript must have the J.2 Handlebars + J.5 xpath-js adapters",
3,
"JavaScript must have J.2 Handlebars + J.5 xpath-js + J.6 header-js",
);
for adapter in js_registered {
assert_eq!(adapter.lang(), Lang::JavaScript);
@ -256,11 +255,20 @@ mod tests {
let go_registered = registry::adapters_for(Lang::Go);
assert_eq!(
go_registered.len(),
1,
"Go must have exactly the J.3 xxe-go adapter",
2,
"Go must have J.3 xxe-go + J.6 header-go",
);
assert_eq!(go_registered[0].lang(), Lang::Go);
for lang in [Lang::Rust, Lang::C, Lang::Cpp, Lang::TypeScript] {
for adapter in go_registered {
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!(
registry::adapters_for(lang).is_empty(),
"{:?} should still have zero adapters before its Track-L phase",

View file

@ -44,18 +44,23 @@ pub fn adapters_for(lang: Lang) -> &'static [&'static dyn FrameworkAdapter] {
// listed in alphabetical order of [`FrameworkAdapter::name`] so a
// later phase that appends a new adapter cannot silently re-order
// the existing first-match.
static RUST: &[&dyn FrameworkAdapter] = &[];
static RUST: &[&dyn FrameworkAdapter] = &[&super::adapters::HeaderRustAdapter];
static C: &[&dyn FrameworkAdapter] = &[];
static CPP: &[&dyn FrameworkAdapter] = &[];
static JAVA: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderJavaAdapter,
&super::adapters::JavaDeserializeAdapter,
&super::adapters::JavaThymeleafAdapter,
&super::adapters::LdapSpringAdapter,
&super::adapters::XpathJavaAdapter,
&super::adapters::XxeJavaAdapter,
];
static GO: &[&dyn FrameworkAdapter] = &[&super::adapters::XxeGoAdapter];
static GO: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderGoAdapter,
&super::adapters::XxeGoAdapter,
];
static PHP: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderPhpAdapter,
&super::adapters::LdapPhpAdapter,
&super::adapters::PhpTwigAdapter,
&super::adapters::PhpUnserializeAdapter,
@ -63,6 +68,7 @@ static PHP: &[&dyn FrameworkAdapter] = &[
&super::adapters::XxePhpAdapter,
];
static PYTHON: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderPythonAdapter,
&super::adapters::LdapPythonAdapter,
&super::adapters::PythonJinja2Adapter,
&super::adapters::PythonPickleAdapter,
@ -70,12 +76,14 @@ static PYTHON: &[&dyn FrameworkAdapter] = &[
&super::adapters::XxePythonAdapter,
];
static RUBY: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderRubyAdapter,
&super::adapters::RubyErbAdapter,
&super::adapters::RubyMarshalAdapter,
&super::adapters::XxeRubyAdapter,
];
static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[];
static JAVASCRIPT: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderJsAdapter,
&super::adapters::JsHandlebarsAdapter,
&super::adapters::XpathJsAdapter,
];

View file

@ -505,6 +505,14 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
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 shape = GoShape::detect(spec, &entry_source);
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 {
let entry_fn = capitalize_first(&spec.entry_name);
let pre_call = pre_call_setup(spec);

View file

@ -567,6 +567,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
if spec.expected_cap == crate::labels::Cap::XPATH_INJECTION {
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 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`,
/// reading the entry file from disk. Exposed so test helpers can pin a
/// per-fixture shape without round-tripping through [`emit`].

View file

@ -449,6 +449,14 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result<HarnessSource, Un
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 shape = JsShape::detect(spec, &entry_source);
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).
///
/// Splices the Node probe shim ([`probe_shim`]) in front of a minimal

View file

@ -432,6 +432,10 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
if spec.expected_cap == crate::labels::Cap::XPATH_INJECTION {
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 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 {
let entry_fn = &spec.entry_name;
let pre_call = build_pre_call(spec, shape);

View file

@ -640,6 +640,16 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
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 shape = PythonShape::detect(spec, &entry_source);
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`,
/// reading the entry file from disk. Exposed so test helpers can pin a
/// per-fixture shape without round-tripping through [`emit`].

View file

@ -424,6 +424,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
if spec.expected_cap == crate::labels::Cap::XXE {
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 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 {
let entry_fn = &spec.entry_name;
let pre_call = build_pre_call(spec);

View file

@ -557,6 +557,96 @@ pub fn detect_shape(spec: &HarnessSpec) -> RustShape {
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 {
let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)];
for path in &candidates {
@ -569,6 +659,14 @@ fn read_entry_source(entry_file: &str) -> String {
/// Emit a Rust harness for `spec`.
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);
// Generic + LibfuzzerTarget accept Param(0)/EnvVar; richer shapes

View file

@ -239,6 +239,32 @@ pub enum ProbePredicate {
/// the parser-refusal benign control still confirm.
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
/// predicate shared by LDAP-filter and XPath-expression injection.
///
@ -404,6 +430,20 @@ pub fn oracle_fired_with_stubs(
if !query_count_cross_ok {
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
// predicates. Each `TemplateEvalEqual { expected }` consults
// 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))),
}
}
Oracle::SinkCrash { signals } => probes.iter().any(|p| match p.kind {
ProbeKind::Crash { signal } => signals.contains(signal),
Oracle::SinkCrash { signals } => probes.iter().any(|p| match &p.kind {
ProbeKind::Crash { signal } => signals.contains(*signal),
ProbeKind::Normal
| ProbeKind::Deserialize { .. }
| ProbeKind::Xxe { .. }
| ProbeKind::Ldap { .. }
| ProbeKind::Xpath { .. } => false,
| ProbeKind::Xpath { .. }
| ProbeKind::HeaderEmit { .. } => false,
}),
Oracle::OutputContains(needle) => {
let nb = needle.as_bytes();
@ -462,6 +503,7 @@ fn is_cross_cutting(pred: &ProbePredicate) -> bool {
| ProbePredicate::TemplateEvalEqual { .. }
| ProbePredicate::XxeEntityExpanded { .. }
| 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
// in [`probes_satisfy_count_gt`] below.
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,
}
}
@ -533,9 +579,9 @@ fn stdout_template_equals(stdout: &[u8], expected: u64) -> bool {
/// True when at least one drained probe is a
/// [`ProbeKind::Deserialize`] record matching `require_invoked`.
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 } => {
gadget_chain_invoked == require_invoked
*gadget_chain_invoked == require_invoked
}
_ => 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`]
/// record matching `require_expanded`.
fn probes_satisfy_xxe(probes: &[SinkProbe], require_expanded: bool) -> bool {
probes.iter().any(|p| match p.kind {
ProbeKind::Xxe { entity_expanded } => entity_expanded == require_expanded,
probes.iter().any(|p| match &p.kind {
ProbeKind::Xxe { entity_expanded } => *entity_expanded == require_expanded,
_ => false,
})
}
@ -555,9 +601,24 @@ fn probes_satisfy_xxe(probes: &[SinkProbe], require_expanded: bool) -> bool {
/// (`entries_returned > n`) and [`ProbeKind::Xpath`]
/// (`nodes_returned > n`).
fn probes_satisfy_count_gt(probes: &[SinkProbe], n: u32) -> bool {
probes.iter().any(|p| match p.kind {
ProbeKind::Ldap { entries_returned } => entries_returned > n,
ProbeKind::Xpath { nodes_returned } => nodes_returned > n,
probes.iter().any(|p| match &p.kind {
ProbeKind::Ldap { entries_returned } => *entries_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,
})
}
@ -595,7 +656,8 @@ fn probe_satisfies_one(probe: &SinkProbe, pred: &ProbePredicate) -> bool {
| ProbePredicate::DeserializeGadgetInvoked { .. }
| ProbePredicate::TemplateEvalEqual { .. }
| 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
/// probe matched" (→ `Confirmed` via `Oracle::SinkCrash`).
pub fn probe_crash_signal(probe: &SinkProbe) -> Option<Signal> {
match probe.kind {
ProbeKind::Crash { signal } => Some(signal),
match &probe.kind {
ProbeKind::Crash { signal } => Some(*signal),
ProbeKind::Normal
| ProbeKind::Deserialize { .. }
| ProbeKind::Xxe { .. }
| ProbeKind::Ldap { .. }
| ProbeKind::Xpath { .. } => None,
| ProbeKind::Xpath { .. }
| ProbeKind::HeaderEmit { .. } => None,
}
}

View file

@ -111,7 +111,7 @@ impl ProbeArg {
/// [`crate::dynamic::oracle::Oracle::SinkCrash`] variant ignores anything
/// other than `Crash { signal }`, so a process-level abort outside the
/// sink no longer satisfies the oracle.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind")]
pub enum ProbeKind {
/// Standard sink observation: arguments were captured before the sink
@ -190,6 +190,28 @@ pub enum ProbeKind {
/// payload's XPath expression.
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 {

View file

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