mirror of
https://github.com/nicolargo/glances.git
synced 2026-04-17 12:30:11 -04:00
290 lines
10 KiB
Python
Executable File
290 lines
10 KiB
Python
Executable File
#!/usr/bin/env python
|
||
#
|
||
# Glances - An eye on your system
|
||
#
|
||
# SPDX-FileCopyrightText: 2024 Nicolas Hennion <nicolas@nicolargo.com>
|
||
#
|
||
# SPDX-License-Identifier: LGPL-3.0-only
|
||
#
|
||
|
||
"""Glances unit tests for the WebUI/RESTful API Central Browser mode.
|
||
|
||
Tests cover:
|
||
- /api/<version>/serverslist endpoint returns a valid servers list
|
||
- Credential fields (password, uri) are stripped from API responses
|
||
- Dynamic (Zeroconf) server entries do not leak saved credentials via the API
|
||
- Server list structure validation
|
||
"""
|
||
|
||
import re
|
||
import shlex
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
import requests
|
||
|
||
from glances.outputs.glances_restful_api import GlancesRestfulApi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
SERVER_PORT = 61237 # Distinct port to avoid conflicts with other tests
|
||
API_VERSION = GlancesRestfulApi.API_VERSION
|
||
URL = f"http://localhost:{SERVER_PORT}/api/{API_VERSION}"
|
||
|
||
# Servers injected into the generated config
|
||
STATIC_SERVERS = [
|
||
{'name': 'localhost', 'alias': 'Local Test Server', 'port': '61209', 'protocol': 'rpc'},
|
||
{'name': 'localhost', 'alias': 'Local REST Server', 'port': '61208', 'protocol': 'rest'},
|
||
]
|
||
|
||
PASSWORDS = {
|
||
'localhost': 'testpassword',
|
||
'default': 'defaultpassword',
|
||
}
|
||
|
||
DEFAULT_CONF = Path(__file__).resolve().parent.parent / 'conf' / 'glances.conf'
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers – generate a browser-enabled config
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _generate_browser_conf(tmp_path):
|
||
"""Read the default glances.conf, activate [serverlist] and [passwords],
|
||
write to tmp_path and return the file path."""
|
||
source = DEFAULT_CONF.read_text(encoding='utf-8')
|
||
|
||
# --- Build [serverlist] replacement ---
|
||
serverlist_lines = [
|
||
'[serverlist]',
|
||
'columns=system:hr_name,load:min5,cpu:total,mem:percent',
|
||
]
|
||
for idx, srv in enumerate(STATIC_SERVERS, start=1):
|
||
serverlist_lines.append(f'server_{idx}_name={srv["name"]}')
|
||
serverlist_lines.append(f'server_{idx}_alias={srv["alias"]}')
|
||
serverlist_lines.append(f'server_{idx}_port={srv["port"]}')
|
||
serverlist_lines.append(f'server_{idx}_protocol={srv["protocol"]}')
|
||
serverlist_block = '\n'.join(serverlist_lines) + '\n'
|
||
|
||
# --- Build [passwords] replacement ---
|
||
password_lines = ['[passwords]']
|
||
for host, pwd in PASSWORDS.items():
|
||
password_lines.append(f'{host}={pwd}')
|
||
password_block = '\n'.join(password_lines) + '\n'
|
||
|
||
# Replace existing sections in-place
|
||
source = re.sub(
|
||
r'\[serverlist\].*?(?=\n\[|\Z)',
|
||
serverlist_block,
|
||
source,
|
||
count=1,
|
||
flags=re.DOTALL,
|
||
)
|
||
source = re.sub(
|
||
r'\[passwords\].*?(?=\n\[|\Z)',
|
||
password_block,
|
||
source,
|
||
count=1,
|
||
flags=re.DOTALL,
|
||
)
|
||
|
||
conf_file = tmp_path / 'glances.conf'
|
||
conf_file.write_text(source, encoding='utf-8')
|
||
return str(conf_file)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture(scope='module')
|
||
def browser_conf_path(tmp_path_factory):
|
||
return _generate_browser_conf(tmp_path_factory.mktemp('browser_api_conf'))
|
||
|
||
|
||
@pytest.fixture(scope='module')
|
||
def glances_browser_server(browser_conf_path):
|
||
"""Start a Glances web server in browser mode with the generated config."""
|
||
cmdline = (
|
||
f'{sys.executable}'
|
||
f' -m glances -B 0.0.0.0 -w --browser'
|
||
f' -p {SERVER_PORT} --disable-webui --disable-autodiscover'
|
||
f' -C {browser_conf_path}'
|
||
)
|
||
args = shlex.split(cmdline)
|
||
pid = subprocess.Popen(args)
|
||
# Wait for the server to start
|
||
time.sleep(5)
|
||
yield pid
|
||
pid.terminate()
|
||
pid.wait(timeout=5)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def http_get(url):
|
||
return requests.get(url, headers={'Accept-encoding': 'identity'}, timeout=10)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests – /api/<version>/serverslist endpoint
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestServersListEndpoint:
|
||
"""Validate the /serverslist API endpoint."""
|
||
|
||
def test_serverslist_returns_200(self, glances_browser_server):
|
||
req = http_get(f'{URL}/serverslist')
|
||
assert req.ok, f'Expected 200, got {req.status_code}'
|
||
|
||
def test_serverslist_returns_list(self, glances_browser_server):
|
||
req = http_get(f'{URL}/serverslist')
|
||
data = req.json()
|
||
assert isinstance(data, list)
|
||
|
||
def test_serverslist_has_servers(self, glances_browser_server):
|
||
"""At least the configured static servers should be returned."""
|
||
req = http_get(f'{URL}/serverslist')
|
||
data = req.json()
|
||
assert len(data) >= len(STATIC_SERVERS), f'Expected at least {len(STATIC_SERVERS)} servers, got {len(data)}'
|
||
|
||
def test_serverslist_server_has_required_fields(self, glances_browser_server):
|
||
"""Each server entry should have essential fields."""
|
||
req = http_get(f'{URL}/serverslist')
|
||
required_keys = {'key', 'name', 'ip', 'port', 'protocol', 'status', 'type'}
|
||
for server in req.json():
|
||
missing = required_keys - set(server.keys())
|
||
assert not missing, f'Server {server.get("name")} missing keys: {missing}'
|
||
|
||
def test_serverslist_server_types(self, glances_browser_server):
|
||
req = http_get(f'{URL}/serverslist')
|
||
for server in req.json():
|
||
assert server['type'] in ('STATIC', 'DYNAMIC')
|
||
|
||
def test_serverslist_server_protocols(self, glances_browser_server):
|
||
req = http_get(f'{URL}/serverslist')
|
||
for server in req.json():
|
||
assert server['protocol'] in ('rpc', 'rest')
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests – Credential sanitization in API responses (CVE fix validation)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestServersListCredentialSanitization:
|
||
"""Verify that password and uri fields are stripped from API responses.
|
||
|
||
This validates the fix for CVE-2026-32633.
|
||
"""
|
||
|
||
def test_no_password_field_in_response(self, glances_browser_server):
|
||
"""The 'password' field must be stripped from all server entries."""
|
||
req = http_get(f'{URL}/serverslist')
|
||
for server in req.json():
|
||
assert 'password' not in server, f'Server {server.get("name")} still exposes "password" field'
|
||
|
||
def test_no_uri_field_in_response(self, glances_browser_server):
|
||
"""The 'uri' field must be stripped from all server entries."""
|
||
req = http_get(f'{URL}/serverslist')
|
||
for server in req.json():
|
||
assert 'uri' not in server, f'Server {server.get("name")} still exposes "uri" field'
|
||
|
||
def test_no_credential_in_any_field(self, glances_browser_server):
|
||
"""No field value should contain embedded credentials (user:pass@ pattern)."""
|
||
req = http_get(f'{URL}/serverslist')
|
||
cred_pattern = re.compile(r'://[^/]*:[^/]*@')
|
||
for server in req.json():
|
||
for key, value in server.items():
|
||
if isinstance(value, str):
|
||
assert not cred_pattern.search(value), (
|
||
f'Server {server.get("name")}, field "{key}" contains embedded credentials: {value}'
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests – _sanitize_server static method
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSanitizeServer:
|
||
"""Unit tests for GlancesRestfulApi._sanitize_server (no server needed)."""
|
||
|
||
def test_strips_password(self):
|
||
server = {'name': 'host', 'password': 'secret', 'status': 'ONLINE'}
|
||
safe = GlancesRestfulApi._sanitize_server(server)
|
||
assert 'password' not in safe
|
||
|
||
def test_strips_uri(self):
|
||
server = {'name': 'host', 'uri': 'http://u:p@host:61209', 'status': 'ONLINE'}
|
||
safe = GlancesRestfulApi._sanitize_server(server)
|
||
assert 'uri' not in safe
|
||
|
||
def test_preserves_other_fields(self):
|
||
server = {
|
||
'name': 'host',
|
||
'ip': '10.0.0.1',
|
||
'port': 61209,
|
||
'protocol': 'rpc',
|
||
'status': 'ONLINE',
|
||
'type': 'STATIC',
|
||
'password': 'secret',
|
||
'uri': 'http://u:p@host:61209',
|
||
}
|
||
safe = GlancesRestfulApi._sanitize_server(server)
|
||
assert safe['name'] == 'host'
|
||
assert safe['ip'] == '10.0.0.1'
|
||
assert safe['port'] == 61209
|
||
assert safe['protocol'] == 'rpc'
|
||
assert safe['status'] == 'ONLINE'
|
||
assert safe['type'] == 'STATIC'
|
||
|
||
def test_does_not_mutate_original(self):
|
||
server = {'name': 'host', 'password': 'secret', 'uri': 'http://x'}
|
||
GlancesRestfulApi._sanitize_server(server)
|
||
assert 'password' in server
|
||
assert 'uri' in server
|
||
|
||
def test_handles_missing_fields(self):
|
||
"""Server dict without password/uri should not raise."""
|
||
server = {'name': 'host', 'status': 'ONLINE'}
|
||
safe = GlancesRestfulApi._sanitize_server(server)
|
||
assert safe == server
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests – Multiple sequential requests (stability)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestServersListStability:
|
||
"""Ensure repeated calls return consistent, sanitized results."""
|
||
|
||
def test_repeated_calls_consistent(self, glances_browser_server):
|
||
"""Multiple sequential requests should return the same server count."""
|
||
counts = []
|
||
for _ in range(3):
|
||
req = http_get(f'{URL}/serverslist')
|
||
assert req.ok
|
||
counts.append(len(req.json()))
|
||
assert len(set(counts)) == 1, f'Inconsistent server counts across calls: {counts}'
|
||
|
||
def test_repeated_calls_never_leak_credentials(self, glances_browser_server):
|
||
"""Credentials must remain stripped across multiple polling cycles."""
|
||
for _ in range(3):
|
||
req = http_get(f'{URL}/serverslist')
|
||
for server in req.json():
|
||
assert 'password' not in server
|
||
assert 'uri' not in server
|