diff --git a/src/dynamic/corpus/header_injection/js.rs b/src/dynamic/corpus/header_injection/js.rs index c7c1c952..6dcee517 100644 --- a/src/dynamic/corpus/header_injection/js.rs +++ b/src/dynamic/corpus/header_injection/js.rs @@ -53,4 +53,62 @@ pub const PAYLOADS: &[CuratedPayload] = &[ benign_control: None, no_benign_control_rationale: None, }, + // Phase 08 tier-(b): raw-socket wire-frame smuggling payload. + // Same CRLF-bearing bytes as the Node payload above, but pinned to + // the `js_raw` fixture (a `net.createServer` callback writing raw + // bytes via `socket.write`). The wire frame captured off the + // response socket carries two distinct `Set-Cookie:` lines, so + // `HeaderSmuggledInWire { primary: "Set-Cookie", smuggled: + // "Set-Cookie" }` fires — proving the smuggled header survived to + // the actual wire instead of being CRLF-stripped en route. + // + // Distinct payload (not just an extra predicate on the Node row) + // because Node's `http.ServerResponse#setHeader` validator strips + // CRLF at the wire-write boundary, so the wire-frame predicate + // would never fire against the canonical Node fixture. See + // `.pitboss/play/deferred.md` (Phase 08 wire-frame option A) for + // the framework-level CRLF-strip empirical from session-0018. + CuratedPayload { + bytes: b"nyx-session\r\nSet-Cookie: nyx-injected=pwn", + label: "header-injection-js-raw-wire-smuggle", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::HeaderSmuggledInWire { + primary: "Set-Cookie", + smuggled: "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_raw/vuln.js"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::HeaderSmuggledInWire { + primary: "Set-Cookie", + smuggled: "Set-Cookie", + }], + benign_control: Some(PayloadRef { + label: "header-injection-js-raw-benign", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"nyx-session%0D%0ASet-Cookie%3A%20nyx-injected%3Dpwn", + label: "header-injection-js-raw-benign", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::HeaderSmuggledInWire { + primary: "Set-Cookie", + smuggled: "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_raw/vuln.js"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, ]; diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 393e028d..fe25534f 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -1365,6 +1365,116 @@ pub fn emit_header_injection_harness(spec: &HarnessSpec) -> HarnessSource { || entry_source.contains("from \"http\"") || entry_source.contains("from 'express'") || entry_source.contains("from \"express\""); + // Phase 08 tier-(b): a fixture that uses `net.createServer` writes + // bytes straight to the response socket via `socket.write`, bypassing + // every framework-level CRLF validator (Node's + // `http.ServerResponse#setHeader` / Express / axum / Tomcat all + // strip CRLF before write). The harness boots the server on a + // loopback port and captures the raw response-header block as a + // `ProbeKind::HeaderWireFrame` probe. Mirrors the Python tier-(b) + // at `src/dynamic/lang/python.rs::emit_header_injection_harness`. + let uses_raw_socket = entry_source.contains("net.createServer") + || entry_source.contains("require('net')") + || entry_source.contains("require(\"net\")") + || entry_source.contains("from 'net'") + || entry_source.contains("from \"net\""); + + let wire_frame_via_fixture = if uses_raw_socket { + format!( + r#"async function nyxWireFrameViaFixture(payload) {{ + // Phase 08 tier-(b): boot the fixture's net.Server on 127.0.0.1:0, + // issue one raw-socket GET, read the bytes the handler wrote to the + // response socket up to the CRLF-CRLF boundary. Returns the captured + // header-block bytes on success, or `null` on import / boot failure so + // the caller can fall back to the inline synthetic probe. + const _net = require('net'); + let mod; + try {{ + mod = require('./{entry_stem}'); + }} catch (e) {{ + return null; + }} + if (!mod || typeof mod.createServer !== 'function' || typeof mod.setCookieValue !== 'function') {{ + return null; + }} + try {{ + if (Buffer.isBuffer(payload)) {{ + mod.setCookieValue(payload); + }} else {{ + mod.setCookieValue(Buffer.from(String(payload), 'utf8')); + }} + }} catch (e) {{ + return null; + }} + let server; + try {{ + server = mod.createServer(); + }} catch (e) {{ + return null; + }} + const listenPort = await new Promise((resolve) => {{ + server.once('error', () => resolve(null)); + server.listen(0, '127.0.0.1', () => {{ + const addr = server.address(); + resolve(addr && typeof addr === 'object' ? addr.port : null); + }}); + }}); + if (listenPort === null) {{ + try {{ server.close(); }} catch (e) {{}} + return null; + }} + let raw = Buffer.alloc(0); + await new Promise((resolve) => {{ + const client = _net.createConnection({{ host: '127.0.0.1', port: listenPort }}, () => {{ + try {{ + client.write('GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n'); + }} catch (e) {{}} + }}); + const timer = setTimeout(() => {{ + try {{ client.destroy(); }} catch (e) {{}} + resolve(); + }}, 5000); + client.on('data', (chunk) => {{ + raw = Buffer.concat([raw, chunk]); + if (raw.length >= 65536 || raw.indexOf('\r\n\r\n') !== -1) {{ + try {{ client.end(); }} catch (e) {{}} + }} + }}); + client.on('end', () => {{ clearTimeout(timer); resolve(); }}); + client.on('error', () => {{ clearTimeout(timer); resolve(); }}); + client.on('close', () => {{ clearTimeout(timer); resolve(); }}); + }}); + try {{ server.close(); }} catch (e) {{}} + const sep = raw.indexOf('\r\n\r\n'); + if (sep === -1) {{ + return raw; + }} + return raw.subarray(0, sep); +}} + +function nyxWireFrameProbe(rawBytes) {{ + const p = process.env.NYX_PROBE_PATH; + if (!p) return; + const rec = {{ + sink_callee: 'net.Server.socket.write', + args: [], + captured_at_ns: Number(process.hrtime.bigint()), + payload_id: process.env.NYX_PAYLOAD_ID || '', + kind: {{ kind: 'HeaderWireFrame', raw_bytes: Array.from(rawBytes) }}, + witness: __nyx_witness('net.Server.socket.write', []), + }}; + try {{ + require('fs').appendFileSync(p, JSON.stringify(rec) + '\n'); + }} catch (e) {{ + // best-effort + }} +}} + +"# + ) + } else { + String::new() + }; let via_fixture = if uses_node_writer { format!( @@ -1434,8 +1544,74 @@ pub fn emit_header_injection_harness(spec: &HarnessSpec) -> HarnessSource { "const name = 'Set-Cookie';\nconst value = payload;\nnyxHeaderProbe(name, value);\nconsole.log('__NYX_SINK_HIT__');\nconsole.log(JSON.stringify({ name: name, value: value }));\n" }; - let body = format!( - r#"// Nyx dynamic harness — HEADER_INJECTION http.ServerResponse#setHeader (Phase 08 / Track J.6). + // Phase 08 tier-(b): when the fixture imports `net.createServer`, run + // the wire-frame branch first (async IIFE awaits the loopback round + // trip). When it succeeds, emit a `HeaderWireFrame` probe plus a + // derived `HeaderEmit` per Set-Cookie line and exit. When it returns + // null (require/boot failure), fall through to the existing sync + // tier-(a) / synthetic path so the harness still produces some + // signal. + let body = if uses_raw_socket { + format!( + r#"// Nyx dynamic harness — HEADER_INJECTION net.Server raw-socket wire-frame (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, protocol: 'in-process' }}, + witness: __nyx_witness('http.ServerResponse#setHeader', [name, value]), + }}; + try {{ + require('fs').appendFileSync(p, JSON.stringify(rec) + '\n'); + }} catch (e) {{ + // best-effort + }} +}} + +{wire_frame_via_fixture}(async () => {{ + const payload = process.env.NYX_PAYLOAD || ''; + const rawBytes = await nyxWireFrameViaFixture(payload); + if (rawBytes !== null && rawBytes !== undefined) {{ + nyxWireFrameProbe(rawBytes); + // Also emit a HeaderEmit record per Set-Cookie line so the tier-(a) + // HeaderInjected predicate fires on the same payload that trips + // HeaderSmuggledInWire. The wire-frame branch is the source of + // truth; the HeaderEmit records are derived from the same captured + // bytes. + const headerText = rawBytes.toString('binary'); + for (const line of headerText.split('\r\n')) {{ + const sep = line.indexOf(': '); + if (sep < 0) continue; + const hname = line.slice(0, sep); + if (hname.toLowerCase() !== 'set-cookie') continue; + const hvalue = line.slice(sep + 2); + nyxHeaderProbe(hname, hvalue); + }} + console.log('__NYX_SINK_HIT__'); + console.log(JSON.stringify({{ wire_frame_len: rawBytes.length }})); + return; + }} + // Synthetic fallback — wire-frame branch did not produce bytes. + const name = 'Set-Cookie'; + const value = payload; + nyxHeaderProbe(name, value); + console.log('__NYX_SINK_HIT__'); + console.log(JSON.stringify({{ name: name, value: value }})); +}})(); +"# + ) + } else { + format!( + r#"// Nyx dynamic harness — HEADER_INJECTION http.ServerResponse#setHeader (Phase 08 / Track J.6). {shim} function nyxHeaderProbe(name, value) {{ @@ -1461,7 +1637,8 @@ function nyxHeaderProbe(name, value) {{ {via_fixture}const payload = process.env.NYX_PAYLOAD || ''; {invoke_via_fixture}"# - ); + ) + }; HarnessSource { source: body, filename: "harness.js".to_owned(), @@ -3028,6 +3205,93 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn emit_header_injection_harness_routes_through_wire_frame_when_net_create_server_imported() { + let dir = std::env::temp_dir().join("nyx_phase08_js_test_wire_frame"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.js"); + std::fs::write( + &entry, + "const net = require('net');\nlet cookieValue = Buffer.alloc(0);\nfunction setCookieValue(v) { cookieValue = Buffer.from(String(v)); }\nfunction createServer() { return net.createServer((s) => { s.write(Buffer.concat([Buffer.from('HTTP/1.0 200 OK\\r\\nSet-Cookie: '), cookieValue, Buffer.from('\\r\\n\\r\\nok')])); s.end(); }); }\nmodule.exports = { setCookieValue, createServer };\n", + ) + .unwrap(); + let h = emit_header_injection_harness(&make_header_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + h.source.contains("async function nyxWireFrameViaFixture(payload)"), + "tier-(b) harness must define the async wire-frame helper: {}", + h.source + ); + assert!( + h.source.contains("require('./vuln')"), + "tier-(b) harness must require the staged fixture: {}", + h.source + ); + assert!( + h.source.contains("mod.createServer()"), + "tier-(b) harness must boot the fixture's net.Server: {}", + h.source + ); + assert!( + h.source.contains("'GET / HTTP/1.0\\r\\nHost: 127.0.0.1\\r\\n\\r\\n'"), + "tier-(b) harness must issue a raw GET over the client socket: {}", + h.source + ); + assert!( + h.source + .contains("kind: 'HeaderWireFrame', raw_bytes: Array.from(rawBytes)"), + "tier-(b) harness must emit a HeaderWireFrame probe carrying the raw header-block bytes: {}", + h.source + ); + assert!( + h.source.contains("wire_frame_len: rawBytes.length"), + "tier-(b) harness must print the wire_frame_len stdout marker: {}", + h.source + ); + assert!( + h.source.contains("if (hname.toLowerCase() !== 'set-cookie')"), + "tier-(b) harness must derive a HeaderEmit probe per Set-Cookie line: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_header_injection_harness_drops_wire_frame_branch_when_only_http_required() { + let dir = std::env::temp_dir().join("nyx_phase08_js_test_no_wire_frame"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.js"); + std::fs::write( + &entry, + "const http = require('http');\nfunction run(res, value) { res.setHeader('Set-Cookie', value); }\nmodule.exports = { run };\n", + ) + .unwrap(); + let h = emit_header_injection_harness(&make_header_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + !h.source.contains("async function nyxWireFrameViaFixture"), + "http-only harness must not emit the wire-frame helper: {}", + h.source + ); + assert!( + !h.source.contains("HeaderWireFrame"), + "http-only harness must not emit the HeaderWireFrame probe shape: {}", + h.source + ); + assert!( + !h.source.contains("wire_frame_len"), + "http-only harness must not emit the wire_frame_len stdout marker: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn emit_header_injection_harness_derives_entry_stem_from_entry_file() { let dir = std::env::temp_dir().join("nyx_phase08_js_test_stem_derive"); diff --git a/tests/dynamic_fixtures/header_injection/js_raw/vuln.js b/tests/dynamic_fixtures/header_injection/js_raw/vuln.js new file mode 100644 index 00000000..60d1472b --- /dev/null +++ b/tests/dynamic_fixtures/header_injection/js_raw/vuln.js @@ -0,0 +1,50 @@ +// Phase 08 (Track J.6) — JavaScript raw-socket HEADER_INJECTION vuln fixture. +// +// Writes the response status line and headers directly to the wire via +// `socket.write`, bypassing the framework-level CRLF validator that +// Node's `http.ServerResponse#setHeader` / Express / axum / Tomcat +// would otherwise interpose. A payload carrying `\r\nSet-Cookie: ...` +// splits the single Set-Cookie header into two on the wire, producing +// the canonical smuggled-second-header shape that +// `ProbeKind::HeaderWireFrame` is designed to catch. +// +// The harness (`src/dynamic/lang/js_shared.rs::emit_header_injection_harness`) +// detects the `net.createServer` import in this file and routes +// through the tier-(b) wire-frame branch: boot a `net.Server` on a +// loopback port, issue one `GET /` over a raw socket, read the bytes +// the handler wrote to the response socket, and emit them as a +// `ProbeKind::HeaderWireFrame` record. +const net = require('net'); + +// Set by the harness before each request. Bytes go straight onto +// the wire with no encoding pass. +let cookieValue = Buffer.alloc(0); + +function setCookieValue(value) { + if (Buffer.isBuffer(value)) { + cookieValue = value; + } else { + cookieValue = Buffer.from(String(value), 'utf8'); + } +} + +function createServer() { + return net.createServer((socket) => { + socket.once('data', () => { + const body = Buffer.from('ok\n'); + const head = Buffer.concat([ + Buffer.from('HTTP/1.0 200 OK\r\n'), + Buffer.from('Content-Length: ' + body.length + '\r\n'), + Buffer.from('Set-Cookie: '), + cookieValue, + Buffer.from('\r\n'), + Buffer.from('\r\n'), + ]); + socket.write(Buffer.concat([head, body])); + socket.end(); + }); + socket.on('error', () => {}); + }); +} + +module.exports = { setCookieValue, createServer }; diff --git a/tests/header_injection_corpus.rs b/tests/header_injection_corpus.rs index 46676a60..6cd560fd 100644 --- a/tests/header_injection_corpus.rs +++ b/tests/header_injection_corpus.rs @@ -726,6 +726,88 @@ mod e2e_phase_08 { (spec, tmp) } + // Phase 08 tier-(b): JavaScript raw-socket wire-frame fixture. + // `tests/dynamic_fixtures/header_injection/js_raw/vuln.js` boots a + // `net.Server` whose callback writes raw bytes via `socket.write`, + // bypassing Node's `http.ServerResponse#setHeader` CRLF strip. The + // harness boots the server on a loopback port, reads the response- + // header block off the socket, and emits a + // `ProbeKind::HeaderWireFrame` record. Asserts the test exercises + // the wire-frame branch (not the synthetic fallback) by pinning + // `wire_frame_len` in the captured stdout — that literal only + // appears in the tier-(b) write path. + fn build_js_raw_spec(entry_name: &str) -> (HarnessSpec, TempDir) { + let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/header_injection/js_raw/vuln.js"); + let tmp = TempDir::new().expect("create tempdir"); + let dst = tmp.path().join("vuln.js"); + std::fs::copy(&fixture_src, &dst).expect("copy js_raw fixture into tempdir"); + let entry_file = dst.to_string_lossy().into_owned(); + let mut digest = blake3::Hasher::new(); + digest.update(b"phase08-e2e-header-injection|js_raw|vuln.js"); + let spec_hash = format!("{:016x}", { + let bytes = digest.finalize(); + u64::from_le_bytes(bytes.as_bytes()[..8].try_into().unwrap()) + }); + let spec = HarnessSpec { + finding_id: spec_hash.clone(), + entry_file: entry_file.clone(), + entry_name: entry_name.to_owned(), + entry_kind: EntryKind::Function, + lang: Lang::JavaScript, + toolchain_id: default_toolchain_id(Lang::JavaScript).into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::HEADER_INJECTION, + constraint_hints: vec![], + sink_file: entry_file, + sink_line: 1, + spec_hash: spec_hash.clone(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), + }; + (spec, tmp) + } + + #[test] + fn js_raw_socket_vuln_confirms_via_wire_frame_probe() { + if !command_available("node") { + eprintln!("SKIP js_raw: missing node"); + return; + } + let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (spec, _tmp) = build_js_raw_spec("run"); + let opts = SandboxOptions { + backend: SandboxBackend::Process, + ..SandboxOptions::default() + }; + let outcome = match run_spec(&spec, &opts) { + Ok(outcome) => outcome, + Err(RunError::BuildFailed { stderr, attempts }) => { + eprintln!( + "SKIP js_raw: harness build failed after {attempts} attempts: {stderr}", + ); + return; + } + Err(e) => panic!("run_spec(js_raw) errored: {e:?}"), + }; + assert_confirmed(Lang::JavaScript, &outcome); + let any_wire_frame_marker = outcome.attempts.iter().any(|a| { + String::from_utf8_lossy(&a.outcome.stdout).contains("wire_frame_len") + }); + assert!( + any_wire_frame_marker, + "js_raw fixture must exercise the tier-(b) wire-frame harness branch; \ + expected `wire_frame_len` substring in at least one attempt's stdout, got attempts={:?}", + outcome + .attempts + .iter() + .map(|a| String::from_utf8_lossy(&a.outcome.stdout).into_owned()) + .collect::>(), + ); + } + #[test] fn python_raw_socket_vuln_confirms_via_wire_frame_probe() { if !command_available("python3") {