feat(tests): support partial confirmations with synthetic-fallback handling in header injection and open redirect scenarios

This commit is contained in:
elipeter 2026-06-02 21:25:00 -05:00
parent 4c824ed543
commit c29cf69d42
5 changed files with 80 additions and 41 deletions

View file

@ -2548,9 +2548,9 @@ pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource {
};
let invoke_via_fixture = if uses_node_writer {
"const captured = nyxRedirectViaFixture(payload);\nif (Array.isArray(captured)) {\n const [location, requestHost] = captured;\n nyxRedirectProbe(location, requestHost);\n nyxFollowLocation(location);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n} else {\n // Synthetic fallback — fixture import / call failed.\n const requestHost = 'example.com';\n const location = payload;\n nyxRedirectProbe(location, requestHost);\n nyxFollowLocation(location);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n}\n"
"const captured = nyxRedirectViaFixture(payload);\nif (Array.isArray(captured)) {\n const [location, requestHost] = captured;\n nyxRedirectProbe(location, requestHost);\n nyxFollowLocation(location);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n} else {\n // Synthetic fallback — fixture import / call failed. The real redirect\n // surface (host allowlist / path guard) never ran, so the synthetic marker\n // routes the runner to PartiallyConfirmed instead of an OOB self-confirm.\n const requestHost = 'example.com';\n const location = payload;\n nyxRedirectProbe(location, requestHost);\n nyxFollowLocation(location);\n console.log('__NYX_SINK_HIT__');\n console.log('__NYX_SYNTHETIC_FALLBACK__');\n console.log(JSON.stringify({ location: location, request_host: requestHost }));\n}\n"
} else {
"const requestHost = 'example.com';\nconst location = payload;\nnyxRedirectProbe(location, requestHost);\nnyxFollowLocation(location);\nconsole.log('__NYX_SINK_HIT__');\nconsole.log(JSON.stringify({ location: location, request_host: requestHost }));\n"
"const requestHost = 'example.com';\nconst location = payload;\nnyxRedirectProbe(location, requestHost);\nnyxFollowLocation(location);\nconsole.log('__NYX_SINK_HIT__');\nconsole.log('__NYX_SYNTHETIC_FALLBACK__');\nconsole.log(JSON.stringify({ location: location, request_host: requestHost }));\n"
};
let body = format!(
@ -2784,6 +2784,11 @@ const payload = process.env.NYX_PAYLOAD || '';
// lodash.merge can throw on weird inputs; the canary observation
// already wrote any probe before the throw.
}
// This block drove the sink DIRECTLY, bypassing any caller-side mitigation
// (the fixture's own entry could not be driven). A canary observation here
// therefore does not prove the guarded code is exploitable, so the runner
// must downgrade to PartiallyConfirmed rather than Confirm.
console.log('__NYX_SYNTHETIC_FALLBACK__');
"#;
let tail = r#"console.log('__NYX_SINK_HIT__');

View file

@ -3456,6 +3456,9 @@ def _nyx_header_probe(name, value):
value = payload
_nyx_header_probe(name, value)
print("__NYX_SINK_HIT__", flush=True)
# Synthetic sink: the real header surface (and its guards) never ran, so
# the runner downgrades this to PartiallyConfirmed rather than Confirm.
print("__NYX_SYNTHETIC_FALLBACK__", flush=True)
sys.stdout.write(json.dumps({{"name": name, "value": value}}) + "\n")
sys.stdout.flush()
@ -3607,6 +3610,10 @@ def _nyx_follow_location(location):
_nyx_redirect_probe(location, request_host)
_nyx_follow_location(location)
print("__NYX_SINK_HIT__", flush=True)
# Synthetic sink: the real redirect surface (host allowlist / path guard)
# never ran, so the runner downgrades to PartiallyConfirmed rather than an
# OOB self-confirm.
print("__NYX_SYNTHETIC_FALLBACK__", flush=True)
sys.stdout.write(json.dumps({{"location": location, "request_host": request_host}}) + "\n")
sys.stdout.flush()

View file

@ -8417,6 +8417,7 @@ fn try_curl_url_propagation(
/// sets `const_values: Some(&callee_body.opt.const_values)` on the child
/// transfer, so callee-local constants are resolved.
/// - Unknown / non-integer / out-of-bounds: falls back to `HeapSlot::Elements`.
///
/// Map a proven constant index/key to its precise `HeapSlot`, or `None`
/// (caller falls back to `HeapSlot::Elements`).
///
@ -8490,6 +8491,7 @@ fn resolve_container_index(index_val: SsaValue, transfer: &SsaTaintTransfer) ->
/// 2. the parallel `arg_string_literals` slot (a *literal* index/key, e.g.
/// `map.get("keyB")`, which carries no SSA value because it is not a
/// variable — the dominant OWASP shape).
///
/// Otherwise returns `HeapSlot::Elements`.
fn resolve_op_slot(
index_arg: Option<usize>,