mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-05-24 08:40:31 -04:00
Implement XSS prevention by encoding special characters in device names across multiple files
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>`
|
||||
);
|
||||
} },
|
||||
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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> -> <script>
|
||||
*
|
||||
* 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
if (str === null || str === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
str = String(str);
|
||||
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
// ----------------------------------------------------
|
||||
/**
|
||||
* Decode HTML entities back into normal characters.
|
||||
*
|
||||
* Example:
|
||||
* <script> -> <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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, '\'');
|
||||
|
||||
if (str === null || str === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
str = String(str);
|
||||
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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;
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`;
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
322
test/ui/test_ui_xss_devname.py
Normal file
322
test/ui/test_ui_xss_devname.py
Normal 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.:
|
||||
<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:
|
||||
<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 <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
|
||||
('<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,
|
||||
)
|
||||
Reference in New Issue
Block a user