diff --git a/src/dynamic/framework/adapters/redirect_php.rs b/src/dynamic/framework/adapters/redirect_php.rs index 7cbec17e..ffb88aa8 100644 --- a/src/dynamic/framework/adapters/redirect_php.rs +++ b/src/dynamic/framework/adapters/redirect_php.rs @@ -16,13 +16,13 @@ pub struct RedirectPhpAdapter; const ADAPTER_NAME: &str = "redirect-php"; -fn callee_is_redirect(name: &str) -> bool { +fn callee_last_segment(name: &str) -> &str { let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name); - let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last); - matches!( - last, - "redirect" | "withRedirect" | "RedirectResponse" | "header" - ) + last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last) +} + +fn file_contains_location_header_token(file_bytes: &[u8]) -> bool { + file_bytes.windows(9).any(|w| w == b"Location:") } fn source_imports_php_web(file_bytes: &[u8]) -> bool { @@ -72,7 +72,14 @@ impl FrameworkAdapter for RedirectPhpAdapter { if url_routed_through_validator(file_bytes) { return None; } - let matches_call = super::any_callee_matches(summary, callee_is_redirect); + let has_location_token = file_contains_location_header_token(file_bytes); + let matches_call = super::any_callee_matches(summary, |name| { + match callee_last_segment(name) { + "redirect" | "withRedirect" | "RedirectResponse" => true, + "header" => has_location_token, + _ => false, + } + }); let matches_source = source_imports_php_web(file_bytes); if matches_call && matches_source { Some(FrameworkBinding { @@ -128,6 +135,25 @@ mod tests { .is_none()); } + #[test] + fn skips_when_header_call_lacks_location_token() { + // Symfony import present, but `header("Content-Type: text/html")` + // is not a redirect. No `Location:` substring means the + // `header` callee no longer fires the redirect adapter. + let src: &[u8] = b" bool { +fn callee_last_segment(name: &str) -> &str { let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name); - let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last); - matches!(last, "to" | "redirect" | "temporary" | "permanent" | "Found") + last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last) +} + +fn receiver_looks_like_redirect(recv: &str) -> bool { + // Real CFG-derived method calls populate receiver text; accept only + // when the receiver visibly references a Redirect-shaped type + // (`Redirect`, `axum::response::Redirect`, `HttpResponse::Found`). + // None-receiver callees (synthetic test fixtures, free functions) + // are handled by `any_callee_matches_with_receiver` itself and pass + // through without consulting this predicate. + recv.contains("Redirect") || recv.contains("Found") } fn source_imports_rust_web(file_bytes: &[u8]) -> bool { @@ -72,7 +81,16 @@ impl FrameworkAdapter for RedirectRustAdapter { if url_routed_through_validator(file_bytes) { return None; } - let matches_call = super::any_callee_matches(summary, callee_is_redirect); + let matches_call = super::any_callee_matches_with_receiver( + summary, + |name| { + matches!( + callee_last_segment(name), + "to" | "redirect" | "temporary" | "permanent" | "Found" + ) + }, + receiver_looks_like_redirect, + ); let matches_source = source_imports_rust_web(file_bytes); if matches_call && matches_source { Some(FrameworkBinding { @@ -128,6 +146,54 @@ mod tests { .is_none()); } + #[test] + fn skips_to_call_with_non_redirect_receiver() { + // Axum import + a chain that calls `.to(...)` on a non-Redirect + // value (e.g. `String::to_owned` collisions surface as + // `.to(...)` on a `Cow` receiver). Receiver text on the + // CalleeSite carries `Cow`, not `Redirect`, so the adapter must + // skip. + let src: &[u8] = b"use axum::response::Redirect;\n\ + use std::borrow::Cow;\n\n\ + fn run(v: Cow) -> String { v.to(&\"target\".to_owned()) }\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite { + name: "to".into(), + receiver: Some("v".into()), + ..Default::default() + }], + ..Default::default() + }; + assert!(RedirectRustAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } + + #[test] + fn fires_on_redirect_receiver_text() { + // Real CFG-derived receiver carries the type identifier; accept + // when receiver text contains `Redirect` (e.g. `Redirect::to(v)` + // resolves to a `Redirect`-prefixed root receiver after the + // `root_member_receiver` drill-down). + let src: &[u8] = b"use axum::response::Redirect;\n\ + fn run(v: String) -> Redirect { Redirect::to(&v) }\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "run".into(), + callees: vec![crate::summary::CalleeSite { + name: "to".into(), + receiver: Some("Redirect".into()), + ..Default::default() + }], + ..Default::default() + }; + assert!(RedirectRustAdapter + .detect(&summary, tree.root_node(), src) + .is_some()); + } + #[test] fn skips_when_url_validated_against_allowlist() { let src: &[u8] = b"use axum::response::Redirect;\n\ diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 75ecdec7..c3573dc3 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -1730,21 +1730,34 @@ if (_kind === 'query') {{ _req.params[_payload_key] = payload; }} let _captured = ''; +let _resolveResponded; +const _responded = new Promise(function (r) {{ _resolveResponded = r; }}); +const _markResponded = function () {{ + if (_resolveResponded) {{ + const _r = _resolveResponded; + _resolveResponded = null; + _r(); + }} +}}; const _res = {{ statusCode: 200, headers: {{}}, status: function (c) {{ this.statusCode = c; return this; }}, set: function (k, v) {{ this.headers[k] = v; return this; }}, setHeader: function (k, v) {{ this.headers[k] = v; }}, - send: function (b) {{ _captured += String(b == null ? '' : b); return this; }}, - end: function (b) {{ if (b != null) _captured += String(b); return this; }}, - json: function (o) {{ _captured += JSON.stringify(o); return this; }}, + send: function (b) {{ _captured += String(b == null ? '' : b); _markResponded(); return this; }}, + end: function (b) {{ if (b != null) _captured += String(b); _markResponded(); return this; }}, + json: function (o) {{ _captured += JSON.stringify(o); _markResponded(); return this; }}, write: function (b) {{ _captured += String(b == null ? '' : b); return this; }}, }}; (async () => {{ try {{ const _result = _handler(_req, _res, function () {{}}); if (_result && typeof _result.then === 'function') await _result; + // Handlers that finish via an async callback (e.g. child_process.exec) + // populate _captured after the handler return. Wait up to 3s for a + // res.send / res.end / res.json call before flushing stdout. + await Promise.race([_responded, new Promise(function (r) {{ setTimeout(r, 3000); }})]); process.stdout.write(_captured + '\n'); }} catch (e) {{ process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); @@ -1766,16 +1779,31 @@ if (typeof _mw !== 'function') {{ }} const _kind = {body_kind:?}; const _payload_key = {payload_key:?}; +let _resolveResponded; +const _responded = new Promise(function (r) {{ _resolveResponded = r; }}); +const _markResponded = function () {{ + if (_resolveResponded) {{ + const _r = _resolveResponded; + _resolveResponded = null; + _r(); + }} +}}; const _ctx = {{ method: {method:?}, query: {{}}, request: {{ body: {{}}, query: {{}}, header: {{}} }}, params: {{}}, headers: {{}}, - body: '', + _body: '', status: 200, set: function (k, v) {{ this.headers[k] = v; }}, }}; +Object.defineProperty(_ctx, 'body', {{ + get: function () {{ return this._body; }}, + set: function (v) {{ this._body = v; _markResponded(); }}, + enumerable: true, + configurable: true, +}}); if (_kind === 'query') {{ _ctx.query[_payload_key] = payload; _ctx.request.query[_payload_key] = payload; @@ -1789,6 +1817,9 @@ if (_kind === 'query') {{ (async () => {{ try {{ await _mw(_ctx, async function () {{}}); + // Wait up to 3s for an async ctx.body assignment (e.g. from a + // child_process.exec callback) before flushing stdout. + await Promise.race([_responded, new Promise(function (r) {{ setTimeout(r, 3000); }})]); process.stdout.write(String(_ctx.body == null ? '' : _ctx.body) + '\n'); }} catch (e) {{ process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); @@ -1825,20 +1856,33 @@ if (_kind === 'query') {{ process.env[_payload_key] = payload; }} let _captured = ''; +let _resolveResponded; +const _responded = new Promise(function (r) {{ _resolveResponded = r; }}); +const _markResponded = function () {{ + if (_resolveResponded) {{ + const _r = _resolveResponded; + _resolveResponded = null; + _r(); + }} +}}; const _res = {{ statusCode: 200, headers: {{}}, status: function (c) {{ this.statusCode = c; return this; }}, setHeader: function (k, v) {{ this.headers[k] = v; }}, - send: function (b) {{ _captured += String(b == null ? '' : b); return this; }}, - end: function (b) {{ if (b != null) _captured += String(b); return this; }}, - json: function (o) {{ _captured += JSON.stringify(o); return this; }}, + send: function (b) {{ _captured += String(b == null ? '' : b); _markResponded(); return this; }}, + end: function (b) {{ if (b != null) _captured += String(b); _markResponded(); return this; }}, + json: function (o) {{ _captured += JSON.stringify(o); _markResponded(); return this; }}, write: function (b) {{ _captured += String(b == null ? '' : b); return this; }}, }}; (async () => {{ try {{ const _result = _handler(_req, _res); if (_result && typeof _result.then === 'function') await _result; + // Handlers that finish via an async callback (e.g. child_process.exec) + // populate _captured after the handler return. Wait up to 3s for a + // res.send / res.end / res.json call before flushing stdout. + await Promise.race([_responded, new Promise(function (r) {{ setTimeout(r, 3000); }})]); process.stdout.write(_captured + '\n'); }} catch (e) {{ process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n');