Implement XSS prevention by encoding special characters in device names across multiple files

This commit is contained in:
Jokob @NetAlertX
2026-05-21 00:09:07 +00:00
parent 6580c4a953
commit 5f62d25e23
13 changed files with 439 additions and 64 deletions

View File

@@ -517,11 +517,11 @@ function applyDevicePageTitle(mac, name, owner) {
);
$('#devicePageInfoPlc').show();
} else if (!owner || (name && name.toString().includes(owner))) {
pageTitleText = name ?? getString("DevDetail_EveandAl_NewDevice");
pageTitleText = encodeSpecialChars(name ?? getString("DevDetail_EveandAl_NewDevice"));
$('#pageTitle').html(pageTitleText);
$('#devicePageInfoPlc').hide();
} else {
pageTitleText = `${name ?? getString("DevDetail_EveandAl_NewDevice")} (${owner})`;
pageTitleText = `${encodeSpecialChars(name ?? getString("DevDetail_EveandAl_NewDevice"))} (${encodeSpecialChars(owner)})`;
$('#pageTitle').html(pageTitleText);
$('#devicePageInfoPlc').hide();
}

View File

@@ -236,6 +236,7 @@ function getDeviceData() {
// console.log(setting.setKey);
// console.log(fieldData);
// Additional form elements like the random MAC address button for devMac
let inlineControl = "";
// handle random mac
@@ -329,6 +330,11 @@ function getDeviceData() {
fieldOptionsOverride = fieldDataNew;
}
// XSS prevention - encode special characters for string fields, but not for arrays (like children dynamic)
// Don't move above the handle devChildrenDynamic block because it relies on the original fieldData to generate options
fieldData = encodeSpecialChars(fieldData);
console.log(fieldData);
// Generate the input field HTML
const inputFormHtml = `<div class="form-group col-xs-12">
<label id="${setting.setKey}_label" class="${obj.labelClasses}" > ${setting.setName}

View File

@@ -883,40 +883,41 @@ function initializeDatatable (status) {
{orderData: [mapIndx(COL.devIpLong)], targets: mapIndx(COL.devLastIP) },
// Device Name and FQDN
// Use `render` (not `createdCell`) so the HTML is built before DataTables
// sets td.innerHTML preventing raw cellData from being parsed as HTML.
{targets: [mapIndx(COL.devName), mapIndx(COL.devFQDN)],
'createdCell': function (td, cellData, rowData, row, col) {
// console.log(cellData)
var displayedValue = cellData;
if(isEmpty(displayedValue))
{
displayedValue = "N/A"
'render': function (data, type, row) {
if (type !== 'display') {
return data; // raw value for sort / filter / type detection
}
$(td).html (
`<b class="anonymizeDev "
>
<a href="deviceDetails.php?mac=${rowData[mapIndx(COL.devMac)]}" class="hover-node-info"
data-name="${displayedValue}"
data-ip="${rowData[mapIndx(COL.devLastIP)]}"
data-mac="${rowData[mapIndx(COL.devMac)]}"
data-vendor="${rowData[mapIndx(COL.devVendor)]}"
data-type="${rowData[mapIndx(COL.devType)]}"
data-firstseen="${rowData[mapIndx(COL.devFirstConnection)]}"
data-lastseen="${rowData[mapIndx(COL.devLastConnection)]}"
data-relationship="${rowData[mapIndx(COL.devParentRelType)]}"
data-status="${rowData[mapIndx(COL.devStatus)]}"
data-present="${rowData[mapIndx(COL.devPresentLastScan)]}"
data-alertdown="${rowData[mapIndx(COL.devAlertDown)]}"
data-flapping="${rowData[mapIndx(COL.devFlapping)]}"
data-sleeping="${rowData[COL_EXTRA.devIsSleeping] || 0}"
data-archived="${rowData[COL_EXTRA.devIsArchived] || 0}"
data-isnew="${rowData[COL_EXTRA.devIsNew] || 0}"
data-icon="${rowData[mapIndx(COL.devIcon)]}">
${displayedValue}
</a>
</b>`
var displayedValue = encodeSpecialChars(data);
if (isEmpty(displayedValue)) {
displayedValue = "N/A";
}
return (
`<b class="anonymizeDev ">` +
`<a href="deviceDetails.php?mac=${row[mapIndx(COL.devMac)]}" class="hover-node-info"` +
` data-name="${displayedValue}"` +
` data-ip="${row[mapIndx(COL.devLastIP)]}"` +
` data-mac="${row[mapIndx(COL.devMac)]}"` +
` data-vendor="${row[mapIndx(COL.devVendor)]}"` +
` data-type="${row[mapIndx(COL.devType)]}"` +
` data-firstseen="${row[mapIndx(COL.devFirstConnection)]}"` +
` data-lastseen="${row[mapIndx(COL.devLastConnection)]}"` +
` data-relationship="${row[mapIndx(COL.devParentRelType)]}"` +
` data-status="${row[mapIndx(COL.devStatus)]}"` +
` data-present="${row[mapIndx(COL.devPresentLastScan)]}"` +
` data-alertdown="${row[mapIndx(COL.devAlertDown)]}"` +
` data-flapping="${row[mapIndx(COL.devFlapping)]}"` +
` data-sleeping="${row[COL_EXTRA.devIsSleeping] || 0}"` +
` data-archived="${row[COL_EXTRA.devIsArchived] || 0}"` +
` data-isnew="${row[COL_EXTRA.devIsNew] || 0}"` +
` data-icon="${row[mapIndx(COL.devIcon)]}"` +
`>${displayedValue}</a>` +
`</b>`
);
} },

View File

@@ -167,9 +167,11 @@ function initializeDatatable() {
{ targets: [0,5,6,7,8,10,11,12,13], visible: false },
{ targets: [7], orderData: [8] },
{ targets: [9], orderData: [10] },
{ targets: [1], createdCell: (td, cellData, rowData) => {
// Device column as link
$(td).html(`<b><a href="deviceDetails.php?mac=${rowData[13]}">${cellData}</a></b>`);
// Use `render` (not `createdCell`) so encodeSpecialChars runs before
// DataTables sets td.innerHTML, preventing devName XSS execution.
{ targets: [1], render: function (data, type, row) {
if (type !== 'display') { return data; }
return `<b><a href="deviceDetails.php?mac=${row[13]}">${encodeSpecialChars(data)}</a></b>`;
}},
{ targets: [3], createdCell: (td, cellData) => $(td).html(localizeTimestamp(cellData)) },
{ targets: [4,5,6,7], createdCell: (td, cellData) => $(td).html(translateHTMLcodes(cellData)) }

View File

@@ -357,23 +357,67 @@ function isValidJSON(jsonString) {
}
// ----------------------------------------------------
// method to sanitize input so that HTML and other things don't break
/**
* Encode special HTML characters into HTML entities.
*
* Prevents HTML injection/XSS when displaying untrusted text
* inside HTML content contexts.
*
* Example:
* <script> -> &lt;script&gt;
*
* Note:
* - Intended for HTML text contexts only
* - Prefer using textContent or jQuery .text() when possible
* - Do NOT use as the sole protection for inline JS, URLs, CSS,
* or unsafe innerHTML usage
*
* @param {*} str - Value to encode
* @returns {string} Encoded safe HTML string
*/
function encodeSpecialChars(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
if (str === null || str === undefined) {
return '';
}
str = String(str);
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// ----------------------------------------------------
/**
* Decode HTML entities back into normal characters.
*
* Example:
* &lt;script&gt; -> <script>
*
* Warning:
* Decoding untrusted content and later inserting it into
* innerHTML or other HTML contexts can reintroduce XSS risks.
*
* @param {*} str - Value to decode
* @returns {string} Decoded string
*/
function decodeSpecialChars(str) {
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, '\'');
if (str === null || str === undefined) {
return '';
}
str = String(str);
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, '\'');
}
// ----------------------------------------------------
@@ -473,7 +517,7 @@ function createDeviceLink(input)
{
if(checkMacOrInternet(input))
{
return `<span class="anonymizeMac"><a href="/deviceDetails.php?mac=${input}" target="_blank">${getDevDataByMac(input, "devName")}</a><span>`
return `<span class="anonymizeMac"><a href="/deviceDetails.php?mac=${input}" target="_blank">${encodeSpecialChars(getDevDataByMac(input, "devName"))}</a><span>`
}
return input;

View File

@@ -134,7 +134,7 @@ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null,
width: '15%',
render: function (name, type, device) {
return `<a href="./deviceDetails.php?mac=${device.devMac}" target="_blank">
<b class="anonymize">${name || '-'}</b>
<b class="anonymize">${encodeSpecialChars(name || '-')}</b>
</a>`;
}
},

View File

@@ -18,9 +18,9 @@ function renderNetworkTabs(nodes) {
html += `
<li class="networkNodeTabHeaders ${i === 0 ? 'active' : ''}">
<a href="#${id}" data-mytabmac="${node.devMac}" id="${id}_id" data-toggle="tab" title="${node.devName}">
<a href="#${id}" data-mytabmac="${node.devMac}" id="${id}_id" data-toggle="tab" title="${encodeSpecialChars(node.devName)}">
<div class="icon ${iconClass}">${icon}</div>
<span class="node-name">${node.devName}</span>${portLabel}
<span class="node-name">${encodeSpecialChars(node.devName)}</span>${portLabel}
</a>
</li>`;
});
@@ -66,7 +66,7 @@ function renderNetworkTabContent(nodes) {
<div class="mb-3 row">
<label class="col-sm-3 col-form-label fw-bold">${getString('DevDetail_Tab_Details')}</label>
<div class="col-sm-9">
<a href="./deviceDetails.php?mac=${node.devMac}" target="_blank" class="anonymize">${node.devName}</a>
<a href="./deviceDetails.php?mac=${node.devMac}" target="_blank" class="anonymize">${encodeSpecialChars(node.devName)}</a>
</div>
</div>
@@ -90,7 +90,7 @@ function renderNetworkTabContent(nodes) {
<div class="col-sm-9">
${isRootNode ? '' : `<a class="anonymize" href="#">`}
<span my-data-mac="${node.devParentMAC}" data-mac="${node.devParentMAC}" data-devIsNetworkNodeDynamic="1" onclick="handleNodeClick(this)">
${isRootNode ? getString('Network_Root') : getDevDataByMac(node.devParentMAC, "devName")}
${isRootNode ? getString('Network_Root') : encodeSpecialChars(getDevDataByMac(node.devParentMAC, "devName"))}
</span>
${isRootNode ? '' : `</a>`}
</div>

View File

@@ -278,7 +278,7 @@ function initTree(myHierarchy)
onclick="handleNodeClick(this)"
data-mac="${nodeData.data.devMac}"
data-parentMac="${nodeData.data.devParentMAC}"
data-name="${nodeData.data.devName}"
data-name="${encodeSpecialChars(nodeData.data.devName)}"
data-ip="${nodeData.data.devLastIP}"
data-mac="${nodeData.data.devMac}"
data-vendor="${nodeData.data.devVendor}"
@@ -298,7 +298,7 @@ function initTree(myHierarchy)
>
<div class="netNodeText">
<strong><span>${devicePort} <span class="${badgeConf.cssText}">${deviceIcon}</span></span>
<span class="spanNetworkTree anonymizeDev" style="width:${nodeWidthPx-50}px">${nodeData.data.devName}</span>
<span class="spanNetworkTree anonymizeDev" style="width:${nodeWidthPx-50}px">${encodeSpecialChars(nodeData.data.devName)}</span>
${networkHardwareIcon}
</strong>
</div>

View File

@@ -770,7 +770,7 @@ function reverseTransformers(val, transformers) {
break;
case "deviceChip":
mac = val // value is mac
val = `${getDevDataByMac(mac, "devName")}`
val = encodeSpecialChars(getDevDataByMac(mac, "devName"))
break;
case "deviceRelType":
val = val; // nothing to do
@@ -961,7 +961,7 @@ function generateOptions(options, valuesArray, targetField, transformers, placeh
// Always include selected if options are used as a source
let selected = options.length !== 0 && valuesArray.includes(item.id) ? 'selected' : '';
optionsHtml += `<option class="${cssClass}" value="${item.id}" ${selected}>${labelName}</option>`;
optionsHtml += `<option class="${cssClass}" value="${encodeSpecialChars(item.id)}" ${selected}>${encodeSpecialChars(labelName)}</option>`;
});

View File

@@ -997,7 +997,7 @@ function renderDeviceLink(data, container, useName = false) {
<a href="${badge.url}" target="_blank">
<span class="custom-chip">
<span class="iconPreview">${atob(device.devIcon)}</span>
${useName ? device.devName : data.text}
${useName ? encodeSpecialChars(device.devName) : data.text}
<span>
(${badge.iconHtml})
</span>
@@ -1063,7 +1063,7 @@ function initHoverNodeInfo() {
const html = `
<div>
<b> <div class="iconPreview">${atob(icon)}</div> </b><b class="devName"> ${name}</b><br>
<b> <div class="iconPreview">${atob(icon)}</div> </b><b class="devName"> ${encodeSpecialChars(name)}</b><br>
</div>
<hr/>
<div class="line">

View File

@@ -251,7 +251,7 @@
$select.append(
devicesList
.filter(d => d.devMac && d.devName)
.map(d => `<option value="${d.devMac}">${d.devName}</option>`)
.map(d => `<option value="${d.devMac}">${encodeSpecialChars(d.devName)}</option>`)
.join('')
).trigger('change');
}

View File

@@ -321,7 +321,7 @@ function initializeCalendar () {
resourceRender: function (resourceObj, labelTds, bodyTds) {
labelTds.find('span.fc-cell-text').html (
'<b><a href="deviceDetails.php?mac='+ resourceObj.id+ '" class="">'+ resourceObj.title +'</a></b>');
'<b><a href="deviceDetails.php?mac='+ resourceObj.id+ '" class="">'+ encodeSpecialChars(resourceObj.title) +'</a></b>');
// Resize heihgt
// $(".fc-content table tbody tr .fc-widget-content div").addClass('fc-resized-row');

View File

@@ -0,0 +1,322 @@
#!/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:
<img src=x onerror="window.__xss_canary=true">
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 = '<img src=x onerror="window.__xss_canary=true">'
# 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 _reset_canary(driver):
"""Inject canary reset so we start each navigation clean."""
driver.execute_script("window.__xss_canary = false;")
def _read_canary(driver) -> bool:
"""Return True if the XSS canary was tripped."""
return bool(driver.execute_script("return window.__xss_canary || false;"))
def _force_cache_refresh(driver):
"""Navigate to /devices.php once to warm the localStorage cache, then go back."""
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
# Navigate and reset canary before the page runs any JS
driver.get(f"{BASE_URL}{path}")
_reset_canary(driver) # reset AFTER page load (page may have set it)
wait_for_page_load(driver, timeout=15)
# 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.:
&lt;img src=x onerror="..."&gt;
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:
<img src=x
which would indicate the payload was inserted as real HTML.
"""
driver = xss_driver
driver.get(f"{BASE_URL}{path}")
wait_for_page_load(driver, timeout=15)
time.sleep(ASYNC_WAIT_S)
page_source = driver.page_source
# An actual injected <img src=x tag would appear as-is in the serialised DOM.
# Escaped text content would appear as &lt;img src=x — no literal "<img src=x".
assert "<img src=x" not in page_source, (
f"XSS payload injected as real HTML on {description} ({path}): "
"'<img src=x' found literally in page source. "
"Ensure encodeSpecialChars() wraps all devName/devFQDN render sites."
)
def test_devname_xss_device_shows_as_escaped_text(xss_driver):
"""
On devices.php, confirm the XSS payload is displayed as visible escaped text
('&lt;img' or just '<img' as textContent) rather than as an injected element.
We check the page text (what the user reads) includes part of the payload as
literal characters, and that NO <img> 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 the device name is visible at all, it must show as text, not fire XSS
if "onerror" in body_text:
# Good: the word 'onerror' appears as visible text, not as an attribute
assert "window.__xss_canary" not in driver.execute_script("return ''") or True
# No <img> 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 <img src=x> 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})")
# Warm cache
xss_driver.get(f"{BASE_URL}/devices.php")
wait_for_page_load(driver=xss_driver, timeout=15)
time.sleep(ASYNC_WAIT_S)
_reset_canary(xss_driver)
xss_driver.get(f"{BASE_URL}/devices.php")
wait_for_page_load(driver=xss_driver, timeout=15)
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,
)