diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index f4a725d4..06e72ddd 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -1221,10 +1221,20 @@ console.log(JSON.stringify({{ render: rendered }})); /// staged document, and writes a `ProbeKind::Xpath { nodes_returned }` /// probe whose `n` is the count returned. Mirrors the synthetic- /// harness pattern used by Phase 03 / 04 / 05 / 06. -pub fn emit_xpath_harness(_spec: &HarnessSpec) -> HarnessSource { +pub fn emit_xpath_harness(spec: &HarnessSpec) -> HarnessSource { let shim = probe_shim(); let corpus_filename = crate::dynamic::stubs::xpath_document::XPATH_CORPUS_FILENAME; let corpus_xml = crate::dynamic::stubs::xpath_document::XPATH_CORPUS_XML; + let entry_source = read_entry_source(&spec.entry_file); + let entry_stem = derive_js_entry_stem(&spec.entry_file); + let entry_name = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; + let uses_real_xpath = entry_source.contains("require('xpath')") + || entry_source.contains("require(\"xpath\")"); + let body = format!( r#"// Nyx dynamic harness — XPATH_INJECTION xpath.select (Phase 07 / Track J.5). {shim} @@ -1263,6 +1273,34 @@ function nyxXpathSelect(expr) {{ return NYX_XPATH_USERS.length; }} +function nyxXpathViaFixture(payload) {{ + // Phase 07 tier-(a): require the fixture and call its + // `{entry_name}` so the real `xpath.select` (or other XPath evaluator + // the fixture chooses) runs against the staged corpus document. + // Returns the node count, or `null` when the require / lookup / call + // fails (e.g. the `xpath` npm package is not installed on the host) + // so the caller can fall back to the inline matcher. + let _entry; + try {{ + _entry = require('./{entry_stem}'); + }} catch (e) {{ + return null; + }} + const fn = _entry && (typeof _entry === 'function' ? _entry : _entry['{entry_name}']); + if (typeof fn !== 'function') return null; + let result; + try {{ + result = fn(payload); + }} catch (e) {{ + // Malformed XPath / parse error / etc. — treat as 0-node return + // so a benign fixture that rejects the payload stays NotConfirmed. + return 0; + }} + if (result == null) return 0; + if (typeof result.length === 'number') return result.length; + return 0; +}} + function nyxXpathProbe(expr, nodesReturned) {{ const p = process.env.NYX_PROBE_PATH; if (!p) return; @@ -1283,13 +1321,23 @@ function nyxXpathProbe(expr, nodesReturned) {{ const payload = process.env.NYX_PAYLOAD || ''; const expr = "//user[@name='" + payload + "']"; -const nodes = nyxXpathSelect(expr); +let nodes = nyxXpathViaFixture(payload); +if (nodes === null) {{ + nodes = nyxXpathSelect(expr); +}} nyxXpathProbe(expr, nodes); console.log('__NYX_SINK_HIT__'); console.log(JSON.stringify({{ expr: expr, nodes_returned: nodes }})); "# ); - let extra_files = vec![(corpus_filename.to_owned(), corpus_xml.to_owned())]; + let mut extra_files = vec![(corpus_filename.to_owned(), corpus_xml.to_owned())]; + if uses_real_xpath { + extra_files.push(("package.json".to_owned(), package_json_xpath())); + extra_files.push(( + "package-lock.json".to_owned(), + package_lock_skeleton("nyx-harness-xpath"), + )); + } HarnessSource { source: body, filename: "harness.js".to_owned(), @@ -1299,6 +1347,25 @@ console.log(JSON.stringify({{ expr: expr, nodes_returned: nodes }})); } } +/// Map an entry file path like `tests/.../vuln.js` to the basename +/// (without extension) the harness will `require('./')`. Falls +/// back to `"vuln"` so the harness still attempts a require when the +/// path is unusable (the inline matcher fires when the require fails). +fn derive_js_entry_stem(entry_file: &str) -> String { + PathBuf::from(entry_file) + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_owned()) + .unwrap_or_else(|| "vuln".to_owned()) +} + +/// `package.json` bundling `xpath` + `@xmldom/xmldom` so the JS XPath +/// fixtures can `require('xpath')` / `require('@xmldom/xmldom')` from +/// the harness workdir. Mirrors `package_json_for` but pins two deps. +fn package_json_xpath() -> String { + "{\n \"name\": \"nyx-harness-xpath\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"dependencies\": {\n \"xpath\": \"^0.0.34\",\n \"@xmldom/xmldom\": \"^0.8.10\"\n }\n}\n".to_owned() +} + /// Phase 08 — Track J.6 header-injection harness for Node /// (`http.ServerResponse#setHeader`). /// @@ -2561,4 +2628,117 @@ mod tests { "stub recorder must read NYX_HTTP_LOG" ); } + + fn make_xpath_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(EntryKind::Function, entry_name, PayloadSlot::Param(0)); + spec.expected_cap = Cap::XPATH_INJECTION; + spec.entry_file = entry_file.into(); + spec.entry_name = entry_name.into(); + spec + } + + #[test] + fn emit_xpath_harness_drives_fixture_through_real_xpath_when_imported() { + let dir = std::env::temp_dir().join("nyx_phase07_js_test_drive_fixture"); + 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 xpath = require('xpath');\n\ + function run(name) { return []; }\n\ + module.exports = { run };\n", + ) + .unwrap(); + let h = emit_xpath_harness(&make_xpath_spec(entry.to_str().unwrap(), "run")); + assert!( + h.source.contains("function nyxXpathViaFixture(payload)"), + "tier-(a) harness must define nyxXpathViaFixture: {}", + h.source + ); + assert!( + h.source.contains("require('./vuln')"), + "tier-(a) harness must require the staged fixture: {}", + h.source + ); + assert!( + h.source.contains("_entry['run']"), + "tier-(a) harness must look up the named entry function: {}", + h.source + ); + assert!( + h.source.contains("if (typeof result.length === 'number') return result.length;"), + "tier-(a) harness must count nodes via the returned array's .length: {}", + h.source + ); + assert!( + h.source.contains("nodes = nyxXpathSelect(expr);"), + "tier-(a) harness must preserve the inline matcher as a fallback: {}", + h.source + ); + assert!( + h.extra_files + .iter() + .any(|(p, c)| p == "package.json" && c.contains("\"xpath\"")), + "tier-(a) harness must stage a package.json with the xpath dep", + ); + assert!( + h.extra_files + .iter() + .any(|(p, c)| p == "package.json" && c.contains("@xmldom/xmldom")), + "tier-(a) harness must stage a package.json with the xmldom dep", + ); + assert!( + h.extra_files + .iter() + .any(|(p, _)| p == "package-lock.json"), + "tier-(a) harness must stage a package-lock.json", + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_xpath_harness_falls_back_to_inline_matcher_without_xpath_require() { + let dir = std::env::temp_dir().join("nyx_phase07_js_test_no_xpath_require"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.js"); + std::fs::write( + &entry, + "function run(name) { return []; }\nmodule.exports = { run };\n", + ) + .unwrap(); + let h = emit_xpath_harness(&make_xpath_spec(entry.to_str().unwrap(), "run")); + assert!( + !h.extra_files.iter().any(|(p, _)| p == "package.json"), + "fallback path must not stage a package.json (xpath dep would be unused)", + ); + assert!( + !h.extra_files + .iter() + .any(|(p, _)| p == "package-lock.json"), + "fallback path must not stage a package-lock.json", + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_xpath_harness_derives_entry_stem_from_entry_file() { + let dir = std::env::temp_dir().join("nyx_phase07_js_test_stem_derive"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("benign.js"); + std::fs::write( + &entry, + "const xpath = require('xpath');\nfunction run(name) { return []; }\nmodule.exports = { run };\n", + ) + .unwrap(); + let h = emit_xpath_harness(&make_xpath_spec(entry.to_str().unwrap(), "run")); + assert!( + h.source.contains("require('./benign')"), + "harness must require the staged fixture by its file_stem: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 0dea1f1e..34b76b18 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -1889,12 +1889,93 @@ fn derive_module_name(entry_file: &str) -> String { /// 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 { +pub fn emit_header_injection_harness(spec: &HarnessSpec) -> HarnessSource { let probe = probe_shim(); + let entry_source = read_entry_source(&spec.entry_file); + let module_name = derive_module_name(&spec.entry_file); + let entry_name = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; + let uses_flask = entry_source.contains("from flask") + || entry_source.contains("import flask") + || entry_source.contains("werkzeug.wrappers"); + let via_fixture = if uses_flask { + format!( + r#"def _nyx_header_via_fixture(payload): + # Phase 08 tier-(a): import the fixture, monkey-patch + # `werkzeug.datastructures.Headers.__setitem__` to capture every + # name/value pair the fixture writes *before* werkzeug's strict + # CRLF validator runs. This mirrors the Java permissive servlet + # stub at `src/dynamic/lang/java_servlet_stubs.rs::http_servlet_response`, + # so a vuln payload with raw `\r\n` is recorded verbatim and a + # benign control whose bytes are URL-encoded is recorded + # URL-encoded. Returns `None` when werkzeug is missing or the + # fixture cannot be imported so the caller can fall back to the + # inline synthetic probe. + try: + import werkzeug.datastructures as _wzd + except Exception: + return None + captured = [] + _orig_setitem = _wzd.Headers.__setitem__ + def _nyx_setitem(self, key, value): + try: + captured.append((str(key), str(value))) + except Exception: + pass + try: + _orig_setitem(self, key, value) + except Exception: + # werkzeug>=2.x rejects CRLF in header values. Swallow + # the validator's exception so the captor still records + # the would-have-been-written bytes. + pass + _wzd.Headers.__setitem__ = _nyx_setitem + sys.path.insert(0, ".") + try: + try: + mod = importlib.import_module("{module_name}") + except Exception: + return None + fn = getattr(mod, "{entry_name}", None) + if fn is None: + return None + try: + fn(payload) + except Exception: + # Fixture itself raised (validator path, missing dep, etc.) + # — return whatever the captor recorded before the throw. + pass + return captured + finally: + _wzd.Headers.__setitem__ = _orig_setitem + + +"# + ) + } else { + String::new() + }; + let invoke_via_fixture = if uses_flask { + r#" captured = _nyx_header_via_fixture(payload) + if captured: + for name, value in captured: + _nyx_header_probe(name, value) + print("__NYX_SINK_HIT__", flush=True) + sys.stdout.write(json.dumps({"headers": [list(p) for p in captured]}) + "\n") + sys.stdout.flush() + return +"# + } else { + "" + }; + let importlib_import = if uses_flask { "import importlib\n" } else { "" }; let body = format!( r#"#!/usr/bin/env python3 """Nyx dynamic harness — HEADER_INJECTION flask.Response.headers.__setitem__ (Phase 08 / Track J.6).""" -import json +{importlib_import}import json import os import sys import time @@ -1917,9 +1998,9 @@ def _nyx_header_probe(name, value): __nyx_emit(rec) -def _nyx_run(): +{via_fixture}def _nyx_run(): payload = os.environ.get("NYX_PAYLOAD", "") - # Synthetic instrumented setter — mirrors +{invoke_via_fixture} # Synthetic fallback — 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. @@ -1954,12 +2035,74 @@ if __name__ == "__main__": /// [`crate::dynamic::oracle::ProbePredicate::RedirectHostNotIn`] /// oracle; the paired benign control redirects to a same-origin /// path and leaves the predicate clear. -pub fn emit_open_redirect_harness(_spec: &HarnessSpec) -> HarnessSource { +pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { let probe = probe_shim(); + let entry_source = read_entry_source(&spec.entry_file); + let module_name = derive_module_name(&spec.entry_file); + let entry_name = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; + let uses_flask = + entry_source.contains("from flask") || entry_source.contains("import flask"); + let via_fixture = if uses_flask { + format!( + r#"def _nyx_redirect_via_fixture(payload): + # Phase 09 tier-(a): import the fixture, call its `{entry_name}` so + # the real `flask.redirect` runs, then read the bound `Location:` + # header off the returned response. Returns `(location, request_host)` + # on success, or `None` when the import / call fails so the caller + # can fall back to the inline synthetic probe. + sys.path.insert(0, ".") + try: + mod = importlib.import_module("{module_name}") + except Exception: + return None + fn = getattr(mod, "{entry_name}", None) + if fn is None: + return None + try: + response = fn(payload) + except Exception: + # Fixture raised (validator path, missing dep, etc.) — drop + # tier-(a) and let the caller fall back. + return None + try: + location = response.headers.get("Location", "") + except Exception: + return None + if not isinstance(location, str): + try: + location = str(location) + except Exception: + return None + return (location, "example.com") + + +"# + ) + } else { + String::new() + }; + let invoke_via_fixture = if uses_flask { + r#" captured = _nyx_redirect_via_fixture(payload) + if captured is not None: + location, request_host = captured + _nyx_redirect_probe(location, request_host) + print("__NYX_SINK_HIT__", flush=True) + sys.stdout.write(json.dumps({"location": location, "request_host": request_host}) + "\n") + sys.stdout.flush() + return +"# + } else { + "" + }; + let importlib_import = if uses_flask { "import importlib\n" } else { "" }; let body = format!( r#"#!/usr/bin/env python3 """Nyx dynamic harness — OPEN_REDIRECT flask.redirect (Phase 09 / Track J.7).""" -import json +{importlib_import}import json import os import sys import time @@ -1985,9 +2128,9 @@ def _nyx_redirect_probe(location, request_host): __nyx_emit(rec) -def _nyx_run(): +{via_fixture}def _nyx_run(): payload = os.environ.get("NYX_PAYLOAD", "") - request_host = "example.com" +{invoke_via_fixture} request_host = "example.com" location = payload _nyx_redirect_probe(location, request_host) print("__NYX_SINK_HIT__", flush=True) @@ -3049,4 +3192,209 @@ mod tests { "module name must come from the entry-file stem, not a hard-coded literal", ); } + + fn make_header_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::HEADER_INJECTION; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_header_injection_harness_routes_through_fixture_when_flask_imported() { + let dir = std::env::temp_dir().join("nyx_phase08_py_test_drive_fixture"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.py"); + std::fs::write( + &entry, + "from flask import Response\n\ + def run(value):\n response = Response('ok')\n response.headers['Set-Cookie'] = value\n return response\n", + ) + .unwrap(); + let h = emit_header_injection_harness(&make_header_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + h.source.contains("def _nyx_header_via_fixture(payload):"), + "tier-(a) harness must define the fixture-routing helper: {}", + h.source + ); + assert!( + h.source.contains("import werkzeug.datastructures"), + "tier-(a) harness must monkey-patch werkzeug Headers: {}", + h.source + ); + assert!( + h.source.contains("_wzd.Headers.__setitem__ = _nyx_setitem"), + "tier-(a) harness must install the permissive captor: {}", + h.source + ); + assert!( + h.source.contains("importlib.import_module(\"vuln\")"), + "tier-(a) harness must import the fixture by its file stem: {}", + h.source + ); + assert!( + h.source.contains("getattr(mod, \"run\", None)"), + "tier-(a) harness must look up the named entry function: {}", + h.source + ); + assert!( + h.source.contains("captured = _nyx_header_via_fixture(payload)"), + "harness main must call the fixture-routing helper first: {}", + h.source + ); + assert!( + h.source + .contains("_nyx_header_probe(\"Set-Cookie\", payload)") + || h.source.contains("value = payload\n _nyx_header_probe(name, value)"), + "fallback path must still emit a synthetic probe: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_header_injection_harness_falls_back_when_flask_not_imported() { + let dir = std::env::temp_dir().join("nyx_phase08_py_test_no_flask"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.py"); + std::fs::write( + &entry, + "def run(value):\n return value\n", + ) + .unwrap(); + let h = emit_header_injection_harness(&make_header_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + !h.source.contains("import werkzeug.datastructures"), + "fallback path must not import werkzeug: {}", + h.source + ); + assert!( + !h.source.contains("def _nyx_header_via_fixture"), + "fallback path must not define the fixture-routing helper: {}", + h.source + ); + assert!( + h.source.contains("value = payload\n _nyx_header_probe(name, value)"), + "fallback path must keep the synthetic probe: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_header_injection_harness_derives_module_name_from_entry_file() { + let dir = std::env::temp_dir().join("nyx_phase08_py_test_module_derive"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("benign.py"); + std::fs::write( + &entry, + "from flask import Response\n\ + def run(v):\n return Response('ok')\n", + ) + .unwrap(); + let h = emit_header_injection_harness(&make_header_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + h.source.contains("importlib.import_module(\"benign\")"), + "module name must come from the entry-file stem: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } + + fn make_redirect_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::OPEN_REDIRECT; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_open_redirect_harness_routes_through_fixture_when_flask_imported() { + let dir = std::env::temp_dir().join("nyx_phase09_py_test_drive_fixture"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.py"); + std::fs::write( + &entry, + "from flask import redirect\ndef run(value):\n return redirect(value)\n", + ) + .unwrap(); + let h = emit_open_redirect_harness(&make_redirect_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + h.source.contains("def _nyx_redirect_via_fixture(payload):"), + "tier-(a) harness must define the fixture-routing helper: {}", + h.source + ); + assert!( + h.source.contains("importlib.import_module(\"vuln\")"), + "tier-(a) harness must import the fixture by its file stem: {}", + h.source + ); + assert!( + h.source.contains("response.headers.get(\"Location\", \"\")"), + "tier-(a) harness must read the Location header off the returned response: {}", + h.source + ); + assert!( + h.source.contains("captured = _nyx_redirect_via_fixture(payload)"), + "harness main must call the fixture-routing helper first: {}", + h.source + ); + assert!( + h.source.contains("location = payload\n _nyx_redirect_probe(location, request_host)"), + "fallback path must keep the synthetic probe: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn emit_open_redirect_harness_falls_back_when_flask_not_imported() { + let dir = std::env::temp_dir().join("nyx_phase09_py_test_no_flask"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let entry = dir.join("vuln.py"); + std::fs::write( + &entry, + "def run(value):\n return value\n", + ) + .unwrap(); + let h = emit_open_redirect_harness(&make_redirect_spec( + entry.to_str().unwrap(), + "run", + )); + assert!( + !h.source.contains("def _nyx_redirect_via_fixture"), + "fallback path must not define the fixture-routing helper: {}", + h.source + ); + assert!( + !h.source.contains("importlib.import_module"), + "fallback path must not import the fixture: {}", + h.source + ); + assert!( + h.source.contains("location = payload\n _nyx_redirect_probe(location, request_host)"), + "fallback path must keep the synthetic probe: {}", + h.source + ); + let _ = std::fs::remove_dir_all(&dir); + } }