xssmap 2.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xssmap/browser.py ADDED
@@ -0,0 +1,900 @@
1
+ """Headless browser execution verifier.
2
+
3
+ A reflected payload that survives verbatim is strong evidence, but only a real
4
+ engine proves execution -- and a real engine is also what proves that a Content
5
+ Security Policy *stopped* it. We look for a local Chrome/Chromium/Edge, drive it
6
+ over the DevTools protocol (stdlib WebSocket, no dependencies) and ask the live
7
+ DOM whether the injected script managed to create its artifact element.
8
+
9
+ Asking the DOM instead of grepping the serialized HTML matters: the handler
10
+ source itself contains the marker string, so a substring match would "confirm"
11
+ payloads that never ran.
12
+
13
+ For POST injections a tiny auto-submitting form is written to disk and opened,
14
+ so the browser performs a genuine cross-document POST to the target.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import json
20
+ import os
21
+ import platform
22
+ import shutil
23
+ import socket
24
+ import struct
25
+ import subprocess
26
+ import tempfile
27
+ import threading
28
+ import time
29
+ import urllib.parse
30
+ import urllib.request
31
+ from typing import Any
32
+
33
+ from .verify import Verification
34
+
35
+ CANDIDATES_WIN = [
36
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
37
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
38
+ os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
39
+ r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
40
+ r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
41
+ r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
42
+ r"C:\Program Files\Chromium\Application\chrome.exe",
43
+ ]
44
+
45
+ CANDIDATES_NIX = [
46
+ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium",
47
+ "/usr/bin/chromium-browser", "/snap/bin/chromium", "/usr/bin/microsoft-edge",
48
+ "/usr/bin/brave-browser", "/usr/bin/vivaldi",
49
+ ]
50
+
51
+ CANDIDATES_MAC = [
52
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
53
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
54
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
55
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
56
+ ]
57
+
58
+
59
+ def find_browser() -> str | None:
60
+ system = platform.system()
61
+ if system == "Windows":
62
+ paths = CANDIDATES_WIN
63
+ elif system == "Darwin":
64
+ paths = CANDIDATES_MAC
65
+ else:
66
+ paths = CANDIDATES_NIX
67
+ for p in paths:
68
+ if p and os.path.isfile(p):
69
+ return p
70
+ for name in ("chrome", "chromium", "google-chrome", "msedge", "brave-browser"):
71
+ found = shutil.which(name)
72
+ if found:
73
+ return found
74
+ return None
75
+
76
+
77
+ class DevToolsError(RuntimeError):
78
+ pass
79
+
80
+
81
+ class WebSocket:
82
+ """Bare-bones RFC 6455 client: text frames out, text messages in."""
83
+
84
+ def __init__(self, url: str, timeout: float = 15.0):
85
+ parts = urllib.parse.urlsplit(url)
86
+ if parts.scheme not in ("ws", "wss"):
87
+ raise DevToolsError(f"unsupported websocket url: {url}")
88
+ host, port = parts.hostname, parts.port or (443 if parts.scheme == "wss" else 80)
89
+ path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
90
+ self.sock = socket.create_connection((host, port), timeout=timeout)
91
+ self.sock.settimeout(timeout)
92
+ key = base64.b64encode(os.urandom(16)).decode()
93
+ request = (
94
+ f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\n"
95
+ "Upgrade: websocket\r\nConnection: Upgrade\r\n"
96
+ f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
97
+ )
98
+ self.sock.sendall(request.encode())
99
+ raw = b""
100
+ while b"\r\n\r\n" not in raw:
101
+ chunk = self.sock.recv(4096)
102
+ if not chunk:
103
+ raise DevToolsError("devtools closed during handshake")
104
+ raw += chunk
105
+ head, _, self._buf = raw.partition(b"\r\n\r\n")
106
+ status = head.split(b"\r\n", 1)[0]
107
+ if b" 101 " not in status + b" ":
108
+ raise DevToolsError(f"devtools handshake failed: {status[:80]!r}")
109
+
110
+ # -- framing -------------------------------------------------------
111
+ def _read(self, n: int) -> bytes:
112
+ while len(self._buf) < n:
113
+ chunk = self.sock.recv(max(4096, n - len(self._buf)))
114
+ if not chunk:
115
+ raise DevToolsError("devtools connection closed")
116
+ self._buf += chunk
117
+ out, self._buf = self._buf[:n], self._buf[n:]
118
+ return out
119
+
120
+ def _send_frame(self, opcode: int, payload: bytes) -> None:
121
+ mask = os.urandom(4)
122
+ header = bytearray([0x80 | opcode])
123
+ n = len(payload)
124
+ if n < 126:
125
+ header.append(0x80 | n)
126
+ elif n < 65536:
127
+ header.append(0x80 | 126)
128
+ header += struct.pack(">H", n)
129
+ else:
130
+ header.append(0x80 | 127)
131
+ header += struct.pack(">Q", n)
132
+ header += mask
133
+ masked = bytes(b ^ mask[i & 3] for i, b in enumerate(payload))
134
+ self.sock.sendall(bytes(header) + masked)
135
+
136
+ def send(self, text: str) -> None:
137
+ self._send_frame(0x1, text.encode("utf-8"))
138
+
139
+ def recv(self) -> str:
140
+ chunks: list[bytes] = []
141
+ while True:
142
+ try:
143
+ b1, b2 = self._read(2)
144
+ except socket.timeout as exc:
145
+ raise DevToolsError("devtools read timed out") from exc
146
+ fin, opcode = b1 & 0x80, b1 & 0x0F
147
+ masked, length = b2 & 0x80, b2 & 0x7F
148
+ if length == 126:
149
+ length = struct.unpack(">H", self._read(2))[0]
150
+ elif length == 127:
151
+ length = struct.unpack(">Q", self._read(8))[0]
152
+ mask = self._read(4) if masked else None
153
+ payload = self._read(length)
154
+ if mask:
155
+ payload = bytes(b ^ mask[i & 3] for i, b in enumerate(payload))
156
+ if opcode == 0x9: # ping
157
+ self._send_frame(0xA, payload)
158
+ continue
159
+ if opcode == 0x8:
160
+ raise DevToolsError("devtools closed the session")
161
+ chunks.append(payload)
162
+ if fin:
163
+ return b"".join(chunks).decode("utf-8", "replace")
164
+
165
+ def close(self) -> None:
166
+ try:
167
+ self._send_frame(0x8, b"")
168
+ except Exception:
169
+ pass
170
+ try:
171
+ self.sock.close()
172
+ except Exception:
173
+ pass
174
+
175
+
176
+ class CDP:
177
+ """One request at a time; events are collected but never awaited."""
178
+
179
+ def __init__(self, ws: WebSocket):
180
+ self.ws = ws
181
+ self._id = 0
182
+ self.events: list[dict] = []
183
+
184
+ def call(self, method: str, params: dict | None = None, timeout: float = 20.0) -> dict:
185
+ self._id += 1
186
+ mid = self._id
187
+ self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
188
+ deadline = time.time() + timeout
189
+ while True:
190
+ if time.time() > deadline:
191
+ raise DevToolsError(f"devtools timeout on {method}")
192
+ message = json.loads(self.ws.recv())
193
+ if message.get("id") == mid:
194
+ if "error" in message:
195
+ raise DevToolsError(str(message["error"].get("message", message["error"])))
196
+ return message.get("result") or {}
197
+ if "method" in message:
198
+ self.events.append(message)
199
+
200
+
201
+ class BrowserVerifier:
202
+ def __init__(self, enabled: bool = True, timeout: float = 25.0, log=None,
203
+ headless: str = "new", proxy: str | None = None):
204
+ self.log = log
205
+ self.timeout = timeout
206
+ self.headless = headless
207
+ self.proxy = proxy
208
+ self.path = find_browser() if enabled else None
209
+ self.available = bool(enabled and self.path)
210
+ self.runs = 0
211
+ self.hits = 0
212
+ self.launches = 0 # browser process starts, expected to stay at 1
213
+ self.seconds = 0.0 # wall time spent inside verification
214
+ self.last_error = ""
215
+ self.last_result: dict = {}
216
+ self.cookies: list[dict] = [] # [{'name','value','url'}] applied over CDP
217
+ self._profile: str | None = None
218
+ self._proc: subprocess.Popen | None = None
219
+ self._cdp: CDP | None = None
220
+ self._port: int | None = None
221
+ self._observers_on = False
222
+ self._lock = threading.RLock()
223
+
224
+ def describe(self) -> str:
225
+ """One line for the report header.
226
+
227
+ ``launches`` is reported on purpose: the verifier keeps one browser
228
+ process and one DevTools session alive for the whole scan, so a run with
229
+ many checks and a single launch is the evidence that page loads were
230
+ reused instead of paying a browser start per payload.
231
+ """
232
+ if not self.available:
233
+ return "browser verification: disabled (no Chromium/Chrome/Edge found)"
234
+ note = f"browser verification: {os.path.basename(self.path or '')}"
235
+ if self.runs:
236
+ note += (f" ({self.runs} check(s), {self.launches} launch(es), "
237
+ f"{1000.0 * self.seconds / self.runs:.0f} ms/check)")
238
+ return note
239
+
240
+ # -- lifecycle -----------------------------------------------------
241
+ def _profile_dir(self) -> str:
242
+ if self._profile is None:
243
+ self._profile = tempfile.mkdtemp(prefix="xssmap-profile-")
244
+ return self._profile
245
+
246
+ def _launch(self) -> None:
247
+ if self._cdp is not None and self._port and self._alive(self._port):
248
+ return
249
+ self._shutdown_browser()
250
+ profile = self._profile_dir()
251
+ port = _free_port()
252
+ proxy_args = (["--proxy-server=" + self.proxy] if self.proxy
253
+ else ["--no-proxy-server"])
254
+ args = [
255
+ self.path or "",
256
+ f"--headless={self.headless}" if self.headless in ("new", "old") else "--headless",
257
+ "--no-sandbox",
258
+ *proxy_args,
259
+ f"--remote-debugging-port={port}",
260
+ f"--user-data-dir={profile}",
261
+ "--disable-gpu",
262
+ "--disable-dev-shm-usage",
263
+ "--no-first-run",
264
+ "--no-default-browser-check",
265
+ "--disable-extensions",
266
+ "--disable-background-networking",
267
+ "--disable-sync",
268
+ "--disable-popup-blocking",
269
+ "--mute-audio",
270
+ "--window-size=1280,900",
271
+ "about:blank",
272
+ ]
273
+ kwargs: dict = {
274
+ "stdin": subprocess.DEVNULL,
275
+ "stdout": subprocess.DEVNULL,
276
+ "stderr": subprocess.DEVNULL,
277
+ }
278
+ if platform.system() == "Windows":
279
+ kwargs["creationflags"] = 0x08000000 # CREATE_NO_WINDOW
280
+ else:
281
+ kwargs["start_new_session"] = True
282
+ self._proc = subprocess.Popen(args, **kwargs)
283
+
284
+ # The Windows launcher often exits at once while the real browser keeps
285
+ # running detached, so liveness is judged by the DevTools endpoint only.
286
+ if not self._wait_for_devtools(port, 20.0):
287
+ raise DevToolsError(self.last_error or "browser did not expose a DevTools port")
288
+ self._port = port
289
+ ws_url = self._page_socket(port)
290
+ cdp = CDP(WebSocket(ws_url, timeout=20.0))
291
+ self._cdp = cdp
292
+ for domain in ("Page.enable", "Runtime.enable"):
293
+ try:
294
+ cdp.call(domain, timeout=10.0)
295
+ except DevToolsError:
296
+ pass
297
+ self.launches += 1
298
+
299
+ def _alive(self, port: int) -> bool:
300
+ try:
301
+ with urllib.request.urlopen(
302
+ f"http://127.0.0.1:{port}/json/version", timeout=1.5) as resp:
303
+ return getattr(resp, "status", 0) == 200
304
+ except Exception:
305
+ return False
306
+
307
+ def _wait_for_devtools(self, port: int, timeout: float) -> bool:
308
+ deadline = time.time() + timeout
309
+ while time.time() < deadline:
310
+ if self._alive(port):
311
+ return True
312
+ time.sleep(0.15)
313
+ return False
314
+
315
+ def _page_socket(self, port: int) -> str:
316
+ base = f"http://127.0.0.1:{port}"
317
+ for _ in range(40):
318
+ try:
319
+ with urllib.request.urlopen(base + "/json/list", timeout=2) as resp:
320
+ targets = json.loads(resp.read().decode("utf-8", "replace"))
321
+ for t in targets:
322
+ if t.get("type") == "page" and t.get("webSocketDebuggerUrl"):
323
+ return t["webSocketDebuggerUrl"]
324
+ except Exception:
325
+ pass
326
+ time.sleep(0.25)
327
+ # no page target yet: ask for one explicitly
328
+ try:
329
+ req = urllib.request.Request(base + "/json/new?about:blank", method="PUT")
330
+ with urllib.request.urlopen(req, timeout=3) as resp:
331
+ target = json.loads(resp.read().decode("utf-8", "replace"))
332
+ if target.get("webSocketDebuggerUrl"):
333
+ return target["webSocketDebuggerUrl"]
334
+ except Exception:
335
+ pass
336
+ raise DevToolsError("browser exposed no page target")
337
+
338
+ def _shutdown_browser(self) -> None:
339
+ # graceful: ask the browser to exit through its own DevTools endpoint
340
+ if self._port:
341
+ try:
342
+ with urllib.request.urlopen(
343
+ f"http://127.0.0.1:{self._port}/json/version", timeout=1.5) as resp:
344
+ info = json.loads(resp.read().decode("utf-8", "replace"))
345
+ browser_ws = info.get("webSocketDebuggerUrl")
346
+ if browser_ws:
347
+ CDP(WebSocket(browser_ws, timeout=4.0)).call("Browser.close", timeout=3.0)
348
+ except Exception:
349
+ pass
350
+ if self._cdp is not None:
351
+ try:
352
+ self._cdp.ws.close()
353
+ except Exception:
354
+ pass
355
+ self._cdp = None
356
+ self._observers_on = False
357
+ if self._proc is not None and self._proc.poll() is None:
358
+ try:
359
+ self._proc.terminate()
360
+ self._proc.wait(timeout=3)
361
+ except Exception:
362
+ try:
363
+ self._proc.kill()
364
+ except Exception:
365
+ pass
366
+ self._proc = None
367
+ if self._profile:
368
+ _kill_by_profile(self._profile)
369
+ self._port = None
370
+
371
+ # -- navigation ----------------------------------------------------
372
+ def _evaluate(self, expression: str, timeout: float = 15.0) -> str:
373
+ cdp = self._cdp
374
+ if cdp is None:
375
+ return ""
376
+ result = cdp.call("Runtime.evaluate", {
377
+ "expression": expression, "returnByValue": True, "awaitPromise": False,
378
+ }, timeout=timeout)
379
+ return str((result.get("result") or {}).get("value") or "")
380
+
381
+ def _evaluate_raw(self, expression: str, timeout: float = 15.0):
382
+ """Evaluate and return the value with its type intact (dicts stay dicts)."""
383
+ cdp = self._cdp
384
+ if cdp is None:
385
+ return None
386
+ result = cdp.call("Runtime.evaluate", {
387
+ "expression": expression, "returnByValue": True, "awaitPromise": False,
388
+ }, timeout=timeout)
389
+ if result.get("exceptionDetails"):
390
+ return None
391
+ return (result.get("result") or {}).get("value")
392
+
393
+ def _load(self, url: str) -> None:
394
+ cdp = self._cdp
395
+ if cdp is None:
396
+ return
397
+ result = cdp.call("Page.navigate", {"url": url}, timeout=20.0)
398
+ self.last_result = {"requested": url, "error": result.get("errorText") or ""}
399
+ deadline = time.time() + 8.0
400
+ while time.time() < deadline:
401
+ try:
402
+ if self._evaluate("document.readyState", timeout=5.0) == "complete":
403
+ break
404
+ except DevToolsError:
405
+ pass
406
+ time.sleep(0.1)
407
+ time.sleep(0.15) # let load handlers (and inline onload) settle
408
+
409
+ def _dom_check(self, target_id: str) -> str:
410
+ expr = ("(function(){var e=document.getElementById(" + json.dumps(target_id) +
411
+ ");return e?(e.tagName+':'+e.id):''})()")
412
+ return self._evaluate(expr)
413
+
414
+ def _enable_observers(self) -> None:
415
+ """Turn on the event domains we need to explain a non-execution.
416
+
417
+ Without these, "the handler never fired" and "CSP refused the handler"
418
+ look identical from the outside. Enabling them costs one round trip per
419
+ browser launch and buys a defensible reason for every negative.
420
+ """
421
+ if self._cdp is None or self._observers_on:
422
+ return
423
+ for domain in ("Runtime.enable", "Log.enable", "Network.enable"):
424
+ try:
425
+ self._cdp.call(domain, timeout=10.0)
426
+ except DevToolsError:
427
+ pass # not fatal: we just lose a diagnostic channel
428
+ self._observers_on = True
429
+ self._apply_cookies()
430
+
431
+ def set_cookies(self, cookies) -> None:
432
+ """Carry an authenticated session into the verifier (secrets stay in memory)."""
433
+ self.cookies = [dict(c) for c in (cookies or []) if c.get("name")]
434
+
435
+ def _apply_cookies(self) -> None:
436
+ if self._cdp is None or not self.cookies:
437
+ return
438
+ for cookie in self.cookies:
439
+ try:
440
+ self._cdp.call("Network.setCookie", {
441
+ "name": cookie["name"], "value": cookie["value"],
442
+ "url": cookie["url"], "path": cookie.get("path", "/"),
443
+ }, timeout=10.0)
444
+ except (DevToolsError, KeyError):
445
+ continue # a rejected cookie must not break verification
446
+
447
+ def _event_mark(self) -> int:
448
+ return len(self._cdp.events) if self._cdp is not None else 0
449
+
450
+ def _events_since(self, mark: int) -> dict:
451
+ """Distil events after ``mark`` into the handful of facts we care about."""
452
+ out: dict[str, Any] = {"csp": [], "errors": [], "failed": [], "scripts": 0}
453
+ if self._cdp is None:
454
+ return out
455
+ for event in self._cdp.events[mark:]:
456
+ method = event.get("method", "")
457
+ params = event.get("params") or {}
458
+ if method == "Log.entryAdded":
459
+ entry = params.get("entry") or {}
460
+ text = (entry.get("text") or "")[:300]
461
+ low = text.lower()
462
+ if "content security policy" in low or entry.get("source") == "security":
463
+ out["csp"].append(text)
464
+ elif entry.get("level") == "error":
465
+ out["errors"].append(text)
466
+ elif method == "Runtime.exceptionThrown":
467
+ details = params.get("exceptionDetails") or {}
468
+ text = (details.get("text") or "") + " " + str(
469
+ (details.get("exception") or {}).get("description", ""))
470
+ if "content security policy" in text.lower():
471
+ out["csp"].append(text[:300])
472
+ else:
473
+ out["errors"].append(text.strip()[:300])
474
+ elif method == "Network.loadingFailed":
475
+ reason = params.get("blockedReason") or params.get("errorText") or ""
476
+ if reason:
477
+ out["failed"].append(str(reason)[:120])
478
+ if "csp" in str(reason).lower():
479
+ out["csp"].append(f"request blocked: {reason}")
480
+ elif method == "Network.requestWillBeSent":
481
+ kind = params.get("type")
482
+ if kind == "Script":
483
+ out["scripts"] += 1
484
+ # Drop the consumed window: a long scan would otherwise accumulate every
485
+ # event of every navigation for the whole run. Each caller takes a fresh
486
+ # mark immediately before loading, so trimming here is safe.
487
+ self._cdp.events[:] = self._cdp.events[mark:][-500:]
488
+ return out
489
+
490
+ # -- public API ----------------------------------------------------
491
+ def evaluate(self, expression: str, timeout: float = 15.0) -> str:
492
+ with self._lock:
493
+ self._launch()
494
+ return self._evaluate(expression, timeout=timeout)
495
+
496
+ def navigate(self, url: str, settle: float = 0.2) -> dict:
497
+ """Load a URL in the current tab and report what actually happened."""
498
+ with self._lock:
499
+ self._launch()
500
+ self._apply_cookies()
501
+ try:
502
+ before = self._evaluate("document.URL")
503
+ except DevToolsError:
504
+ before = ""
505
+ self.last_result = {"url": url, "loaded": False}
506
+ cdp = self._cdp
507
+ if cdp is None:
508
+ self.last_result = {"url": url, "loaded": False,
509
+ "error": "browser connection is not open"}
510
+ return self.last_result
511
+ try:
512
+ result = cdp.call("Page.navigate", {"url": url}, timeout=25.0)
513
+ except DevToolsError as exc:
514
+ self.last_result = {"url": url, "loaded": False, "error": str(exc)[:200]}
515
+ return self.last_result
516
+ error_text = result.get("errorText")
517
+ # Page.navigate returns before the new document commits. Poll until the
518
+ # URL actually changes; otherwise readyState still describes the old page
519
+ # and every check races the load.
520
+ same_doc = before.split("#", 1)[0] == url.split("#", 1)[0]
521
+ deadline = time.time() + (3.0 if same_doc else 10.0)
522
+ final_url = before
523
+ ready = ""
524
+ while time.time() < deadline:
525
+ try:
526
+ final_url = self._evaluate("document.URL")
527
+ ready = self._evaluate("document.readyState")
528
+ except DevToolsError:
529
+ time.sleep(0.1)
530
+ continue
531
+ if ready in ("interactive", "complete") and (same_doc or final_url != before):
532
+ break
533
+ time.sleep(0.1)
534
+ time.sleep(settle)
535
+ ready = self._evaluate("document.readyState") or ready
536
+ loaded = bool(final_url) and ready in ("interactive", "complete")
537
+ self.last_result = {
538
+ "url": final_url, "requested": url,
539
+ "loaded": loaded, "error": error_text or "",
540
+ "title": self._evaluate("document.title"),
541
+ }
542
+ return self.last_result
543
+
544
+ def _wait_ready(self, timeout: float = 8.0) -> None:
545
+ deadline = time.time() + timeout
546
+ while time.time() < deadline:
547
+ try:
548
+ if self._evaluate("document.readyState", timeout=5.0) in ("interactive", "complete"):
549
+ return
550
+ except DevToolsError:
551
+ pass
552
+ time.sleep(0.1)
553
+
554
+ def screenshot(self, path: str) -> bool:
555
+ """Capture the viewport as PNG evidence."""
556
+ with self._lock:
557
+ if self._cdp is None:
558
+ return False
559
+ try:
560
+ data = self._cdp.call("Page.captureScreenshot", {"format": "png"}, timeout=15.0)
561
+ raw = base64.b64decode(data.get("data", ""))
562
+ if not raw:
563
+ return False
564
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
565
+ with open(path, "wb") as fh:
566
+ fh.write(raw)
567
+ return True
568
+ except Exception:
569
+ return False
570
+
571
+ def _page_live(self) -> dict:
572
+ """Is there a committed, parsed document in the tab right now?"""
573
+ try:
574
+ probe = self._evaluate(
575
+ "(function(){return JSON.stringify({u:document.URL,"
576
+ "r:document.readyState,b:!!document.body,"
577
+ "n:(document.body?document.body.childElementCount:0)})})()")
578
+ except DevToolsError as exc:
579
+ return {"live": False, "error": str(exc)[:160]}
580
+ try:
581
+ data = json.loads(probe) if probe else {}
582
+ except ValueError:
583
+ return {"live": False, "error": "unreadable page probe"}
584
+ data["live"] = bool(data.get("u")) and data.get("r") in ("interactive", "complete")
585
+ return data
586
+
587
+ def verify_execution(self, req, target_id: str,
588
+ screenshot_path: str | None = None) -> Verification:
589
+ """Load the injected request and return a tri-state verdict.
590
+
591
+ The distinction that matters: a page that loaded and did nothing is
592
+ ``not_observed`` (a real negative), while anything that stopped us from
593
+ getting a valid look is ``not_tested`` (an absence of evidence). Only
594
+ the first may be used to argue that a payload does not work.
595
+ """
596
+ if not self.available:
597
+ return Verification.not_tested("no Chromium/Chrome/Edge on this host")
598
+ with self._lock:
599
+ self.runs += 1
600
+ started = time.monotonic()
601
+ wrapper = None
602
+ self.last_result = {"loaded": False}
603
+ try:
604
+ target = req.url
605
+ if req.method.upper() == "POST":
606
+ wrapper = self._post_wrapper(req)
607
+ if wrapper is None:
608
+ return Verification.not_tested(
609
+ "could not build a POST wrapper page for the browser")
610
+ target = "file:///" + wrapper.replace("\\", "/")
611
+ self._launch()
612
+ self._enable_observers()
613
+ mark = self._event_mark()
614
+ self._load(target)
615
+ live = self._page_live()
616
+ events = self._events_since(mark)
617
+ self.last_result = {
618
+ "loaded": bool(live.get("live")),
619
+ "url": live.get("u", ""),
620
+ "requested": req.url,
621
+ "csp": events["csp"],
622
+ "console_errors": events["errors"],
623
+ "failed_requests": events["failed"],
624
+ "scripts": events["scripts"],
625
+ }
626
+ if not live.get("live"):
627
+ reason = (live.get("error") or "no committed document")
628
+ nav = (self.last_result or {}).get("error") or ""
629
+ if nav:
630
+ reason = f"{reason}; navigation error: {nav}"
631
+ return Verification.not_tested(f"page never committed: {reason}"[:240])
632
+
633
+ found = self._dom_check(target_id)
634
+ if screenshot_path:
635
+ self.screenshot(screenshot_path)
636
+ if found:
637
+ self.hits += 1
638
+ return Verification.confirmed(
639
+ f"DOM contains injected node <{found}> after execution",
640
+ artifact=found, url=live.get("u", ""))
641
+
642
+ detail = {"url": live.get("u", ""), "scripts": events["scripts"],
643
+ "csp": events["csp"], "console_errors": events["errors"]}
644
+ if events["csp"]:
645
+ return Verification.not_observed(
646
+ "Content-Security-Policy refused execution: "
647
+ + events["csp"][0][:160], **detail)
648
+ if events["errors"]:
649
+ return Verification.not_observed(
650
+ "handler did not run; console reported: "
651
+ + events["errors"][0][:160], **detail)
652
+ return Verification.not_observed(
653
+ "page rendered; the payload handler produced no effect", **detail)
654
+ except DevToolsError as exc:
655
+ self.last_error = str(exc)[:200]
656
+ self.last_result = {"loaded": False, "error": self.last_error}
657
+ self._shutdown_browser()
658
+ return Verification.not_tested(f"devtools failure, browser recycled: {exc}"[:240])
659
+ except Exception as exc:
660
+ self.last_error = f"{type(exc).__name__}: {exc}"[:200]
661
+ self.last_result = {"loaded": False, "error": self.last_error}
662
+ self._shutdown_browser()
663
+ return Verification.from_exception(exc, "verifier error")
664
+ finally:
665
+ self.seconds += time.monotonic() - started
666
+ if wrapper and os.path.exists(wrapper):
667
+ try:
668
+ os.remove(wrapper)
669
+ except OSError:
670
+ pass
671
+
672
+ def prove(self, req, probe_js: str, target_id: str = "",
673
+ trigger_js: str = "") -> dict:
674
+ """Load a request and report what the injected script actually reached.
675
+
676
+ :meth:`verify_execution` answers "did this payload run" with one DOM
677
+ artifact. This answers the next question: once it runs, what can it
678
+ read and do? The probe is the injected script's own report (cookies it
679
+ can see, same-origin fetches it can make, whether it still owns the
680
+ document), so an exploit claim rests on the page's behaviour rather
681
+ than on the scanner's expectation of it.
682
+ """
683
+ out: dict = {"loaded": False, "probe": None, "artifact": "",
684
+ "csp": [], "errors": [], "failed": [], "url": "", "error": ""}
685
+ if not self.available:
686
+ out["error"] = "no Chromium/Chrome/Edge on this host"
687
+ return out
688
+ with self._lock:
689
+ wrapper = None
690
+ try:
691
+ target = req.url
692
+ if req.method.upper() == "POST":
693
+ wrapper = self._post_wrapper(req)
694
+ if wrapper is None:
695
+ body = (req.body or b"").lstrip()
696
+ if body.startswith((b"{", b"[")):
697
+ out["error"] = ("JSON request bodies have no form equivalent, so "
698
+ "this POST cannot be replayed from a wrapper page")
699
+ else:
700
+ out["error"] = "could not build a POST wrapper page for the browser"
701
+ return out
702
+ target = "file:///" + wrapper.replace("\\", "/")
703
+ self._launch()
704
+ self._enable_observers()
705
+ mark = self._event_mark()
706
+ self._load(target)
707
+ live = self._page_live()
708
+ events = self._events_since(mark)
709
+ out.update({"loaded": bool(live.get("live")),
710
+ "url": live.get("u", ""),
711
+ "csp": events["csp"], "errors": events["errors"],
712
+ "failed": events["failed"], "scripts": events["scripts"]})
713
+ self.last_result = dict(out)
714
+ if not out["loaded"]:
715
+ out["error"] = live.get("error") or "no committed document"
716
+ return out
717
+ if trigger_js:
718
+ try:
719
+ self._evaluate(trigger_js, timeout=5.0)
720
+ except DevToolsError:
721
+ pass
722
+ time.sleep(0.2) # let the dispatched handlers run
723
+ if target_id:
724
+ out["artifact"] = self._dom_check(target_id)
725
+ if probe_js:
726
+ out["probe"] = self._evaluate_raw(probe_js)
727
+ return out
728
+ except DevToolsError as exc:
729
+ self.last_error = str(exc)[:200]
730
+ self._shutdown_browser()
731
+ out["error"] = f"devtools failure, browser recycled: {exc}"[:240]
732
+ return out
733
+ except Exception as exc:
734
+ self.last_error = f"{type(exc).__name__}: {exc}"[:200]
735
+ self._shutdown_browser()
736
+ out["error"] = self.last_error
737
+ return out
738
+ finally:
739
+ if wrapper and os.path.exists(wrapper):
740
+ try:
741
+ os.remove(wrapper)
742
+ except OSError:
743
+ pass
744
+
745
+ def roundtrip(self, markup: str, target_id: str) -> dict:
746
+ """Serialize *markup* the way a sanitiser would, then re-parse it live.
747
+
748
+ Mutation XSS lives in the gap between the parser that produced a
749
+ document and the parser that later consumes it. This mirrors that gap:
750
+ parse -> serialise -> parse in a fresh document that actually executes,
751
+ and report whether the second parse produced a live node.
752
+ """
753
+ if not self.available:
754
+ return {"ok": False, "error": "browser unavailable"}
755
+ script = _ROUNDTRIP_JS % (json.dumps(markup), json.dumps(target_id))
756
+ with self._lock:
757
+ self._launch()
758
+ if self._cdp is None:
759
+ return {"ok": False, "error": "browser not running"}
760
+ try:
761
+ result = self._cdp.call("Runtime.evaluate", {
762
+ "expression": script, "returnByValue": True, "awaitPromise": True,
763
+ }, timeout=20.0)
764
+ raw = str((result.get("result") or {}).get("value") or "")
765
+ except DevToolsError as exc:
766
+ self._shutdown_browser()
767
+ return {"ok": False, "error": str(exc)[:200]}
768
+ try:
769
+ data = json.loads(raw) if raw else {}
770
+ except ValueError:
771
+ return {"ok": False, "error": "unreadable round-trip result"}
772
+ data["ok"] = True
773
+ data["activated"] = bool(data.get("hit"))
774
+ data["control_activated"] = bool(data.get("control_hit"))
775
+ return data
776
+
777
+ def _post_wrapper(self, req) -> str | None:
778
+ try:
779
+ body = (req.body or b"").decode("utf-8", "replace")
780
+ pairs = urllib.parse.parse_qsl(body, keep_blank_values=True)
781
+ if not pairs:
782
+ return None
783
+ inputs = "\n".join(
784
+ f'<input type="hidden" name="{_attr(k)}" value="{_attr(v)}">' for k, v in pairs
785
+ )
786
+ html = (
787
+ "<!doctype html><html><body>"
788
+ f'<form id="f" method="POST" action="{_attr(req.url)}">{inputs}</form>'
789
+ "<script>document.getElementById('f').submit();</script>"
790
+ "</body></html>"
791
+ )
792
+ fd, path = tempfile.mkstemp(prefix="xssmap-post-", suffix=".html")
793
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
794
+ fh.write(html)
795
+ return path
796
+ except Exception:
797
+ return None
798
+
799
+ def cleanup(self) -> None:
800
+ with self._lock:
801
+ self._shutdown_browser()
802
+ if self._profile and os.path.isdir(self._profile):
803
+ shutil.rmtree(self._profile, ignore_errors=True)
804
+ self._profile = None
805
+
806
+
807
+ def _free_port() -> int:
808
+ sock = socket.socket()
809
+ try:
810
+ sock.bind(("127.0.0.1", 0))
811
+ return sock.getsockname()[1]
812
+ finally:
813
+ sock.close()
814
+
815
+
816
+ def _kill_by_profile(profile: str) -> None:
817
+ """Stop browser processes still holding *profile* (they can outlive the launcher)."""
818
+ names = ("msedge.exe", "chrome.exe", "brave.exe", "chromium.exe")
819
+ if platform.system() == "Windows":
820
+ quoted = profile.replace("'", "''")
821
+ filter_names = " or ".join(f"Name='{n}'" for n in names)
822
+ script = (
823
+ f"Get-CimInstance Win32_Process -Filter \"{filter_names}\" | "
824
+ f"Where-Object {{ $_.CommandLine -like '*{quoted}*' }} | "
825
+ "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
826
+ )
827
+ try:
828
+ subprocess.run(
829
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
830
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
831
+ stderr=subprocess.DEVNULL, timeout=25,
832
+ creationflags=0x08000000,
833
+ )
834
+ except Exception:
835
+ pass
836
+ else:
837
+ try:
838
+ subprocess.run(
839
+ ["pkill", "-f", profile], stdin=subprocess.DEVNULL,
840
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
841
+ )
842
+ except Exception:
843
+ pass
844
+
845
+
846
+ def _attr(value: str) -> str:
847
+ return (value.replace("&", "&amp;").replace('"', "&quot;")
848
+ .replace("<", "&lt;").replace(">", "&gt;"))
849
+
850
+
851
+ # Parse -> serialise -> re-parse, with a paired control so the comparison is
852
+ # meaningful. Both stages load a same-origin srcdoc iframe that really executes:
853
+ # control = a fresh document parsed from the raw markup
854
+ # test = a fresh document parsed from serialize(parse(raw markup))
855
+ # A vector is mutation XSS only when the control stays inert and the test
856
+ # executes. Without the control, an ordinary payload that the serialiser merely
857
+ # re-quotes (src=x -> src="x") would be mislabelled as a mutation.
858
+ # The stage parse happens in a detached div, so nothing runs there; any stray
859
+ # artifact that a detached parse somehow created is cleaned up afterwards.
860
+ # Two %s slots, filled with json.dumps(): braces are never interpreted.
861
+ _ROUNDTRIP_JS = """(async function(){
862
+ var payload = %s, target = %s;
863
+ var stage = document.createElement('div');
864
+ stage.innerHTML = payload;
865
+ var serialized = stage.innerHTML;
866
+ function loadInto(html) {
867
+ return new Promise(function(resolve){
868
+ var frame = document.createElement('iframe');
869
+ frame.setAttribute('aria-hidden','true');
870
+ frame.style.cssText = 'width:0;height:0;border:0;position:absolute;left:-9999px';
871
+ (document.body || document.documentElement).appendChild(frame);
872
+ var settled = false;
873
+ function finish(how) {
874
+ if (settled) { return; }
875
+ settled = true;
876
+ setTimeout(function(){
877
+ var hit = '', err = '';
878
+ try {
879
+ var doc = frame.contentDocument;
880
+ var el = doc && doc.getElementById(target);
881
+ if (el) { hit = el.tagName + ':' + el.id; }
882
+ } catch (e) { err = e.name; }
883
+ frame.remove();
884
+ resolve({hit: hit, error: err, how: how});
885
+ }, 200);
886
+ }
887
+ frame.addEventListener('load', function(){ finish('load'); }, {once:true});
888
+ setTimeout(function(){ finish('timeout'); }, 2000);
889
+ frame.srcdoc = '<!doctype html><html><body>' + html + '</body></html>';
890
+ });
891
+ }
892
+ var control = await loadInto(payload);
893
+ var test = await loadInto(serialized);
894
+ var stray = document.getElementById(target);
895
+ if (stray && stray.parentNode) { stray.parentNode.removeChild(stray); }
896
+ stage.remove();
897
+ return JSON.stringify({serialized: serialized,
898
+ control_hit: control.hit, control_how: control.how,
899
+ hit: test.hit, how: test.how, error: test.error});
900
+ })()"""