From e0e49f65d368f797d1c94c42e1cfb34d89e6b198 Mon Sep 17 00:00:00 2001 From: pitboss Date: Mon, 18 May 2026 01:08:32 -0500 Subject: [PATCH] =?UTF-8?q?[pitboss]=20phase=2008:=20Track=20J.6=20+=20Tra?= =?UTF-8?q?ck=20L.6=20=E2=80=94=20`HEADER=5FINJECTION`=20corpus=20+=20ever?= =?UTF-8?q?y=20HTTP=20framework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dynamic/corpus.rs | 4 +- src/dynamic/corpus/header_injection/go.rs | 56 +++ src/dynamic/corpus/header_injection/java.rs | 63 +++ src/dynamic/corpus/header_injection/js.rs | 56 +++ src/dynamic/corpus/header_injection/mod.rs | 31 ++ src/dynamic/corpus/header_injection/php.rs | 58 +++ src/dynamic/corpus/header_injection/python.rs | 62 +++ src/dynamic/corpus/header_injection/ruby.rs | 57 +++ src/dynamic/corpus/header_injection/rust.rs | 57 +++ src/dynamic/corpus/registry.rs | 70 ++- src/dynamic/framework/adapters/header_go.rs | 110 +++++ src/dynamic/framework/adapters/header_java.rs | 106 +++++ src/dynamic/framework/adapters/header_js.rs | 118 +++++ src/dynamic/framework/adapters/header_php.rs | 109 +++++ .../framework/adapters/header_python.rs | 112 +++++ src/dynamic/framework/adapters/header_ruby.rs | 111 +++++ src/dynamic/framework/adapters/header_rust.rs | 112 +++++ src/dynamic/framework/adapters/mod.rs | 14 + src/dynamic/framework/mod.rs | 46 +- src/dynamic/framework/registry.rs | 12 +- src/dynamic/lang/go.rs | 70 +++ src/dynamic/lang/java.rs | 84 ++++ src/dynamic/lang/js_shared.rs | 60 +++ src/dynamic/lang/php.rs | 52 +++ src/dynamic/lang/python.rs | 78 ++++ src/dynamic/lang/ruby.rs | 54 +++ src/dynamic/lang/rust.rs | 98 ++++ src/dynamic/oracle.rs | 91 +++- src/dynamic/probe.rs | 24 +- src/dynamic/telemetry.rs | 2 +- .../header_injection/go/benign.go | 15 + .../header_injection/go/vuln.go | 13 + .../header_injection/java/Benign.java | 16 + .../header_injection/java/Vuln.java | 13 + .../header_injection/js/benign.js | 13 + .../header_injection/js/vuln.js | 13 + .../header_injection/php/benign.php | 9 + .../header_injection/php/vuln.php | 10 + .../header_injection/python/benign.py | 13 + .../header_injection/python/vuln.py | 13 + .../header_injection/ruby/benign.rb | 13 + .../header_injection/ruby/vuln.rb | 13 + .../header_injection/rust/benign.rs | 16 + .../header_injection/rust/vuln.rs | 17 + tests/header_injection_corpus.rs | 429 ++++++++++++++++++ 45 files changed, 2552 insertions(+), 41 deletions(-) create mode 100644 src/dynamic/corpus/header_injection/go.rs create mode 100644 src/dynamic/corpus/header_injection/java.rs create mode 100644 src/dynamic/corpus/header_injection/js.rs create mode 100644 src/dynamic/corpus/header_injection/mod.rs create mode 100644 src/dynamic/corpus/header_injection/php.rs create mode 100644 src/dynamic/corpus/header_injection/python.rs create mode 100644 src/dynamic/corpus/header_injection/ruby.rs create mode 100644 src/dynamic/corpus/header_injection/rust.rs create mode 100644 src/dynamic/framework/adapters/header_go.rs create mode 100644 src/dynamic/framework/adapters/header_java.rs create mode 100644 src/dynamic/framework/adapters/header_js.rs create mode 100644 src/dynamic/framework/adapters/header_php.rs create mode 100644 src/dynamic/framework/adapters/header_python.rs create mode 100644 src/dynamic/framework/adapters/header_ruby.rs create mode 100644 src/dynamic/framework/adapters/header_rust.rs create mode 100644 tests/dynamic_fixtures/header_injection/go/benign.go create mode 100644 tests/dynamic_fixtures/header_injection/go/vuln.go create mode 100644 tests/dynamic_fixtures/header_injection/java/Benign.java create mode 100644 tests/dynamic_fixtures/header_injection/java/Vuln.java create mode 100644 tests/dynamic_fixtures/header_injection/js/benign.js create mode 100644 tests/dynamic_fixtures/header_injection/js/vuln.js create mode 100644 tests/dynamic_fixtures/header_injection/php/benign.php create mode 100644 tests/dynamic_fixtures/header_injection/php/vuln.php create mode 100644 tests/dynamic_fixtures/header_injection/python/benign.py create mode 100644 tests/dynamic_fixtures/header_injection/python/vuln.py create mode 100644 tests/dynamic_fixtures/header_injection/ruby/benign.rb create mode 100644 tests/dynamic_fixtures/header_injection/ruby/vuln.rb create mode 100644 tests/dynamic_fixtures/header_injection/rust/benign.rs create mode 100644 tests/dynamic_fixtures/header_injection/rust/vuln.rs create mode 100644 tests/header_injection_corpus.rs diff --git a/src/dynamic/corpus.rs b/src/dynamic/corpus.rs index 0edd5003..06e73366 100644 --- a/src/dynamic/corpus.rs +++ b/src/dynamic/corpus.rs @@ -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)] diff --git a/src/dynamic/corpus/header_injection/go.rs b/src/dynamic/corpus/header_injection/go.rs new file mode 100644 index 00000000..550fc541 --- /dev/null +++ b/src/dynamic/corpus/header_injection/go.rs @@ -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, + }, +]; diff --git a/src/dynamic/corpus/header_injection/java.rs b/src/dynamic/corpus/header_injection/java.rs new file mode 100644 index 00000000..96de1661 --- /dev/null +++ b/src/dynamic/corpus/header_injection/java.rs @@ -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: }` +//! 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, + }, +]; diff --git a/src/dynamic/corpus/header_injection/js.rs b/src/dynamic/corpus/header_injection/js.rs new file mode 100644 index 00000000..c7c1c952 --- /dev/null +++ b/src/dynamic/corpus/header_injection/js.rs @@ -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, + }, +]; diff --git a/src/dynamic/corpus/header_injection/mod.rs b/src/dynamic/corpus/header_injection/mod.rs new file mode 100644 index 00000000..41b264c5 --- /dev/null +++ b/src/dynamic/corpus/header_injection/mod.rs @@ -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; diff --git a/src/dynamic/corpus/header_injection/php.rs b/src/dynamic/corpus/header_injection/php.rs new file mode 100644 index 00000000..1fa0777a --- /dev/null +++ b/src/dynamic/corpus/header_injection/php.rs @@ -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, + }, +]; diff --git a/src/dynamic/corpus/header_injection/python.rs b/src/dynamic/corpus/header_injection/python.rs new file mode 100644 index 00000000..0c50a2c6 --- /dev/null +++ b/src/dynamic/corpus/header_injection/python.rs @@ -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: }` +//! 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, + }, +]; diff --git a/src/dynamic/corpus/header_injection/ruby.rs b/src/dynamic/corpus/header_injection/ruby.rs new file mode 100644 index 00000000..42dac2a8 --- /dev/null +++ b/src/dynamic/corpus/header_injection/ruby.rs @@ -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, + }, +]; diff --git a/src/dynamic/corpus/header_injection/rust.rs b/src/dynamic/corpus/header_injection/rust.rs new file mode 100644 index 00000000..e7ea0cc9 --- /dev/null +++ b/src/dynamic/corpus/header_injection/rust.rs @@ -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, + }, +]; diff --git a/src/dynamic/corpus/registry.rs b/src/dynamic/corpus/registry.rs index 73d1eeeb..433799be 100644 --- a/src/dynamic/corpus/registry.rs +++ b/src/dynamic/corpus/registry.rs @@ -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 diff --git a/src/dynamic/framework/adapters/header_go.rs b/src/dynamic/framework/adapters/header_go.rs new file mode 100644 index 00000000..18754dde --- /dev/null +++ b/src/dynamic/framework/adapters/header_go.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/header_java.rs b/src/dynamic/framework/adapters/header_java.rs new file mode 100644 index 00000000..b29aba57 --- /dev/null +++ b/src/dynamic/framework/adapters/header_java.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/header_js.rs b/src/dynamic/framework/adapters/header_js.rs new file mode 100644 index 00000000..e38e1fa2 --- /dev/null +++ b/src/dynamic/framework/adapters/header_js.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/header_php.rs b/src/dynamic/framework/adapters/header_php.rs new file mode 100644 index 00000000..07b79e7d --- /dev/null +++ b/src/dynamic/framework/adapters/header_php.rs @@ -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 { + 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" 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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/header_ruby.rs b/src/dynamic/framework/adapters/header_ruby.rs new file mode 100644 index 00000000..d768edcd --- /dev/null +++ b/src/dynamic/framework/adapters/header_ruby.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/header_rust.rs b/src/dynamic/framework/adapters/header_rust.rs new file mode 100644 index 00000000..de7ad104 --- /dev/null +++ b/src/dynamic/framework/adapters/header_rust.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index 292a64ed..247042c9 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -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; diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index 354e5803..ebfdeffa 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -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", diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs index ce951e6d..7531840a 100644 --- a/src/dynamic/framework/registry.rs +++ b/src/dynamic/framework/registry.rs @@ -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, ]; diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index eb5badf8..8b0917bb 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -505,6 +505,14 @@ pub fn emit(spec: &HarnessSpec) -> Result { 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); diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 4e12e6e0..05757e11 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -567,6 +567,9 @@ pub fn emit(spec: &HarnessSpec) -> Result { 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`]. diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index ab080c07..a48bd763 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -449,6 +449,14 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result 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 diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index c48aac79..6f540175 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -432,6 +432,10 @@ pub fn emit(spec: &HarnessSpec) -> Result { 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#" '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); diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 742f347f..55aa2502 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -640,6 +640,16 @@ pub fn emit(spec: &HarnessSpec) -> Result { 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`]. diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 891f76f4..c5b38025 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -424,6 +424,9 @@ pub fn emit(spec: &HarnessSpec) -> Result { 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); diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index 73121cda..3f9f9e87 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -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 { + // 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 diff --git a/src/dynamic/oracle.rs b/src/dynamic/oracle.rs index 0036ffe0..494ec844 100644 --- a/src/dynamic/oracle.rs +++ b/src/dynamic/oracle.rs @@ -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 { - 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, } } diff --git a/src/dynamic/probe.rs b/src/dynamic/probe.rs index 5d321abc..d8fa82ae 100644 --- a/src/dynamic/probe.rs +++ b/src/dynamic/probe.rs @@ -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 { diff --git a/src/dynamic/telemetry.rs b/src/dynamic/telemetry.rs index c8b23c22..9d2942e2 100644 --- a/src/dynamic/telemetry.rs +++ b/src/dynamic/telemetry.rs @@ -60,7 +60,7 @@ pub const NYX_VERSION: &str = env!("CARGO_PKG_VERSION"); /// [`crate::dynamic::corpus::CORPUS_VERSION`]; the compile-time assertion /// below + the [`corpus_version_const_matches_corpus_module`] runtime test /// jointly guard drift. -pub const CORPUS_VERSION: &str = "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 diff --git a/tests/dynamic_fixtures/header_injection/go/benign.go b/tests/dynamic_fixtures/header_injection/go/benign.go new file mode 100644 index 00000000..8ccf25df --- /dev/null +++ b/tests/dynamic_fixtures/header_injection/go/benign.go @@ -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)) +} diff --git a/tests/dynamic_fixtures/header_injection/go/vuln.go b/tests/dynamic_fixtures/header_injection/go/vuln.go new file mode 100644 index 00000000..2329ab79 --- /dev/null +++ b/tests/dynamic_fixtures/header_injection/go/vuln.go @@ -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) +} diff --git a/tests/dynamic_fixtures/header_injection/java/Benign.java b/tests/dynamic_fixtures/header_injection/java/Benign.java new file mode 100644 index 00000000..58cc1491 --- /dev/null +++ b/tests/dynamic_fixtures/header_injection/java/Benign.java @@ -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); + } +} diff --git a/tests/dynamic_fixtures/header_injection/java/Vuln.java b/tests/dynamic_fixtures/header_injection/java/Vuln.java new file mode 100644 index 00000000..4bd9c6a3 --- /dev/null +++ b/tests/dynamic_fixtures/header_injection/java/Vuln.java @@ -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); + } +} diff --git a/tests/dynamic_fixtures/header_injection/js/benign.js b/tests/dynamic_fixtures/header_injection/js/benign.js new file mode 100644 index 00000000..54765570 --- /dev/null +++ b/tests/dynamic_fixtures/header_injection/js/benign.js @@ -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 }; diff --git a/tests/dynamic_fixtures/header_injection/js/vuln.js b/tests/dynamic_fixtures/header_injection/js/vuln.js new file mode 100644 index 00000000..b8bceaa7 --- /dev/null +++ b/tests/dynamic_fixtures/header_injection/js/vuln.js @@ -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 }; diff --git a/tests/dynamic_fixtures/header_injection/php/benign.php b/tests/dynamic_fixtures/header_injection/php/benign.php new file mode 100644 index 00000000..d636ee4d --- /dev/null +++ b/tests/dynamic_fixtures/header_injection/php/benign.php @@ -0,0 +1,9 @@ + 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", + } +}