#!/usr/bin/env python # # Glances - An eye on your system # # SPDX-FileCopyrightText: 2024 Nicolas Hennion # # SPDX-License-Identifier: LGPL-3.0-only # """Glances unit tests for the WebUI/RESTful API Central Browser mode. Tests cover: - /api//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//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