mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 08: Track J.6 + Track L.6 — HEADER_INJECTION corpus + every HTTP framework
This commit is contained in:
parent
59d627cb22
commit
e0e49f65d3
45 changed files with 2552 additions and 41 deletions
|
|
@ -50,6 +50,7 @@ pub mod registry;
|
|||
mod cmdi;
|
||||
mod 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)]
|
||||
|
|
|
|||
56
src/dynamic/corpus/header_injection/go.rs
Normal file
56
src/dynamic/corpus/header_injection/go.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
//! Go `Cap::HEADER_INJECTION` payloads —
|
||||
//! `http.ResponseWriter.Header().Set` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Spliced into the host's `w.Header().Set("Set-
|
||||
//! Cookie", value)` call without CRLF stripping.
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with
|
||||
//! `net/url.QueryEscape`. Captured value carries `%0D%0A` so the
|
||||
//! predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-go-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/go/vuln.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-go-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-go-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/go/benign.go"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
63
src/dynamic/corpus/header_injection/java.rs
Normal file
63
src/dynamic/corpus/header_injection/java.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
//! Java `Cap::HEADER_INJECTION` payloads —
|
||||
//! `HttpServletResponse.setHeader` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Concatenated into the host's
|
||||
//! `response.setHeader("Set-Cookie", value)` call without CRLF
|
||||
//! stripping, the wire response carries the attacker's second
|
||||
//! header. The harness's instrumented `setHeader` records a
|
||||
//! `ProbeKind::HeaderEmit { name: "Set-Cookie", value: <raw bytes> }`
|
||||
//! probe with the unescaped CRLF intact.
|
||||
//!
|
||||
//! Benign control: same logical session-id, but the harness's
|
||||
//! benign code path runs the value through `URLEncoder.encode(...,
|
||||
//! "UTF-8")` so the carried bytes become
|
||||
//! `nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn`. The
|
||||
//! captured value has no literal `\r\n`; the
|
||||
//! [`ProbePredicate::HeaderInjected`] predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-java-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/java/Vuln.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-java-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-java-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/java/Benign.java"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
56
src/dynamic/corpus/header_injection/js.rs
Normal file
56
src/dynamic/corpus/header_injection/js.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
//! JavaScript `Cap::HEADER_INJECTION` payloads —
|
||||
//! `http.ServerResponse#setHeader` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Spliced into the host's
|
||||
//! `res.setHeader('Set-Cookie', value)` call without CRLF stripping.
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with
|
||||
//! `encodeURIComponent`. Captured value carries `%0D%0A` so the
|
||||
//! predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-js-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/js/vuln.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-js-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-js-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/js/benign.js"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
31
src/dynamic/corpus/header_injection/mod.rs
Normal file
31
src/dynamic/corpus/header_injection/mod.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
//! HTTP response-header CRLF injection (`Cap::HEADER_INJECTION`)
|
||||
//! per-language payload slices.
|
||||
//!
|
||||
//! Phase 08 (Track J.6) carves header injection across the seven HTTP
|
||||
//! framework ecosystems Nyx supports: Java (`HttpServletResponse.
|
||||
//! setHeader`), Python (`flask.Response.headers.__setitem__`), PHP
|
||||
//! (`header()`), Ruby (`Rack::Response#set_header`), JavaScript
|
||||
//! (`http.ServerResponse#setHeader`), Go (`http.ResponseWriter.
|
||||
//! Header().Set`), Rust (`axum`-style `HeaderMap::insert`). Every
|
||||
//! vuln payload appends a `\r\n` followed by an injected header line
|
||||
//! (`Set-Cookie: nyx-injected=pwn`) — once the host code splices the
|
||||
//! attacker bytes into the response writer's value argument the wire
|
||||
//! actually carries two headers instead of one. The paired benign
|
||||
//! control passes the same logical value through the per-language URL
|
||||
//! encoder so the captured value carries `%0d%0a` (not the raw
|
||||
//! bytes), the encoded text is preserved verbatim inside a single
|
||||
//! header value, and the differential rule stays clear.
|
||||
//!
|
||||
//! The oracle's
|
||||
//! [`crate::dynamic::oracle::ProbePredicate::HeaderInjected`] reads
|
||||
//! the per-payload `ProbeKind::HeaderEmit { name, value }` records
|
||||
//! and fires when the value contains a literal CRLF byte pair —
|
||||
//! vuln passes, benign clears, fulfilling the §4.1 differential rule.
|
||||
|
||||
pub mod go;
|
||||
pub mod java;
|
||||
pub mod js;
|
||||
pub mod php;
|
||||
pub mod python;
|
||||
pub mod ruby;
|
||||
pub mod rust;
|
||||
58
src/dynamic/corpus/header_injection/php.rs
Normal file
58
src/dynamic/corpus/header_injection/php.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! PHP `Cap::HEADER_INJECTION` payloads — `header()` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Concatenated into the host's `header("Set-
|
||||
//! Cookie: " . $value)` call without CRLF stripping, the wire response
|
||||
//! carries the attacker's second header. The harness's instrumented
|
||||
//! `header()` records a `ProbeKind::HeaderEmit` probe with the
|
||||
//! unescaped CRLF intact.
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with PHP's
|
||||
//! `urlencode`. Captured value carries `%0D%0A` so the predicate
|
||||
//! stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-php-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/php/vuln.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-php-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-php-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/php/benign.php"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
62
src/dynamic/corpus/header_injection/python.rs
Normal file
62
src/dynamic/corpus/header_injection/python.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//! Python `Cap::HEADER_INJECTION` payloads —
|
||||
//! `flask.Response.headers.__setitem__` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a session cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Spliced into the host's
|
||||
//! `response.headers["Set-Cookie"] = value` assignment without CRLF
|
||||
//! stripping, the WSGI layer carries the attacker's second header on
|
||||
//! the wire. The harness's instrumented response writer records a
|
||||
//! `ProbeKind::HeaderEmit { name: "Set-Cookie", value: <raw bytes> }`
|
||||
//! probe with the unescaped CRLF intact.
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with
|
||||
//! `urllib.parse.quote`. The carried bytes become
|
||||
//! `nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn` — no literal
|
||||
//! CRLF — and the [`ProbePredicate::HeaderInjected`] predicate stays
|
||||
//! clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-python-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/python/vuln.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-python-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-python-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/python/benign.py"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
57
src/dynamic/corpus/header_injection/ruby.rs
Normal file
57
src/dynamic/corpus/header_injection/ruby.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//! Ruby `Cap::HEADER_INJECTION` payloads —
|
||||
//! `Rack::Response#set_header` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Spliced into the host's
|
||||
//! `response.set_header("Set-Cookie", value)` call without CRLF
|
||||
//! stripping, the wire response carries the attacker's second header.
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with
|
||||
//! `URI.encode_www_form_component`. Captured value carries `%0D%0A`
|
||||
//! so the predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-ruby-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/ruby/vuln.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-ruby-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-ruby-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/ruby/benign.rb"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
57
src/dynamic/corpus/header_injection/rust.rs
Normal file
57
src/dynamic/corpus/header_injection/rust.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//! Rust `Cap::HEADER_INJECTION` payloads — `axum`-style
|
||||
//! `HeaderMap::insert` CRLF injection.
|
||||
//!
|
||||
//! Vuln payload: a cookie value followed by `\r\nSet-Cookie:
|
||||
//! nyx-injected=pwn`. Spliced into a hand-rolled `HeaderMap` insert
|
||||
//! that bypasses the `HeaderValue::from_str` validity check (e.g.
|
||||
//! `HeaderValue::from_bytes(...).unwrap()` over a tainted slice).
|
||||
//!
|
||||
//! Benign control: same logical cookie value pre-encoded with the
|
||||
//! `percent-encoding` crate. Captured value carries `%0D%0A` so the
|
||||
//! predicate stays clear.
|
||||
|
||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||
use crate::dynamic::oracle::ProbePredicate;
|
||||
|
||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn",
|
||||
label: "header-injection-rust-crlf",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: false,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/rust/vuln.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
benign_control: Some(PayloadRef {
|
||||
label: "header-injection-rust-benign",
|
||||
}),
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
CuratedPayload {
|
||||
bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn",
|
||||
label: "header-injection-rust-benign",
|
||||
oracle: Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
},
|
||||
is_benign: true,
|
||||
provenance: PayloadProvenance::Curated,
|
||||
since_corpus_version: 12,
|
||||
deprecated_at_corpus_version: None,
|
||||
fixture_paths: &["tests/dynamic_fixtures/header_injection/rust/benign.rs"],
|
||||
oob_nonce_slot: false,
|
||||
probe_predicates: &[],
|
||||
benign_control: None,
|
||||
no_benign_control_rationale: None,
|
||||
},
|
||||
];
|
||||
|
|
@ -23,7 +23,10 @@
|
|||
use std::collections::HashMap;
|
||||
use std::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
|
||||
|
|
|
|||
110
src/dynamic/framework/adapters/header_go.rs
Normal file
110
src/dynamic/framework/adapters/header_go.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//! Go [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions
|
||||
//! (`http.ResponseWriter.Header().Set` / `Add`, Gin `c.Header`,
|
||||
//! Echo `c.Response().Header().Set`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical Go HTTP response writers and the surrounding
|
||||
//! source imports `net/http` or one of the supported frameworks.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderGoAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-go";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "Set" | "Add" | "Header" | "WriteHeader")
|
||||
}
|
||||
|
||||
fn source_imports_go_http(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"\"net/http\"",
|
||||
b"net/http\"",
|
||||
b"github.com/gin-gonic/gin",
|
||||
b"github.com/labstack/echo",
|
||||
b"github.com/gofiber/fiber",
|
||||
b"github.com/go-chi/chi",
|
||||
b".Header().Set",
|
||||
b".Header().Add",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderGoAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Go
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_imports_go_http(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_go(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_header_set() {
|
||||
let src: &[u8] =
|
||||
b"package x\nimport \"net/http\"\nfunc Run(w http.ResponseWriter, v string) { w.Header().Set(\"Set-Cookie\", v) }\n";
|
||||
let tree = parse_go(src);
|
||||
let summary = FuncSummary {
|
||||
name: "Run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("Set")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderGoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"package x\nfunc Add(a, b int) int { return a + b }\n";
|
||||
let tree = parse_go(src);
|
||||
let summary = FuncSummary {
|
||||
name: "Add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderGoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
106
src/dynamic/framework/adapters/header_java.rs
Normal file
106
src/dynamic/framework/adapters/header_java.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! Java [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions
|
||||
//! (`HttpServletResponse.setHeader` / `addHeader`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical servlet response-writer entry points and the
|
||||
//! surrounding source imports a servlet API.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderJavaAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-java";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "setHeader" | "addHeader" | "setDateHeader" | "addDateHeader" | "setIntHeader" | "addIntHeader")
|
||||
}
|
||||
|
||||
fn source_imports_servlet(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"javax.servlet",
|
||||
b"jakarta.servlet",
|
||||
b"HttpServletResponse",
|
||||
b"ServerHttpResponse",
|
||||
b"org.springframework.http",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderJavaAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Java
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_imports_servlet(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_java(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_setheader() {
|
||||
let src: &[u8] = b"import javax.servlet.http.HttpServletResponse;\n\
|
||||
class C { void run(HttpServletResponse r, String v) { r.setHeader(\"Set-Cookie\", v); } }\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("setHeader")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderJavaAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"class C { int add(int a, int b) { return a + b; } }\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderJavaAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
118
src/dynamic/framework/adapters/header_js.rs
Normal file
118
src/dynamic/framework/adapters/header_js.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
//! JavaScript [`super::super::FrameworkAdapter`] matching HTTP
|
||||
//! response-header CRLF-injection sink constructions
|
||||
//! (`http.ServerResponse#setHeader`, Express `res.setHeader` /
|
||||
//! `res.header`, Koa `ctx.set`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical Node response writers and the surrounding source
|
||||
//! imports the matching framework module or `node:http`.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderJsAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-js";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "setHeader" | "header" | "set" | "writeHead" | "append")
|
||||
}
|
||||
|
||||
fn source_uses_node_http(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"require('http')",
|
||||
b"require(\"http\")",
|
||||
b"require('node:http')",
|
||||
b"from 'http'",
|
||||
b"from \"http\"",
|
||||
b"require('express')",
|
||||
b"require(\"express\")",
|
||||
b"from 'express'",
|
||||
b"from \"express\"",
|
||||
b"require('koa')",
|
||||
b"require(\"koa\")",
|
||||
b"require('fastify')",
|
||||
b"require(\"fastify\")",
|
||||
b"res.setHeader",
|
||||
b"res.header",
|
||||
b"ctx.set(",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderJsAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::JavaScript
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_uses_node_http(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_setheader() {
|
||||
let src: &[u8] = b"const http = require('http');\n\
|
||||
function run(res, value) { res.setHeader('Set-Cookie', value); }\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("setHeader")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderJsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"function add(a, b) { return a + b; }\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderJsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
109
src/dynamic/framework/adapters/header_php.rs
Normal file
109
src/dynamic/framework/adapters/header_php.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! PHP [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions (`header()`,
|
||||
//! Symfony / Laravel `Response::headers->set`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical PHP response writers and the surrounding source
|
||||
//! either references the built-in `$_SERVER` request surface or
|
||||
//! imports a Symfony / Laravel response helper.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderPhpAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-php";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
|
||||
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
|
||||
let last = last.rsplit_once("->").map(|(_, s)| s).unwrap_or(last);
|
||||
matches!(last, "header" | "setRawHeader" | "headers" | "set" | "add")
|
||||
}
|
||||
|
||||
fn source_uses_php_response(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"header(",
|
||||
b"$_SERVER",
|
||||
b"Symfony\\Component\\HttpFoundation",
|
||||
b"Illuminate\\Http\\Response",
|
||||
b"->headers->",
|
||||
b"response()->header",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderPhpAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Php
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_uses_php_response(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_php(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_header_call() {
|
||||
let src: &[u8] = b"<?php\nfunction run($v) { header('Set-Cookie: ' . $v); }\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("header")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"<?php\nfunction add($a, $b) { return $a + $b; }\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderPhpAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
112
src/dynamic/framework/adapters/header_python.rs
Normal file
112
src/dynamic/framework/adapters/header_python.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//! Python [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions
|
||||
//! (`flask.Response.headers.__setitem__`, Django `HttpResponse.__setitem__`,
|
||||
//! Starlette `headers.append`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical Python web framework response writers and the
|
||||
//! surrounding source imports the matching framework module.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderPythonAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-python";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"__setitem__" | "set_header" | "setdefault" | "add_header" | "append"
|
||||
) || matches!(name, "Response.headers.__setitem__" | "make_response" | "Response.headers.add")
|
||||
}
|
||||
|
||||
fn source_imports_python_web(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"from flask",
|
||||
b"import flask",
|
||||
b"from django.http",
|
||||
b"from starlette",
|
||||
b"from fastapi",
|
||||
b"response.headers",
|
||||
b"resp.headers",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderPythonAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Python
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_imports_python_web(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_flask_header_assignment() {
|
||||
let src: &[u8] = b"from flask import make_response\n\
|
||||
def run(value):\n resp = make_response('hi')\n resp.headers['Set-Cookie'] = value\n return resp\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("__setitem__")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderPythonAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"def add(a, b):\n return a + b\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderPythonAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
111
src/dynamic/framework/adapters/header_ruby.rs
Normal file
111
src/dynamic/framework/adapters/header_ruby.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! Ruby [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions
|
||||
//! (`Rack::Response#set_header`, Rails `response.headers[]=`,
|
||||
//! Sinatra `response['Set-Cookie']=`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical Ruby web framework response writers and the
|
||||
//! surrounding source imports / mentions Rack / Rails / Sinatra.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderRubyAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-ruby";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
let last = last.rsplit_once('#').map(|(_, s)| s).unwrap_or(last);
|
||||
matches!(last, "set_header" | "[]=" | "store" | "add_header")
|
||||
}
|
||||
|
||||
fn source_uses_ruby_web(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"Rack::Response",
|
||||
b"require 'rack'",
|
||||
b"require \"rack\"",
|
||||
b"require 'sinatra'",
|
||||
b"require \"sinatra\"",
|
||||
b"ActionController",
|
||||
b"response.headers",
|
||||
b"response[",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderRubyAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Ruby
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_uses_ruby_web(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_ruby(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_set_header() {
|
||||
let src: &[u8] = b"require 'rack'\n\
|
||||
def run(value)\n response = Rack::Response.new\n response.set_header('Set-Cookie', value)\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("set_header")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"def add(a, b)\n a + b\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderRubyAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
112
src/dynamic/framework/adapters/header_rust.rs
Normal file
112
src/dynamic/framework/adapters/header_rust.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//! Rust [`super::super::FrameworkAdapter`] matching HTTP response-
|
||||
//! header CRLF-injection sink constructions
|
||||
//! (`axum`-style `headers_mut().insert`, `actix-web` `HttpResponse::
|
||||
//! insert_header`, `hyper` `Response::headers_mut().insert`).
|
||||
//!
|
||||
//! Phase 08 (Track J.6). Fires when the function body invokes one
|
||||
//! of the canonical Rust HTTP response header writers and the
|
||||
//! surrounding source imports `http`, `axum`, `actix_web`, or
|
||||
//! `hyper`.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct HeaderRustAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "header-rust";
|
||||
|
||||
fn callee_is_header_setter(name: &str) -> bool {
|
||||
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
|
||||
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
|
||||
matches!(last, "insert" | "append" | "insert_header" | "header")
|
||||
}
|
||||
|
||||
fn source_imports_rust_http(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"use http::HeaderMap",
|
||||
b"use http::header",
|
||||
b"use axum::",
|
||||
b"use actix_web",
|
||||
b"use hyper::",
|
||||
b"HeaderMap::new",
|
||||
b"HeaderValue::from",
|
||||
b"headers_mut()",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for HeaderRustAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Rust
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_header_setter);
|
||||
let matches_source = source_imports_rust_http(file_bytes);
|
||||
if matches_call && matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Function,
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_rust(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_headers_insert() {
|
||||
let src: &[u8] = b"use axum::http::HeaderMap;\n\
|
||||
fn run(headers: &mut HeaderMap, value: &str) { headers.insert(\"set-cookie\", value.parse().unwrap()); }\n";
|
||||
let tree = parse_rust(src);
|
||||
let summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
callees: vec![crate::summary::CalleeSite::bare("insert")],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderRustAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_plain_function() {
|
||||
let src: &[u8] = b"fn add(a: i32, b: i32) -> i32 { a + b }\n";
|
||||
let tree = parse_rust(src);
|
||||
let summary = FuncSummary {
|
||||
name: "add".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(HeaderRustAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,13 @@
|
|||
//! the route / framework adapters; the per-cap sink adapters live
|
||||
//! 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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -505,6 +505,14 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
return Ok(emit_xxe_harness(spec));
|
||||
}
|
||||
|
||||
// Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit. The
|
||||
// Go harness models `w.Header().Set("Set-Cookie", value)` and
|
||||
// records the unmodified value via a `ProbeKind::HeaderEmit`
|
||||
// probe.
|
||||
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = GoShape::detect(spec, &entry_source);
|
||||
let main_go = generate_main_go(spec, shape);
|
||||
|
|
@ -610,6 +618,68 @@ func main() {{
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 08 — Track J.6 header-injection harness for Go
|
||||
/// (`http.ResponseWriter.Header().Set`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented `Header.Set`
|
||||
/// shim that records the *unmodified* value bytes (including any
|
||||
/// embedded `\r\n`) via a `ProbeKind::HeaderEmit` probe. Mirrors
|
||||
/// the synthetic-harness pattern used by Phase 05.
|
||||
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let go_mod = generate_go_mod();
|
||||
let source = format!(
|
||||
r##"// Nyx dynamic harness — HEADER_INJECTION http.ResponseWriter.Header().Set (Phase 08 / Track J.6).
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
{shim}
|
||||
|
||||
func nyxHeaderProbe(name, value string) {{
|
||||
__nyx_emit(map[string]interface{{}}{{
|
||||
"sink_callee": "http.ResponseWriter.Header.Set",
|
||||
"args": []map[string]interface{{}}{{
|
||||
{{"kind": "String", "value": name}},
|
||||
{{"kind": "String", "value": value}},
|
||||
}},
|
||||
"captured_at_ns": uint64(time.Now().UnixNano()),
|
||||
"payload_id": os.Getenv("NYX_PAYLOAD_ID"),
|
||||
"kind": map[string]interface{{}}{{"kind": "HeaderEmit", "name": name, "value": value}},
|
||||
"witness": __nyx_witness("http.ResponseWriter.Header.Set", []string{{name, value}}),
|
||||
}})
|
||||
}}
|
||||
|
||||
func main() {{
|
||||
__nyx_install_crash_guard("http.ResponseWriter.Header.Set")
|
||||
defer __nyx_recover_crash("http.ResponseWriter.Header.Set")()
|
||||
payload := os.Getenv("NYX_PAYLOAD")
|
||||
name := "Set-Cookie"
|
||||
value := payload
|
||||
nyxHeaderProbe(name, value)
|
||||
fmt.Println("__NYX_SINK_HIT__")
|
||||
body, _ := json.Marshal(map[string]interface{{}}{{"name": name, "value": value}})
|
||||
fmt.Println(string(body))
|
||||
}}
|
||||
"##
|
||||
);
|
||||
HarnessSource {
|
||||
source,
|
||||
filename: "main.go".to_owned(),
|
||||
command: vec!["./nyx_harness".to_owned()],
|
||||
extra_files: vec![("go.mod".to_owned(), go_mod)],
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_main_go(spec: &HarnessSpec, shape: GoShape) -> String {
|
||||
let entry_fn = capitalize_first(&spec.entry_name);
|
||||
let pre_call = pre_call_setup(spec);
|
||||
|
|
|
|||
|
|
@ -567,6 +567,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
if spec.expected_cap == crate::labels::Cap::XPATH_INJECTION {
|
||||
return Ok(emit_xpath_harness(spec));
|
||||
}
|
||||
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = JavaShape::detect(spec, &entry_source);
|
||||
|
|
@ -1209,6 +1212,87 @@ public class NyxHarness {{
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 08 — Track J.6 header-injection harness for Java
|
||||
/// (`HttpServletResponse.setHeader`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||
/// `response.setHeader("Set-Cookie", value)` shim that records the
|
||||
/// *unmodified* value bytes (including any embedded `\r\n`) via a
|
||||
/// `ProbeKind::HeaderEmit` probe. Mirrors the synthetic-harness
|
||||
/// pattern used by Phase 03 / 04 / 05 / 06 / 07.
|
||||
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let source = format!(
|
||||
r#"// Nyx dynamic harness — HEADER_INJECTION HttpServletResponse.setHeader (Phase 08 / Track J.6).
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
|
||||
public class NyxHarness {{
|
||||
{shim}
|
||||
|
||||
static void nyxHeaderProbe(String name, String value) {{
|
||||
String p = System.getenv("NYX_PROBE_PATH");
|
||||
if (p == null || p.isEmpty()) return;
|
||||
long now = System.nanoTime();
|
||||
String pid = System.getenv("NYX_PAYLOAD_ID");
|
||||
if (pid == null) pid = "";
|
||||
StringBuilder line = new StringBuilder(256);
|
||||
line.append("{{\"sink_callee\":\"HttpServletResponse.setHeader\",\"args\":[");
|
||||
line.append("{{\"kind\":\"String\",\"value\":\"");
|
||||
nyxJsonEscape(name, line);
|
||||
line.append("\"}},{{\"kind\":\"String\",\"value\":\"");
|
||||
nyxJsonEscape(value, line);
|
||||
line.append("\"}}],");
|
||||
line.append("\"captured_at_ns\":").append(now).append(',');
|
||||
line.append("\"payload_id\":\"");
|
||||
nyxJsonEscape(pid, line);
|
||||
line.append("\",\"kind\":{{\"kind\":\"HeaderEmit\",\"name\":\"");
|
||||
nyxJsonEscape(name, line);
|
||||
line.append("\",\"value\":\"");
|
||||
nyxJsonEscape(value, line);
|
||||
line.append("\"}},");
|
||||
line.append("\"witness\":");
|
||||
line.append(nyxWitnessJson("HttpServletResponse.setHeader", new String[]{{name, value}}));
|
||||
line.append("}}\n");
|
||||
try (FileWriter fw = new FileWriter(p, true)) {{
|
||||
fw.write(line.toString());
|
||||
}} catch (IOException e) {{
|
||||
// best-effort
|
||||
}}
|
||||
}}
|
||||
|
||||
public static void main(String[] args) {{
|
||||
String payload = System.getenv("NYX_PAYLOAD");
|
||||
if (payload == null) payload = "";
|
||||
String name = "Set-Cookie";
|
||||
String value = payload;
|
||||
nyxHeaderProbe(name, value);
|
||||
System.out.println("__NYX_SINK_HIT__");
|
||||
StringBuilder body = new StringBuilder(64);
|
||||
body.append("{{\"name\":\"");
|
||||
nyxJsonEscape(name, body);
|
||||
body.append("\",\"value\":\"");
|
||||
nyxJsonEscape(value, body);
|
||||
body.append("\"}}");
|
||||
System.out.println(body.toString());
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source,
|
||||
filename: "NyxHarness.java".to_owned(),
|
||||
command: vec![
|
||||
"java".to_owned(),
|
||||
"-cp".to_owned(),
|
||||
".".to_owned(),
|
||||
"NyxHarness".to_owned(),
|
||||
],
|
||||
extra_files: Vec::new(),
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
|
||||
/// reading the entry file from disk. Exposed so test helpers can pin a
|
||||
/// per-fixture shape without round-tripping through [`emit`].
|
||||
|
|
|
|||
|
|
@ -449,6 +449,14 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result<HarnessSource, Un
|
|||
return Ok(emit_xpath_harness(spec));
|
||||
}
|
||||
|
||||
// Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit. The
|
||||
// synthetic harness calls an instrumented `res.setHeader` shim
|
||||
// that records the unmodified value bytes via a
|
||||
// `ProbeKind::HeaderEmit` probe.
|
||||
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = JsShape::detect(spec, &entry_source);
|
||||
let entry_subpath = entry_subpath_for_shape(shape, is_typescript);
|
||||
|
|
@ -610,6 +618,58 @@ console.log(JSON.stringify({{ expr: expr, nodes_returned: nodes }}));
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 08 — Track J.6 header-injection harness for Node
|
||||
/// (`http.ServerResponse#setHeader`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||
/// `res.setHeader('Set-Cookie', value)` shim that records the
|
||||
/// *unmodified* value bytes (including any embedded `\r\n`) via a
|
||||
/// `ProbeKind::HeaderEmit` probe. Mirrors the synthetic-harness
|
||||
/// pattern used by Phase 03 / 04 / 05 / 06 / 07.
|
||||
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let body = format!(
|
||||
r#"// Nyx dynamic harness — HEADER_INJECTION http.ServerResponse#setHeader (Phase 08 / Track J.6).
|
||||
{shim}
|
||||
|
||||
function nyxHeaderProbe(name, value) {{
|
||||
const p = process.env.NYX_PROBE_PATH;
|
||||
if (!p) return;
|
||||
const rec = {{
|
||||
sink_callee: 'http.ServerResponse#setHeader',
|
||||
args: [
|
||||
{{ kind: 'String', value: name }},
|
||||
{{ kind: 'String', value: value }},
|
||||
],
|
||||
captured_at_ns: Number(process.hrtime.bigint()),
|
||||
payload_id: process.env.NYX_PAYLOAD_ID || '',
|
||||
kind: {{ kind: 'HeaderEmit', name: name, value: value }},
|
||||
witness: __nyx_witness('http.ServerResponse#setHeader', [name, value]),
|
||||
}};
|
||||
try {{
|
||||
require('fs').appendFileSync(p, JSON.stringify(rec) + '\n');
|
||||
}} catch (e) {{
|
||||
// best-effort
|
||||
}}
|
||||
}}
|
||||
|
||||
const payload = process.env.NYX_PAYLOAD || '';
|
||||
const name = 'Set-Cookie';
|
||||
const value = payload;
|
||||
nyxHeaderProbe(name, value);
|
||||
console.log('__NYX_SINK_HIT__');
|
||||
console.log(JSON.stringify({{ name: name, value: value }}));
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.js".to_owned(),
|
||||
command: vec!["node".to_owned(), "harness.js".to_owned()],
|
||||
extra_files: Vec::new(),
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 26 — Node chain-step harness (shared between JS + TS emitters).
|
||||
///
|
||||
/// Splices the Node probe shim ([`probe_shim`]) in front of a minimal
|
||||
|
|
|
|||
|
|
@ -432,6 +432,10 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
if spec.expected_cap == crate::labels::Cap::XPATH_INJECTION {
|
||||
return Ok(emit_xpath_harness(spec));
|
||||
}
|
||||
// Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit.
|
||||
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = PhpShape::detect(spec, &entry_source);
|
||||
|
|
@ -869,6 +873,54 @@ echo json_encode(['expr' => $expr, 'nodes_returned' => $nodes]) . "\n";
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 08 — Track J.6 header-injection harness for PHP (`header()`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented `header()`
|
||||
/// shim that records the *unmodified* value bytes (including any
|
||||
/// embedded `\r\n`) via a `ProbeKind::HeaderEmit` probe. Mirrors
|
||||
/// the synthetic-harness pattern used by Phase 03 / 04 / 05 / 06 /
|
||||
/// 07.
|
||||
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let body = format!(
|
||||
r#"<?php
|
||||
// Nyx dynamic harness — HEADER_INJECTION header() (Phase 08 / Track J.6).
|
||||
{shim}
|
||||
|
||||
function _nyx_header_probe(string $name, string $value): void {{
|
||||
$p = getenv('NYX_PROBE_PATH');
|
||||
if ($p === false || $p === '') return;
|
||||
$rec = [
|
||||
'sink_callee' => 'header()',
|
||||
'args' => [
|
||||
['kind' => 'String', 'value' => $name],
|
||||
['kind' => 'String', 'value' => $value],
|
||||
],
|
||||
'captured_at_ns' => (int) hrtime(true),
|
||||
'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''),
|
||||
'kind' => ['kind' => 'HeaderEmit', 'name' => $name, 'value' => $value],
|
||||
'witness' => __nyx_witness('header()', [$name, $value]),
|
||||
];
|
||||
@file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND);
|
||||
}}
|
||||
|
||||
$payload = (string) (getenv('NYX_PAYLOAD') ?: '');
|
||||
$name = 'Set-Cookie';
|
||||
$value = $payload;
|
||||
_nyx_header_probe($name, $value);
|
||||
echo "__NYX_SINK_HIT__\n";
|
||||
echo json_encode(['name' => $name, 'value' => $value]) . "\n";
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.php".to_owned(),
|
||||
command: vec!["php".to_owned(), "harness.php".to_owned()],
|
||||
extra_files: Vec::new(),
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String {
|
||||
let entry_fn = &spec.entry_name;
|
||||
let pre_call = build_pre_call(spec, shape);
|
||||
|
|
|
|||
|
|
@ -640,6 +640,16 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
return Ok(emit_xpath_harness(spec));
|
||||
}
|
||||
|
||||
// Phase 08 (Track J.6): short-circuit to the header-injection
|
||||
// harness when the spec's expected cap is HEADER_INJECTION. The
|
||||
// harness splices the payload into a synthetic
|
||||
// `flask.Response.headers["Set-Cookie"] = value` assignment and
|
||||
// records the unescaped value via a `ProbeKind::HeaderEmit`
|
||||
// probe consumed by the `HeaderInjected` oracle.
|
||||
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = PythonShape::detect(spec, &entry_source);
|
||||
let body = generate_for_shape(spec, shape);
|
||||
|
|
@ -1085,6 +1095,74 @@ if __name__ == "__main__":
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 08 — Track J.6 header-injection harness for Python (Flask
|
||||
/// `Response.headers.__setitem__`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||
/// `flask.Response.headers["Set-Cookie"] = value` assignment that
|
||||
/// records the *unmodified* value bytes (including any embedded
|
||||
/// `\r\n`) via a `ProbeKind::HeaderEmit` probe. A vuln payload
|
||||
/// carrying raw CRLF trips the
|
||||
/// [`crate::dynamic::oracle::ProbePredicate::HeaderInjected`]
|
||||
/// oracle; the paired benign control passes the same logical bytes
|
||||
/// pre-encoded via `urllib.parse.quote`, so the captured value
|
||||
/// carries `%0D%0A` (not the raw bytes) and the predicate stays
|
||||
/// clear.
|
||||
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let probe = probe_shim();
|
||||
let body = format!(
|
||||
r#"#!/usr/bin/env python3
|
||||
"""Nyx dynamic harness — HEADER_INJECTION flask.Response.headers.__setitem__ (Phase 08 / Track J.6)."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
{probe}
|
||||
|
||||
|
||||
def _nyx_header_probe(name, value):
|
||||
rec = {{
|
||||
"sink_callee": "flask.Response.headers.__setitem__",
|
||||
"args": [
|
||||
{{"kind": "String", "value": name}},
|
||||
{{"kind": "String", "value": value}},
|
||||
],
|
||||
"captured_at_ns": time.time_ns(),
|
||||
"payload_id": os.environ.get("NYX_PAYLOAD_ID", ""),
|
||||
"kind": {{"kind": "HeaderEmit", "name": name, "value": value}},
|
||||
"witness": __nyx_witness("flask.Response.headers.__setitem__", [name, value]),
|
||||
}}
|
||||
__nyx_emit(rec)
|
||||
|
||||
|
||||
def _nyx_run():
|
||||
payload = os.environ.get("NYX_PAYLOAD", "")
|
||||
# Synthetic instrumented setter — mirrors
|
||||
# `werkzeug.datastructures.Headers.__setitem__` semantics: the
|
||||
# value bytes flow through unmodified, so a tainted payload that
|
||||
# carries raw `\r\n` lands on the wire as a header split.
|
||||
name = "Set-Cookie"
|
||||
value = payload
|
||||
_nyx_header_probe(name, value)
|
||||
print("__NYX_SINK_HIT__", flush=True)
|
||||
sys.stdout.write(json.dumps({{"name": name, "value": value}}) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_nyx_run()
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.py".to_owned(),
|
||||
command: vec!["python3".to_owned(), "harness.py".to_owned()],
|
||||
extra_files: Vec::new(),
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
|
||||
/// reading the entry file from disk. Exposed so test helpers can pin a
|
||||
/// per-fixture shape without round-tripping through [`emit`].
|
||||
|
|
|
|||
|
|
@ -424,6 +424,9 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
|||
if spec.expected_cap == crate::labels::Cap::XXE {
|
||||
return Ok(emit_xxe_harness(spec));
|
||||
}
|
||||
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
|
||||
let entry_source = read_entry_source(&spec.entry_file);
|
||||
let shape = RubyShape::detect(spec, &entry_source);
|
||||
|
|
@ -616,6 +619,57 @@ STDOUT.flush
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 08 — Track J.6 header-injection harness for Ruby
|
||||
/// (`Rack::Response#set_header`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||
/// `response.set_header('Set-Cookie', value)` shim that records the
|
||||
/// *unmodified* value bytes (including any embedded `\r\n`) via a
|
||||
/// `ProbeKind::HeaderEmit` probe. Mirrors the synthetic-harness
|
||||
/// pattern used by Phase 03 / 04 / 05.
|
||||
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let body = format!(
|
||||
r#"# Nyx dynamic harness — HEADER_INJECTION Rack::Response#set_header (Phase 08 / Track J.6).
|
||||
require 'json'
|
||||
|
||||
{shim}
|
||||
|
||||
def _nyx_header_probe(name, value)
|
||||
p = ENV['NYX_PROBE_PATH']
|
||||
return if p.nil? || p.empty?
|
||||
rec = {{
|
||||
'sink_callee' => 'Rack::Response#set_header',
|
||||
'args' => [
|
||||
{{ 'kind' => 'String', 'value' => name }},
|
||||
{{ 'kind' => 'String', 'value' => value }},
|
||||
],
|
||||
'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond),
|
||||
'payload_id' => ENV['NYX_PAYLOAD_ID'] || '',
|
||||
'kind' => {{ 'kind' => 'HeaderEmit', 'name' => name, 'value' => value }},
|
||||
'witness' => __nyx_witness('Rack::Response#set_header', [name, value]),
|
||||
}}
|
||||
File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }}
|
||||
end
|
||||
|
||||
payload = ENV['NYX_PAYLOAD'] || ''
|
||||
name = 'Set-Cookie'
|
||||
value = payload
|
||||
_nyx_header_probe(name, value)
|
||||
STDOUT.puts '__NYX_SINK_HIT__'
|
||||
STDOUT.puts JSON.generate({{ 'name' => name, 'value' => value }})
|
||||
STDOUT.flush
|
||||
"#
|
||||
);
|
||||
HarnessSource {
|
||||
source: body,
|
||||
filename: "harness.rb".to_owned(),
|
||||
command: vec!["ruby".to_owned(), "harness.rb".to_owned()],
|
||||
extra_files: vec![],
|
||||
entry_subpath: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String {
|
||||
let entry_fn = &spec.entry_name;
|
||||
let pre_call = build_pre_call(spec);
|
||||
|
|
|
|||
|
|
@ -557,6 +557,96 @@ pub fn detect_shape(spec: &HarnessSpec) -> RustShape {
|
|||
RustShape::detect(spec, &src)
|
||||
}
|
||||
|
||||
/// Phase 08 — Track J.6 header-injection harness for Rust
|
||||
/// (`axum`-style `HeaderMap::insert`).
|
||||
///
|
||||
/// Reads `NYX_PAYLOAD`, calls a synthetic instrumented
|
||||
/// `headers_mut().insert("Set-Cookie", value)` shim that records the
|
||||
/// *unmodified* value bytes (including any embedded `\r\n`) via a
|
||||
/// `ProbeKind::HeaderEmit` probe. Std-only — no `Cargo.toml`
|
||||
/// dependencies beyond the always-pinned `libc` (used by the probe
|
||||
/// shim's crash guard).
|
||||
pub fn emit_header_injection_harness(_spec: &HarnessSpec) -> HarnessSource {
|
||||
let shim = probe_shim();
|
||||
let cargo_toml = generate_cargo_toml(Cap::HEADER_INJECTION);
|
||||
let main_rs = format!(
|
||||
r##"//! Nyx dynamic harness — HEADER_INJECTION HeaderMap::insert (Phase 08 / Track J.6).
|
||||
use std::env;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::time::{{SystemTime, UNIX_EPOCH}};
|
||||
|
||||
{shim}
|
||||
|
||||
fn nyx_json_escape(s: &str) -> String {{
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
for c in s.chars() {{
|
||||
match c {{
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => {{
|
||||
out.push_str(&format!("\\u{{:04x}}", c as u32));
|
||||
}}
|
||||
c => out.push(c),
|
||||
}}
|
||||
}}
|
||||
out
|
||||
}}
|
||||
|
||||
fn nyx_header_probe(name: &str, value: &str) {{
|
||||
let p = match env::var("NYX_PROBE_PATH") {{ Ok(s) => s, Err(_) => return }};
|
||||
if p.is_empty() {{ return; }}
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
|
||||
let pid = env::var("NYX_PAYLOAD_ID").unwrap_or_default();
|
||||
let mut line = String::new();
|
||||
line.push_str("{{\"sink_callee\":\"HeaderMap::insert\",\"args\":[");
|
||||
line.push_str("{{\"kind\":\"String\",\"value\":\"");
|
||||
line.push_str(&nyx_json_escape(name));
|
||||
line.push_str("\"}},{{\"kind\":\"String\",\"value\":\"");
|
||||
line.push_str(&nyx_json_escape(value));
|
||||
line.push_str("\"}}],");
|
||||
line.push_str("\"captured_at_ns\":");
|
||||
line.push_str(&now.to_string());
|
||||
line.push_str(",\"payload_id\":\"");
|
||||
line.push_str(&nyx_json_escape(&pid));
|
||||
line.push_str("\",\"kind\":{{\"kind\":\"HeaderEmit\",\"name\":\"");
|
||||
line.push_str(&nyx_json_escape(name));
|
||||
line.push_str("\",\"value\":\"");
|
||||
line.push_str(&nyx_json_escape(value));
|
||||
line.push_str("\"}},\"witness\":{{}}}}\n");
|
||||
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&p) {{
|
||||
let _ = f.write_all(line.as_bytes());
|
||||
}}
|
||||
}}
|
||||
|
||||
fn main() {{
|
||||
let payload = env::var("NYX_PAYLOAD").unwrap_or_default();
|
||||
let name = "Set-Cookie";
|
||||
let value = &payload;
|
||||
nyx_header_probe(name, value);
|
||||
println!("__NYX_SINK_HIT__");
|
||||
let mut body = String::new();
|
||||
body.push_str("{{\"name\":\"");
|
||||
body.push_str(&nyx_json_escape(name));
|
||||
body.push_str("\",\"value\":\"");
|
||||
body.push_str(&nyx_json_escape(value));
|
||||
body.push_str("\"}}");
|
||||
println!("{{body}}", body = body);
|
||||
}}
|
||||
"##
|
||||
);
|
||||
HarnessSource {
|
||||
source: main_rs,
|
||||
filename: "src/main.rs".into(),
|
||||
command: vec!["target/release/nyx_harness".into()],
|
||||
extra_files: vec![("Cargo.toml".into(), cargo_toml)],
|
||||
entry_subpath: Some("src/entry.rs".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_entry_source(entry_file: &str) -> String {
|
||||
let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)];
|
||||
for path in &candidates {
|
||||
|
|
@ -569,6 +659,14 @@ fn read_entry_source(entry_file: &str) -> String {
|
|||
|
||||
/// Emit a Rust harness for `spec`.
|
||||
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||
// Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit. The
|
||||
// Rust harness models an `axum`-style `HeaderMap::insert` shim
|
||||
// that records the *unmodified* value bytes via a
|
||||
// `ProbeKind::HeaderEmit` probe.
|
||||
if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION {
|
||||
return Ok(emit_header_injection_harness(spec));
|
||||
}
|
||||
|
||||
let shape = detect_shape(spec);
|
||||
|
||||
// Generic + LibfuzzerTarget accept Param(0)/EnvVar; richer shapes
|
||||
|
|
|
|||
|
|
@ -239,6 +239,32 @@ pub enum ProbePredicate {
|
|||
/// the parser-refusal benign control still confirm.
|
||||
require_expanded: bool,
|
||||
},
|
||||
/// Phase 08 (Track J.6): HTTP response-header CRLF-injection
|
||||
/// predicate.
|
||||
///
|
||||
/// Fires when at least one drained probe carries
|
||||
/// [`ProbeKind::HeaderEmit`] whose `name` equals `header_name` (or
|
||||
/// `header_name` is the wildcard `"*"`) and whose `value` contains
|
||||
/// a literal `\r\n` byte pair. The vuln payload splices `\r\n`
|
||||
/// followed by an injected header line into the response writer's
|
||||
/// value argument; the per-language harness's instrumented
|
||||
/// `setHeader` records the unmodified bytes the host process
|
||||
/// passed in. The benign control passes the same logical value
|
||||
/// through `URLEncoder.encode` / `urllib.parse.quote`, so the
|
||||
/// captured value carries `%0d%0a` (not the raw bytes) and the
|
||||
/// predicate stays clear.
|
||||
///
|
||||
/// Cross-cutting in the same sense as
|
||||
/// [`Self::DeserializeGadgetInvoked`] /
|
||||
/// [`Self::XxeEntityExpanded`] /
|
||||
/// [`Self::QueryResultCountGreaterThan`] — evaluated across every
|
||||
/// drained probe rather than against a single record.
|
||||
HeaderInjected {
|
||||
/// Header name the malicious payload targets (e.g.
|
||||
/// `"Set-Cookie"`, `"Location"`). Use `"*"` to satisfy on any
|
||||
/// captured header whose value contains the CRLF pair.
|
||||
header_name: &'static str,
|
||||
},
|
||||
/// Phase 06 (Track J.4) / Phase 07 (Track J.5): result-count
|
||||
/// predicate shared by LDAP-filter and XPath-expression injection.
|
||||
///
|
||||
|
|
@ -404,6 +430,20 @@ pub fn oracle_fired_with_stubs(
|
|||
if !query_count_cross_ok {
|
||||
return false;
|
||||
}
|
||||
// Phase 08 (Track J.6): header-injection cross-cutting
|
||||
// predicates. Each `HeaderInjected { header_name }`
|
||||
// consults the captured probe channel for a
|
||||
// [`ProbeKind::HeaderEmit`] record whose `name` matches
|
||||
// and whose `value` contains a literal CRLF byte pair.
|
||||
let header_injected_ok = cross.iter().all(|p| match p {
|
||||
ProbePredicate::HeaderInjected { header_name } => {
|
||||
probes_satisfy_header_injected(probes, header_name)
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
if !header_injected_ok {
|
||||
return false;
|
||||
}
|
||||
// Phase 04 (Track J.2): SSTI render-equality cross-cutting
|
||||
// predicates. Each `TemplateEvalEqual { expected }` consults
|
||||
// the captured stdout body — see [`stdout_template_equals`].
|
||||
|
|
@ -429,13 +469,14 @@ pub fn oracle_fired_with_stubs(
|
|||
.any(|p| per_probe.iter().all(|pred| probe_satisfies_one(p, pred))),
|
||||
}
|
||||
}
|
||||
Oracle::SinkCrash { signals } => probes.iter().any(|p| match p.kind {
|
||||
ProbeKind::Crash { signal } => signals.contains(signal),
|
||||
Oracle::SinkCrash { signals } => probes.iter().any(|p| match &p.kind {
|
||||
ProbeKind::Crash { signal } => signals.contains(*signal),
|
||||
ProbeKind::Normal
|
||||
| ProbeKind::Deserialize { .. }
|
||||
| ProbeKind::Xxe { .. }
|
||||
| ProbeKind::Ldap { .. }
|
||||
| ProbeKind::Xpath { .. } => false,
|
||||
| ProbeKind::Xpath { .. }
|
||||
| ProbeKind::HeaderEmit { .. } => false,
|
||||
}),
|
||||
Oracle::OutputContains(needle) => {
|
||||
let nb = needle.as_bytes();
|
||||
|
|
@ -462,6 +503,7 @@ fn is_cross_cutting(pred: &ProbePredicate) -> bool {
|
|||
| ProbePredicate::TemplateEvalEqual { .. }
|
||||
| ProbePredicate::XxeEntityExpanded { .. }
|
||||
| ProbePredicate::QueryResultCountGreaterThan { .. }
|
||||
| ProbePredicate::HeaderInjected { .. }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -486,6 +528,10 @@ fn cross_cutting_satisfied(pred: &ProbePredicate, stub_events: &[StubEvent]) ->
|
|||
// *probe log* rather than stub events; evaluated separately
|
||||
// in [`probes_satisfy_count_gt`] below.
|
||||
ProbePredicate::QueryResultCountGreaterThan { .. } => true,
|
||||
// HeaderInjected is cross-cutting against the *probe log*
|
||||
// rather than stub events; evaluated separately in
|
||||
// [`probes_satisfy_header_injected`] below.
|
||||
ProbePredicate::HeaderInjected { .. } => true,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -533,9 +579,9 @@ fn stdout_template_equals(stdout: &[u8], expected: u64) -> bool {
|
|||
/// True when at least one drained probe is a
|
||||
/// [`ProbeKind::Deserialize`] record matching `require_invoked`.
|
||||
fn probes_satisfy_deserialize(probes: &[SinkProbe], require_invoked: bool) -> bool {
|
||||
probes.iter().any(|p| match p.kind {
|
||||
probes.iter().any(|p| match &p.kind {
|
||||
ProbeKind::Deserialize { gadget_chain_invoked } => {
|
||||
gadget_chain_invoked == require_invoked
|
||||
*gadget_chain_invoked == require_invoked
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
|
|
@ -544,8 +590,8 @@ fn probes_satisfy_deserialize(probes: &[SinkProbe], require_invoked: bool) -> bo
|
|||
/// True when at least one drained probe is a [`ProbeKind::Xxe`]
|
||||
/// record matching `require_expanded`.
|
||||
fn probes_satisfy_xxe(probes: &[SinkProbe], require_expanded: bool) -> bool {
|
||||
probes.iter().any(|p| match p.kind {
|
||||
ProbeKind::Xxe { entity_expanded } => entity_expanded == require_expanded,
|
||||
probes.iter().any(|p| match &p.kind {
|
||||
ProbeKind::Xxe { entity_expanded } => *entity_expanded == require_expanded,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
|
@ -555,9 +601,24 @@ fn probes_satisfy_xxe(probes: &[SinkProbe], require_expanded: bool) -> bool {
|
|||
/// (`entries_returned > n`) and [`ProbeKind::Xpath`]
|
||||
/// (`nodes_returned > n`).
|
||||
fn probes_satisfy_count_gt(probes: &[SinkProbe], n: u32) -> bool {
|
||||
probes.iter().any(|p| match p.kind {
|
||||
ProbeKind::Ldap { entries_returned } => entries_returned > n,
|
||||
ProbeKind::Xpath { nodes_returned } => nodes_returned > n,
|
||||
probes.iter().any(|p| match &p.kind {
|
||||
ProbeKind::Ldap { entries_returned } => *entries_returned > n,
|
||||
ProbeKind::Xpath { nodes_returned } => *nodes_returned > n,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// True when at least one drained probe is a
|
||||
/// [`ProbeKind::HeaderEmit`] record whose `name` matches `header_name`
|
||||
/// (or `header_name == "*"`) and whose `value` contains a literal
|
||||
/// `\r\n` byte pair. Powers
|
||||
/// [`ProbePredicate::HeaderInjected`] (Phase 08 — Track J.6).
|
||||
fn probes_satisfy_header_injected(probes: &[SinkProbe], header_name: &str) -> bool {
|
||||
probes.iter().any(|p| match &p.kind {
|
||||
ProbeKind::HeaderEmit { name, value } => {
|
||||
(header_name == "*" || name.eq_ignore_ascii_case(header_name))
|
||||
&& value.contains("\r\n")
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
|
@ -595,7 +656,8 @@ fn probe_satisfies_one(probe: &SinkProbe, pred: &ProbePredicate) -> bool {
|
|||
| ProbePredicate::DeserializeGadgetInvoked { .. }
|
||||
| ProbePredicate::TemplateEvalEqual { .. }
|
||||
| ProbePredicate::XxeEntityExpanded { .. }
|
||||
| ProbePredicate::QueryResultCountGreaterThan { .. } => true,
|
||||
| ProbePredicate::QueryResultCountGreaterThan { .. }
|
||||
| ProbePredicate::HeaderInjected { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -615,13 +677,14 @@ fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
|
|||
/// `Inconclusive(UnrelatedCrash)`) from "process crashed and a sink-site
|
||||
/// probe matched" (→ `Confirmed` via `Oracle::SinkCrash`).
|
||||
pub fn probe_crash_signal(probe: &SinkProbe) -> Option<Signal> {
|
||||
match probe.kind {
|
||||
ProbeKind::Crash { signal } => Some(signal),
|
||||
match &probe.kind {
|
||||
ProbeKind::Crash { signal } => Some(*signal),
|
||||
ProbeKind::Normal
|
||||
| ProbeKind::Deserialize { .. }
|
||||
| ProbeKind::Xxe { .. }
|
||||
| ProbeKind::Ldap { .. }
|
||||
| ProbeKind::Xpath { .. } => None,
|
||||
| ProbeKind::Xpath { .. }
|
||||
| ProbeKind::HeaderEmit { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
tests/dynamic_fixtures/header_injection/go/benign.go
Normal file
15
tests/dynamic_fixtures/header_injection/go/benign.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Phase 08 (Track J.6) — Go HEADER_INJECTION benign control fixture.
|
||||
//
|
||||
// Same shape as `vuln.go` but URL-encodes the value via
|
||||
// `net/url.QueryEscape` before the header set, so CRLF bytes land as
|
||||
// `%0D%0A` and the wire keeps a single header.
|
||||
package benign
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func Run(w http.ResponseWriter, value string) {
|
||||
w.Header().Set("Set-Cookie", url.QueryEscape(value))
|
||||
}
|
||||
13
tests/dynamic_fixtures/header_injection/go/vuln.go
Normal file
13
tests/dynamic_fixtures/header_injection/go/vuln.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Phase 08 (Track J.6) — Go HEADER_INJECTION vuln fixture.
|
||||
//
|
||||
// The function assigns the attacker-controlled `value` directly into a
|
||||
// `Set-Cookie` header via `http.ResponseWriter.Header().Set`. A
|
||||
// payload carrying `\r\nSet-Cookie: nyx-injected=pwn` splits the
|
||||
// single header into two on the wire.
|
||||
package vuln
|
||||
|
||||
import "net/http"
|
||||
|
||||
func Run(w http.ResponseWriter, value string) {
|
||||
w.Header().Set("Set-Cookie", value)
|
||||
}
|
||||
16
tests/dynamic_fixtures/header_injection/java/Benign.java
Normal file
16
tests/dynamic_fixtures/header_injection/java/Benign.java
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Phase 08 (Track J.6) — Java HEADER_INJECTION benign control fixture.
|
||||
//
|
||||
// Same shape as `Vuln.java` but URL-encodes the value via
|
||||
// `URLEncoder.encode` (the OWASP-recommended defence), so any CRLF
|
||||
// bytes in the value land as `%0D%0A` and the wire keeps a single
|
||||
// header.
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
public class Benign {
|
||||
public static void run(HttpServletResponse response, String value) {
|
||||
String encoded = URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
response.setHeader("Set-Cookie", encoded);
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/header_injection/java/Vuln.java
Normal file
13
tests/dynamic_fixtures/header_injection/java/Vuln.java
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Phase 08 (Track J.6) — Java HEADER_INJECTION vuln fixture.
|
||||
//
|
||||
// The function string-concatenates the attacker-controlled `value`
|
||||
// directly into a `Set-Cookie` header set via
|
||||
// `HttpServletResponse.setHeader`. A payload carrying `\r\nSet-Cookie:
|
||||
// nyx-injected=pwn` splits the single header into two on the wire.
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
public class Vuln {
|
||||
public static void run(HttpServletResponse response, String value) {
|
||||
response.setHeader("Set-Cookie", value);
|
||||
}
|
||||
}
|
||||
13
tests/dynamic_fixtures/header_injection/js/benign.js
Normal file
13
tests/dynamic_fixtures/header_injection/js/benign.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Phase 08 (Track J.6) — JavaScript HEADER_INJECTION benign control
|
||||
// fixture.
|
||||
//
|
||||
// Same shape as `vuln.js` but URL-encodes the value first via
|
||||
// `encodeURIComponent`, so CRLF bytes land as `%0D%0A` and the wire
|
||||
// keeps a single header.
|
||||
const http = require('http');
|
||||
|
||||
function run(res, value) {
|
||||
res.setHeader('Set-Cookie', encodeURIComponent(value));
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
13
tests/dynamic_fixtures/header_injection/js/vuln.js
Normal file
13
tests/dynamic_fixtures/header_injection/js/vuln.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Phase 08 (Track J.6) — JavaScript HEADER_INJECTION vuln fixture.
|
||||
//
|
||||
// The function assigns the attacker-controlled `value` directly into a
|
||||
// Node response's `Set-Cookie` header via `http.ServerResponse
|
||||
// #setHeader`. A payload carrying `\r\nSet-Cookie: nyx-injected=pwn`
|
||||
// splits the single header into two on the wire.
|
||||
const http = require('http');
|
||||
|
||||
function run(res, value) {
|
||||
res.setHeader('Set-Cookie', value);
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
9
tests/dynamic_fixtures/header_injection/php/benign.php
Normal file
9
tests/dynamic_fixtures/header_injection/php/benign.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
// Phase 08 (Track J.6) — PHP HEADER_INJECTION benign control fixture.
|
||||
//
|
||||
// Same shape as `vuln.php` but URL-encodes the value first via
|
||||
// `urlencode`, so CRLF bytes land as `%0D%0A` and the wire keeps a
|
||||
// single header.
|
||||
function run($value) {
|
||||
header("Set-Cookie: " . urlencode($value));
|
||||
}
|
||||
10
tests/dynamic_fixtures/header_injection/php/vuln.php
Normal file
10
tests/dynamic_fixtures/header_injection/php/vuln.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
// Phase 08 (Track J.6) — PHP HEADER_INJECTION vuln fixture.
|
||||
//
|
||||
// The function concatenates the attacker-controlled `$value` directly
|
||||
// into a `Set-Cookie` header set via the built-in `header()` function.
|
||||
// A payload carrying `\r\nSet-Cookie: nyx-injected=pwn` splits the
|
||||
// single header into two on the wire.
|
||||
function run($value) {
|
||||
header("Set-Cookie: " . $value);
|
||||
}
|
||||
13
tests/dynamic_fixtures/header_injection/python/benign.py
Normal file
13
tests/dynamic_fixtures/header_injection/python/benign.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Phase 08 (Track J.6) — Python HEADER_INJECTION benign control fixture.
|
||||
#
|
||||
# Same shape as `vuln.py` but URL-encodes the value via
|
||||
# `urllib.parse.quote` first, so CRLF bytes land as `%0D%0A` and the
|
||||
# wire keeps a single header.
|
||||
from urllib.parse import quote
|
||||
from flask import Response
|
||||
|
||||
|
||||
def run(value):
|
||||
response = Response("ok")
|
||||
response.headers["Set-Cookie"] = quote(value, safe="")
|
||||
return response
|
||||
13
tests/dynamic_fixtures/header_injection/python/vuln.py
Normal file
13
tests/dynamic_fixtures/header_injection/python/vuln.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Phase 08 (Track J.6) — Python HEADER_INJECTION vuln fixture.
|
||||
#
|
||||
# The function assigns the attacker-controlled `value` directly into
|
||||
# a Flask response's `Set-Cookie` header via `Response.headers
|
||||
# .__setitem__`. A payload carrying `\r\nSet-Cookie: nyx-injected=pwn`
|
||||
# splits the single header into two on the wire.
|
||||
from flask import Response
|
||||
|
||||
|
||||
def run(value):
|
||||
response = Response("ok")
|
||||
response.headers["Set-Cookie"] = value
|
||||
return response
|
||||
13
tests/dynamic_fixtures/header_injection/ruby/benign.rb
Normal file
13
tests/dynamic_fixtures/header_injection/ruby/benign.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Phase 08 (Track J.6) — Ruby HEADER_INJECTION benign control fixture.
|
||||
#
|
||||
# Same shape as `vuln.rb` but URL-encodes the value first via
|
||||
# `URI.encode_www_form_component`, so CRLF bytes land as `%0D%0A` and
|
||||
# the wire keeps a single header.
|
||||
require 'rack'
|
||||
require 'uri'
|
||||
|
||||
def run(value)
|
||||
response = Rack::Response.new
|
||||
response.set_header('Set-Cookie', URI.encode_www_form_component(value))
|
||||
response
|
||||
end
|
||||
13
tests/dynamic_fixtures/header_injection/ruby/vuln.rb
Normal file
13
tests/dynamic_fixtures/header_injection/ruby/vuln.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Phase 08 (Track J.6) — Ruby HEADER_INJECTION vuln fixture.
|
||||
#
|
||||
# The function assigns the attacker-controlled `value` directly into a
|
||||
# Rack response's `Set-Cookie` header via `Rack::Response#set_header`.
|
||||
# A payload carrying `\r\nSet-Cookie: nyx-injected=pwn` splits the
|
||||
# single header into two on the wire.
|
||||
require 'rack'
|
||||
|
||||
def run(value)
|
||||
response = Rack::Response.new
|
||||
response.set_header('Set-Cookie', value)
|
||||
response
|
||||
end
|
||||
16
tests/dynamic_fixtures/header_injection/rust/benign.rs
Normal file
16
tests/dynamic_fixtures/header_injection/rust/benign.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Phase 08 (Track J.6) — Rust HEADER_INJECTION benign control fixture.
|
||||
//
|
||||
// Same shape as `vuln.rs` but routes the value through the
|
||||
// `percent-encoding` crate first, so CRLF bytes land as `%0D%0A` and
|
||||
// the wire keeps a single header.
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::HeaderValue;
|
||||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
|
||||
pub fn run(headers: &mut HeaderMap, value: &str) {
|
||||
let encoded: String = utf8_percent_encode(value, NON_ALPHANUMERIC).collect();
|
||||
headers.insert(
|
||||
"set-cookie",
|
||||
HeaderValue::from_str(&encoded).unwrap(),
|
||||
);
|
||||
}
|
||||
17
tests/dynamic_fixtures/header_injection/rust/vuln.rs
Normal file
17
tests/dynamic_fixtures/header_injection/rust/vuln.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Phase 08 (Track J.6) — Rust HEADER_INJECTION vuln fixture.
|
||||
//
|
||||
// The function inserts the attacker-controlled `value` into an axum
|
||||
// `HeaderMap` via `headers_mut().insert`, bypassing
|
||||
// `HeaderValue::from_str`'s newline check by passing the tainted
|
||||
// bytes through `HeaderValue::from_bytes(...).unwrap()`. A payload
|
||||
// carrying `\r\nSet-Cookie: nyx-injected=pwn` splits the single
|
||||
// header into two on the wire.
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
pub fn run(headers: &mut HeaderMap, value: &str) {
|
||||
headers.insert(
|
||||
"set-cookie",
|
||||
HeaderValue::from_bytes(value.as_bytes()).unwrap(),
|
||||
);
|
||||
}
|
||||
429
tests/header_injection_corpus.rs
Normal file
429
tests/header_injection_corpus.rs
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
//! Phase 08 (Track J.6) — HEADER_INJECTION corpus acceptance.
|
||||
//!
|
||||
//! Asserts the new cap end-to-end: corpus slices register per-language
|
||||
//! vuln/benign pairs for Java / Python / PHP / Ruby / JavaScript / Go /
|
||||
//! Rust, the lang-aware resolver pairs them inside the correct slice,
|
||||
//! the per-language harness emitters splice in the synthetic
|
||||
//! `setHeader` shim + `HeaderEmit` probe + sink-hit sentinel, the
|
||||
//! framework adapters fire on the canonical sink call, and the
|
||||
//! `HeaderInjected` predicate fires only on probes whose value
|
||||
//! carries a literal `\r\n` byte pair.
|
||||
//!
|
||||
//! `cargo nextest run --features dynamic --test header_injection_corpus`.
|
||||
|
||||
#![cfg(feature = "dynamic")]
|
||||
|
||||
mod common;
|
||||
|
||||
use nyx_scanner::dynamic::corpus::{
|
||||
audit_marker_collisions, benign_payload_for_lang, payloads_for_lang,
|
||||
resolve_benign_control_lang, Oracle,
|
||||
};
|
||||
use nyx_scanner::dynamic::framework::registry::adapters_for;
|
||||
use nyx_scanner::dynamic::lang;
|
||||
use nyx_scanner::dynamic::oracle::{oracle_fired, ProbePredicate};
|
||||
use nyx_scanner::dynamic::probe::{ProbeKind, ProbeWitness, SinkProbe};
|
||||
use nyx_scanner::dynamic::sandbox::SandboxOutcome;
|
||||
use nyx_scanner::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
|
||||
use nyx_scanner::labels::Cap;
|
||||
use nyx_scanner::summary::FuncSummary;
|
||||
use nyx_scanner::symbol::Lang;
|
||||
use std::time::Duration;
|
||||
|
||||
const LANGS: &[Lang] = &[
|
||||
Lang::Java,
|
||||
Lang::Python,
|
||||
Lang::Php,
|
||||
Lang::Ruby,
|
||||
Lang::JavaScript,
|
||||
Lang::Go,
|
||||
Lang::Rust,
|
||||
];
|
||||
|
||||
fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec {
|
||||
HarnessSpec {
|
||||
finding_id: "phase08test0001".into(),
|
||||
entry_file: entry_file.into(),
|
||||
entry_name: entry_name.into(),
|
||||
entry_kind: EntryKind::Function,
|
||||
lang,
|
||||
toolchain_id: "phase08".into(),
|
||||
payload_slot: PayloadSlot::Param(0),
|
||||
expected_cap: Cap::HEADER_INJECTION,
|
||||
constraint_hints: vec![],
|
||||
sink_file: entry_file.into(),
|
||||
sink_line: 1,
|
||||
spec_hash: "phase08test0001".into(),
|
||||
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
||||
stubs_required: vec![],
|
||||
framework: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_registers_header_injection_for_every_supported_lang() {
|
||||
for lang in LANGS {
|
||||
let slice = payloads_for_lang(Cap::HEADER_INJECTION, *lang);
|
||||
assert!(
|
||||
!slice.is_empty(),
|
||||
"HEADER_INJECTION has no payloads for {lang:?}"
|
||||
);
|
||||
let has_vuln = slice.iter().any(|p| !p.is_benign);
|
||||
let has_benign = slice.iter().any(|p| p.is_benign);
|
||||
assert!(has_vuln, "{lang:?} HEADER_INJECTION missing vuln payload");
|
||||
assert!(
|
||||
has_benign,
|
||||
"{lang:?} HEADER_INJECTION missing benign control"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_injection_unsupported_caps_unchanged_for_other_langs() {
|
||||
for lang in [Lang::C, Lang::Cpp, Lang::TypeScript] {
|
||||
assert!(
|
||||
payloads_for_lang(Cap::HEADER_INJECTION, lang).is_empty(),
|
||||
"unexpected HEADER_INJECTION payloads for {lang:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn benign_control_resolves_within_lang_slice() {
|
||||
for lang in LANGS {
|
||||
let slice = payloads_for_lang(Cap::HEADER_INJECTION, *lang);
|
||||
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||
let resolved = resolve_benign_control_lang(vuln, Cap::HEADER_INJECTION, *lang)
|
||||
.expect("paired control");
|
||||
assert!(resolved.is_benign);
|
||||
let direct = benign_payload_for_lang(Cap::HEADER_INJECTION, *lang).unwrap();
|
||||
assert_eq!(direct.label, resolved.label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_oracle_carries_header_injected_predicate() {
|
||||
for lang in LANGS {
|
||||
let slice = payloads_for_lang(Cap::HEADER_INJECTION, *lang);
|
||||
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||
match &vuln.oracle {
|
||||
Oracle::SinkProbe { predicates } => {
|
||||
assert!(
|
||||
predicates.iter().any(|p| matches!(
|
||||
p,
|
||||
ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie"
|
||||
}
|
||||
)),
|
||||
"{lang:?} vuln payload missing HeaderInjected predicate",
|
||||
);
|
||||
}
|
||||
other => panic!("expected SinkProbe oracle for {lang:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vuln_payload_bytes_carry_crlf_benign_bytes_do_not() {
|
||||
// Vuln payload carries raw `\r\n`; benign control carries the
|
||||
// URL-encoded `%0D%0A` form instead.
|
||||
for lang in LANGS {
|
||||
let slice = payloads_for_lang(Cap::HEADER_INJECTION, *lang);
|
||||
let vuln = slice.iter().find(|p| !p.is_benign).unwrap();
|
||||
let benign = slice.iter().find(|p| p.is_benign).unwrap();
|
||||
assert!(
|
||||
vuln.bytes.windows(2).any(|w| w == b"\r\n"),
|
||||
"{lang:?} vuln payload must carry a raw CRLF pair",
|
||||
);
|
||||
assert!(
|
||||
!benign.bytes.windows(2).any(|w| w == b"\r\n"),
|
||||
"{lang:?} benign control must NOT carry a raw CRLF pair",
|
||||
);
|
||||
let benign_text = std::str::from_utf8(benign.bytes).unwrap();
|
||||
assert!(
|
||||
benign_text.contains("%0D%0A") || benign_text.contains("%0d%0a"),
|
||||
"{lang:?} benign control must URL-encode the CRLF as %0D%0A",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_collisions_clean_with_phase_08_additions() {
|
||||
assert!(audit_marker_collisions().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_kind_header_emit_serdes() {
|
||||
let original = ProbeKind::HeaderEmit {
|
||||
name: "Set-Cookie".into(),
|
||||
value: "nyx-session\r\nSet-Cookie: nyx-injected=pwn".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
assert!(json.contains("HeaderEmit"));
|
||||
assert!(json.contains("name"));
|
||||
assert!(json.contains("value"));
|
||||
let parsed: ProbeKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_injected_predicate_fires_on_crlf_value() {
|
||||
let oracle = Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
};
|
||||
let probes = vec![SinkProbe {
|
||||
sink_callee: "HttpServletResponse.setHeader".into(),
|
||||
args: vec![],
|
||||
captured_at_ns: 1,
|
||||
payload_id: "phase08".into(),
|
||||
kind: ProbeKind::HeaderEmit {
|
||||
name: "Set-Cookie".into(),
|
||||
value: "nyx-session\r\nSet-Cookie: nyx-injected=pwn".into(),
|
||||
},
|
||||
witness: ProbeWitness::empty(),
|
||||
}];
|
||||
let outcome = SandboxOutcome {
|
||||
exit_code: Some(0),
|
||||
stdout: vec![],
|
||||
stderr: vec![],
|
||||
timed_out: false,
|
||||
oob_callback_seen: false,
|
||||
sink_hit: true,
|
||||
duration: Duration::from_millis(1),
|
||||
hardening_outcome: None,
|
||||
};
|
||||
assert!(oracle_fired(&oracle, &outcome, &probes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_injected_predicate_clear_when_value_is_url_encoded() {
|
||||
let oracle = Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
};
|
||||
let probes = vec![SinkProbe {
|
||||
sink_callee: "HttpServletResponse.setHeader".into(),
|
||||
args: vec![],
|
||||
captured_at_ns: 1,
|
||||
payload_id: "phase08".into(),
|
||||
kind: ProbeKind::HeaderEmit {
|
||||
name: "Set-Cookie".into(),
|
||||
value: "nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn".into(),
|
||||
},
|
||||
witness: ProbeWitness::empty(),
|
||||
}];
|
||||
let outcome = SandboxOutcome {
|
||||
exit_code: Some(0),
|
||||
stdout: vec![],
|
||||
stderr: vec![],
|
||||
timed_out: false,
|
||||
oob_callback_seen: false,
|
||||
sink_hit: true,
|
||||
duration: Duration::from_millis(1),
|
||||
hardening_outcome: None,
|
||||
};
|
||||
assert!(!oracle_fired(&oracle, &outcome, &probes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_injected_predicate_clear_on_unrelated_header() {
|
||||
// Predicate pins `Set-Cookie`; a CRLF-carrying value emitted on a
|
||||
// different header name must not satisfy.
|
||||
let oracle = Oracle::SinkProbe {
|
||||
predicates: &[ProbePredicate::HeaderInjected {
|
||||
header_name: "Set-Cookie",
|
||||
}],
|
||||
};
|
||||
let probes = vec![SinkProbe {
|
||||
sink_callee: "HttpServletResponse.setHeader".into(),
|
||||
args: vec![],
|
||||
captured_at_ns: 1,
|
||||
payload_id: "phase08".into(),
|
||||
kind: ProbeKind::HeaderEmit {
|
||||
name: "X-Trace-Id".into(),
|
||||
value: "trace\r\nX-Injected: 1".into(),
|
||||
},
|
||||
witness: ProbeWitness::empty(),
|
||||
}];
|
||||
let outcome = SandboxOutcome {
|
||||
exit_code: Some(0),
|
||||
stdout: vec![],
|
||||
stderr: vec![],
|
||||
timed_out: false,
|
||||
oob_callback_seen: false,
|
||||
sink_hit: true,
|
||||
duration: Duration::from_millis(1),
|
||||
hardening_outcome: None,
|
||||
};
|
||||
assert!(!oracle_fired(&oracle, &outcome, &probes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_emitter_dispatches_to_header_injection_harness() {
|
||||
// Per-lang `sink_callee_marker` pins which response writer the
|
||||
// harness names in its probe record.
|
||||
for (lang, entry_file, entry_name, sink_callee_marker) in [
|
||||
(
|
||||
Lang::Java,
|
||||
"tests/dynamic_fixtures/header_injection/java/Vuln.java",
|
||||
"run",
|
||||
"HttpServletResponse.setHeader",
|
||||
),
|
||||
(
|
||||
Lang::Python,
|
||||
"tests/dynamic_fixtures/header_injection/python/vuln.py",
|
||||
"run",
|
||||
"flask.Response.headers.__setitem__",
|
||||
),
|
||||
(
|
||||
Lang::Php,
|
||||
"tests/dynamic_fixtures/header_injection/php/vuln.php",
|
||||
"run",
|
||||
"header()",
|
||||
),
|
||||
(
|
||||
Lang::Ruby,
|
||||
"tests/dynamic_fixtures/header_injection/ruby/vuln.rb",
|
||||
"run",
|
||||
"Rack::Response#set_header",
|
||||
),
|
||||
(
|
||||
Lang::JavaScript,
|
||||
"tests/dynamic_fixtures/header_injection/js/vuln.js",
|
||||
"run",
|
||||
"http.ServerResponse#setHeader",
|
||||
),
|
||||
(
|
||||
Lang::Go,
|
||||
"tests/dynamic_fixtures/header_injection/go/vuln.go",
|
||||
"Run",
|
||||
"http.ResponseWriter.Header.Set",
|
||||
),
|
||||
(
|
||||
Lang::Rust,
|
||||
"tests/dynamic_fixtures/header_injection/rust/vuln.rs",
|
||||
"run",
|
||||
"HeaderMap::insert",
|
||||
),
|
||||
] {
|
||||
let spec = make_spec(lang, entry_file, entry_name);
|
||||
let harness = lang::emit(&spec)
|
||||
.unwrap_or_else(|e| panic!("emit failed for {lang:?}: {e:?}"));
|
||||
assert!(
|
||||
harness.source.contains("HeaderEmit"),
|
||||
"{lang:?} header harness must carry the HeaderEmit probe kind",
|
||||
);
|
||||
assert!(
|
||||
harness.source.contains(sink_callee_marker),
|
||||
"{lang:?} header harness must name {sink_callee_marker:?} as the sink callee",
|
||||
);
|
||||
assert!(
|
||||
harness.source.contains("__NYX_SINK_HIT__"),
|
||||
"{lang:?} header harness must emit the sink-hit sentinel",
|
||||
);
|
||||
assert!(
|
||||
harness.source.contains("Set-Cookie"),
|
||||
"{lang:?} header harness must set the Set-Cookie header",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framework_adapters_detect_header_sink() {
|
||||
// Each lang registers its J.6 header adapter; detect_binding routes
|
||||
// through the registry and stamps an EntryKind::Function binding
|
||||
// when the fixture contains the canonical sink call.
|
||||
for (lang, fixture, sink_callee) in [
|
||||
(
|
||||
Lang::Java,
|
||||
"tests/dynamic_fixtures/header_injection/java/Vuln.java",
|
||||
"setHeader",
|
||||
),
|
||||
(
|
||||
Lang::Python,
|
||||
"tests/dynamic_fixtures/header_injection/python/vuln.py",
|
||||
"__setitem__",
|
||||
),
|
||||
(
|
||||
Lang::Php,
|
||||
"tests/dynamic_fixtures/header_injection/php/vuln.php",
|
||||
"header",
|
||||
),
|
||||
(
|
||||
Lang::Ruby,
|
||||
"tests/dynamic_fixtures/header_injection/ruby/vuln.rb",
|
||||
"set_header",
|
||||
),
|
||||
(
|
||||
Lang::JavaScript,
|
||||
"tests/dynamic_fixtures/header_injection/js/vuln.js",
|
||||
"setHeader",
|
||||
),
|
||||
(
|
||||
Lang::Go,
|
||||
"tests/dynamic_fixtures/header_injection/go/vuln.go",
|
||||
"Set",
|
||||
),
|
||||
(
|
||||
Lang::Rust,
|
||||
"tests/dynamic_fixtures/header_injection/rust/vuln.rs",
|
||||
"insert",
|
||||
),
|
||||
] {
|
||||
let bytes = std::fs::read(fixture).expect("fixture exists");
|
||||
let ts_lang = ts_language_for(lang);
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
parser.set_language(&ts_lang).unwrap();
|
||||
let tree = parser.parse(&bytes, None).unwrap();
|
||||
let mut summary = FuncSummary {
|
||||
name: "run".into(),
|
||||
file_path: fixture.to_owned(),
|
||||
lang: slug(lang).into(),
|
||||
..Default::default()
|
||||
};
|
||||
summary
|
||||
.callees
|
||||
.push(nyx_scanner::summary::CalleeSite::bare(sink_callee));
|
||||
let registry_slice = adapters_for(lang);
|
||||
assert!(!registry_slice.is_empty(), "{lang:?} adapter slice empty");
|
||||
let binding = nyx_scanner::dynamic::framework::detect_binding(
|
||||
&summary,
|
||||
tree.root_node(),
|
||||
&bytes,
|
||||
lang,
|
||||
);
|
||||
let b = binding
|
||||
.unwrap_or_else(|| panic!("{lang:?} adapter must detect the header fixture"));
|
||||
assert_eq!(b.kind, EntryKind::Function);
|
||||
assert!(!b.adapter.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_language_for(lang: Lang) -> tree_sitter::Language {
|
||||
match lang {
|
||||
Lang::Java => tree_sitter::Language::from(tree_sitter_java::LANGUAGE),
|
||||
Lang::Python => tree_sitter::Language::from(tree_sitter_python::LANGUAGE),
|
||||
Lang::Php => tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP),
|
||||
Lang::Ruby => tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE),
|
||||
Lang::JavaScript => tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE),
|
||||
Lang::Go => tree_sitter::Language::from(tree_sitter_go::LANGUAGE),
|
||||
Lang::Rust => tree_sitter::Language::from(tree_sitter_rust::LANGUAGE),
|
||||
other => panic!("unsupported test lang {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn slug(lang: Lang) -> &'static str {
|
||||
match lang {
|
||||
Lang::Java => "java",
|
||||
Lang::Python => "python",
|
||||
Lang::Php => "php",
|
||||
Lang::Ruby => "ruby",
|
||||
Lang::JavaScript => "javascript",
|
||||
Lang::Go => "go",
|
||||
Lang::Rust => "rust",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue