#!/usr/bin/env python3
"""
Stored-XSS regression tests for devName / devFQDN rendering.
Scenario
--------
A LAN-controlled scanner (e.g. DHCPLSS / nmap) can supply arbitrary hostnames
that end up stored as `devName` or `devFQDN` in the database. If these values
are injected into jQuery `.html()` calls or template-literal HTML strings
without HTML-entity escaping, a stored XSS payload executes in the
authenticated operator's browser.
These tests verify that no page listed in the "affected surfaces" table renders
the raw payload as executable HTML.
Canary mechanism
----------------
The XSS payload sets `window.__xss_canary = true` if executed:
Before each page navigation we reset the canary to `false`. After the page
fully loads (and after any async device-list fetches have had time to run) we
assert that the canary is still `false`.
Note: these tests require a running NetAlertX backend and frontend (use the
devcontainer startup tasks or the Docker compose stack). They are Selenium-
based (headless Chromium) and are skipped when a browser is unavailable.
"""
import time
import requests
import pytest
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from selenium.webdriver.common.by import By # noqa: E402
from .test_helpers import ( # noqa: E402
BASE_URL, API_BASE_URL,
get_driver, get_api_token,
wait_for_page_load,
)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# XSS payload: sets the window canary if the string is parsed as HTML.
# We keep the payload simple and deterministic.
XSS_PAYLOAD = '
'
# A second, attribute-break variant that escapes unquoted attribute contexts
XSS_ATTR_BREAK = '" onmouseover="window.__xss_canary=true" data-x="'
# The fake MAC used for our synthetic test device (FA:CE prefix = recognised as
# "fake" by isFakeMac() in ui_components.js, so it won't affect real scanning).
XSS_TEST_MAC = "fa:ce:00:00:00:01"
# Seconds to wait after page load for async device-list fetches to complete.
ASYNC_WAIT_S = 4
# Pages to exercise (relative URLs; all are authenticated but devcontainer
# skips auth by default).
PAGES_UNDER_TEST = [
("/devices.php", "Device list table (devName / devFQDN columns)"),
("/network.php", "Network tabs and tree"),
(f"/deviceDetails.php?mac={XSS_TEST_MAC}", "Device detail page title"),
("/presence.php", "FullCalendar presence view"),
("/multiEditCore.php", "Multi-edit device selector"),
("/events.php", "Events table device name column"),
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _auth_headers(token):
return {"Authorization": f"Bearer {token}"}
def _create_xss_device(token: str):
"""Create a synthetic device whose devName is the XSS payload."""
payload = {
"createNew": True,
"devName": XSS_PAYLOAD,
"devFQDN": XSS_PAYLOAD,
"devOwner": "XSS-test",
"devType": "Other",
"devVendor": "XSS-test",
"devLastIP": "192.168.99.99",
}
resp = requests.post(
f"{API_BASE_URL}/device/{XSS_TEST_MAC}",
json=payload,
headers=_auth_headers(token),
timeout=10,
)
return resp
def _create_xss_event(token: str):
"""Create an event for the XSS test device so events.php renders its name."""
requests.post(
f"{API_BASE_URL}/events/create/{XSS_TEST_MAC}",
json={"event_type": "Device Down", "ip": "192.168.99.99", "additional_info": "xss-test"},
headers=_auth_headers(token),
timeout=10,
)
def _delete_xss_device(token: str):
"""Delete the synthetic XSS test device and its events."""
requests.delete(
f"{API_BASE_URL}/events/{XSS_TEST_MAC}",
headers=_auth_headers(token),
timeout=10,
)
requests.delete(
f"{API_BASE_URL}/devices",
json={"macs": [XSS_TEST_MAC]},
headers=_auth_headers(token),
timeout=10,
)
def _read_canary(driver) -> bool:
"""Return True if the XSS canary was tripped."""
return bool(driver.execute_script("return window.__xss_canary || false;"))
def _navigate_with_canary(driver, url, timeout=15):
"""Navigate to *url* with window.__xss_canary pre-initialised to false.
Uses CDP Page.addScriptToEvaluateOnNewDocument so the canary is set
*before* any page script runs. If we reset it after driver.get() instead,
a payload that fires during page load would set the canary and we would
immediately clear the evidence.
"""
result = driver.execute_cdp_cmd(
"Page.addScriptToEvaluateOnNewDocument",
{"source": "window.__xss_canary = false;"},
)
script_id = result.get("identifier")
try:
driver.get(url)
wait_for_page_load(driver, timeout=timeout)
finally:
if script_id:
driver.execute_cdp_cmd(
"Page.removeScriptToEvaluateOnNewDocument",
{"identifier": script_id},
)
def _force_cache_refresh(driver):
"""Clear localStorage to force a fresh device-list fetch on next page load."""
driver.execute_script("if(window.localStorage){ localStorage.clear(); }")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def xss_token():
"""Retrieve the API token; skip the module if unavailable."""
token = get_api_token()
if not token:
pytest.skip("API token not available – is the backend running?")
return token
@pytest.fixture(scope="module")
def xss_device(xss_token):
"""Create the XSS test device (and one event) before the module; clean up after."""
resp = _create_xss_device(xss_token)
if resp.status_code not in (200, 201):
pytest.skip(f"Could not create XSS test device (HTTP {resp.status_code}). "
"Is the backend running?")
# Create an event so events.php actually renders this device's name.
_create_xss_event(xss_token)
yield XSS_TEST_MAC
_delete_xss_device(xss_token)
@pytest.fixture(scope="module")
def xss_driver(xss_device): # depend on xss_device so device exists before browser starts
"""Single headless browser instance shared across all XSS tests in this module."""
driver = get_driver()
if not driver:
pytest.skip("Headless browser (Chromium) not available")
# Warm the localStorage device cache so later pages can render the device.
driver.get(f"{BASE_URL}/devices.php")
wait_for_page_load(driver, timeout=15)
time.sleep(ASYNC_WAIT_S) # let async API fetch populate localStorage
yield driver
driver.quit()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("path,description", PAGES_UNDER_TEST)
def test_devname_xss_not_executed(xss_driver, path, description):
"""
Verify that the XSS canary is NOT tripped when a page renders the XSS
device name.
Pass criterion: window.__xss_canary remains false after the page loads
and the async device list fetch has had time to complete.
"""
driver = xss_driver
# Pre-initialise canary via CDP so it is false before any page JS runs.
# Resetting after driver.get() would erase evidence of early-firing payloads.
_navigate_with_canary(driver, f"{BASE_URL}{path}")
# Give async JS (DataTables, FullCalendar, network tree) time to render
time.sleep(ASYNC_WAIT_S)
# Final canary check
fired = _read_canary(driver)
assert not fired, (
f"XSS canary was tripped on {description} ({path}). "
f"The raw payload '{XSS_PAYLOAD}' was executed as HTML. "
"Check that encodeSpecialChars() is applied at all render sites."
)
@pytest.mark.parametrize("path,description", PAGES_UNDER_TEST)
def test_devname_raw_payload_not_in_html_source(xss_driver, path, description):
"""
Verify that the XSS payload was NOT injected as a real HTML element.
A properly escaped devName ends up as text content in the DOM, e.g.:
<img src=x onerror="...">
The browser's DOM serialiser encodes < and > but NOT ", so page_source
still contains the literal string onerror="..." — that is expected and
safe. What must NOT appear is an actual unescaped opening tag:
element with src=x was injected.
"""
driver = xss_driver
driver.get(f"{BASE_URL}/devices.php")
wait_for_page_load(driver, timeout=15)
time.sleep(ASYNC_WAIT_S)
# The body text should contain the literal payload characters as plain text
body_text = driver.find_element(By.TAG_NAME, "body").text
if "onerror" in body_text:
# Payload is visible as literal text — confirm it was not also executed
assert not driver.execute_script("return window.__xss_canary || false"), (
"XSS canary fired even though payload appeared as visible text on devices.php"
)
# No
with src=x should have been injected by the XSS payload
injected_imgs = driver.find_elements(By.CSS_SELECTOR, "img[src='x']")
assert len(injected_imgs) == 0, (
f"XSS payload created an
element — payload was not escaped. "
f"Found {len(injected_imgs)} injected image(s)."
)
def test_devname_attribute_break_xss_not_executed(xss_driver, xss_token):
"""
Verify that the attribute-break variant of XSS payload is also handled.
This checks that encodeSpecialChars() encodes double-quotes in devName,
preventing an attacker from breaking out of an HTML attribute context.
"""
attr_break_mac = "fa:ce:00:00:00:02"
attr_payload = {
"createNew": True,
"devName": XSS_ATTR_BREAK,
"devOwner": "XSS-attr-test",
"devLastIP": "192.168.99.100",
}
try:
resp = requests.post(
f"{API_BASE_URL}/device/{attr_break_mac}",
json=attr_payload,
headers=_auth_headers(xss_token),
timeout=10,
)
if resp.status_code not in (200, 201):
pytest.skip(f"Could not create attribute-break XSS device (HTTP {resp.status_code})")
# Pre-set canary via CDP, then navigate — same pattern as main tests.
_navigate_with_canary(xss_driver, f"{BASE_URL}/devices.php")
time.sleep(ASYNC_WAIT_S)
fired = _read_canary(xss_driver)
assert not fired, (
f"Attribute-break XSS canary was tripped on devices.php. "
f"Payload: {XSS_ATTR_BREAK!r}. "
"Ensure double-quotes are encoded by encodeSpecialChars()."
)
finally:
requests.delete(
f"{API_BASE_URL}/devices",
json={"macs": [attr_break_mac]},
headers=_auth_headers(xss_token),
timeout=10,
)