//! 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 `"),
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 {
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::()))
}
}
}
}
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("\">