Glances Central Browser Autodiscovery Leaks Reusable Credentials to Zeroconf-Spoofed Servers
Description
Glances is an open-source system cross-platform monitoring tool. Prior to version 4.5.2, in Central Browser mode, Glances stores both the Zeroconf-advertised server name and the discovered IP address for dynamic servers, but later builds connection URIs from the untrusted advertised name instead of the discovered IP. When a dynamic server reports itself as protected, Glances also uses that same untrusted name as the lookup key for saved passwords and the global [passwords] default credential. An attacker on the same local network can advertise a fake Glances service over Zeroconf and cause the browser to automatically send a reusable Glances authentication secret to an attacker-controlled host. This affects the background polling path and the REST/WebUI click-through path in Central Browser mode. Version 4.5.2 fixes the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
GlancesPyPI | < 4.5.2 | 4.5.2 |
Affected products
1Patches
161d38eec5217Merge branch 'GHSA-vx5f-957p-qpvm' into develop
4 files changed · +788 −5
glances/client_browser.py+3 −1 modified@@ -52,7 +52,9 @@ def __display_server(self, server): # A password is needed to access to the server's stats if server['password'] is None: # First of all, check if a password is available in the [passwords] section - clear_password = self.servers_list.password.get_password(server['name']) + # Use _get_preconfigured_password to avoid leaking saved/default credentials + # to untrusted dynamic (Zeroconf) server entries + clear_password = self.servers_list._get_preconfigured_password(server) if ( clear_password is None or self.servers_list.get_servers_list()[self.screen.active_server]['status'] == 'PROTECTED'
glances/servers_list.py+27 −4 modified@@ -116,18 +116,41 @@ def update_servers_stats(self): self.threads_list[key] = thread thread.start() + @staticmethod + def _get_connect_host(server): + """Return the host to use for connecting to the server. + + For dynamic (Zeroconf) servers, use the discovered IP address + instead of the untrusted advertised name. + """ + if server.get('type') == 'DYNAMIC': + return server['ip'] + return server['name'] + + def _get_preconfigured_password(self, server): + """Return the preconfigured password for the server. + + Dynamic (Zeroconf) entries are untrusted and should not inherit + saved or default credentials to prevent credential exfiltration + via fake Zeroconf services. + """ + if server.get('type') == 'DYNAMIC': + return None + return self.password.get_password(server['name']) + def get_uri(self, server): """Return the URI for the given server dict.""" + host = self._get_connect_host(server) # Select the connection mode (with or without password) if server['password'] != "": if server['status'] == 'PROTECTED': - # Try with the preconfigure password (only if status is PROTECTED) - clear_password = self.password.get_password(server['name']) + # Try with the preconfigured password (only if status is PROTECTED) + clear_password = self._get_preconfigured_password(server) if clear_password is not None: server['password'] = self.password.get_hash(clear_password) - uri = 'http://{}:{}@{}:{}'.format(server['username'], server['password'], server['name'], server['port']) + uri = 'http://{}:{}@{}:{}'.format(server['username'], server['password'], host, server['port']) else: - uri = 'http://{}:{}'.format(server['name'], server['port']) + uri = 'http://{}:{}'.format(host, server['port']) return uri def set_in_selected(self, selected, key, value):
tests/test_browser_restful.py+292 −0 added@@ -0,0 +1,292 @@ +#!/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 os +import re +import shlex +import subprocess +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.""" + if os.path.isfile('.venv/bin/python'): + cmdline = '.venv/bin/python' + else: + cmdline = 'python' + cmdline += ( + 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
tests/test_browser_tui.py+466 −0 added@@ -0,0 +1,466 @@ +#!/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 TUI Central Browser mode. + +Tests cover: +- Config generation from the default glances.conf with browser options enabled +- Static server list loading from config +- Password list loading and lookup (host-specific, default, missing) +- URI generation (with/without credentials, static vs dynamic servers) +- Credential sanitization: dynamic (Zeroconf) servers must not inherit saved passwords +- Credential sanitization: URIs for dynamic servers use IP, not advertised name +""" + +import re +from pathlib import Path +from unittest.mock import patch + +import pytest + +from glances.config import Config +from glances.password_list import GlancesPasswordList +from glances.servers_list import GlancesServersList + +# --------------------------------------------------------------------------- +# Helpers – generate a browser-enabled config from the default glances.conf +# --------------------------------------------------------------------------- + +DEFAULT_CONF = Path(__file__).resolve().parent.parent / 'conf' / 'glances.conf' + +# Servers injected into the generated config +# All names must be DNS-resolvable (localhost, 127.0.0.1) to avoid gethostbyname errors +STATIC_SERVERS = [ + {'name': 'localhost', 'alias': 'Local RPC Server', 'port': '61209', 'protocol': 'rpc'}, + {'name': 'localhost', 'alias': 'Local REST Server', 'port': '61208', 'protocol': 'rest'}, + {'name': '127.0.0.1', 'alias': 'Loopback Server', 'port': '61210', 'protocol': 'rpc'}, +] + +PASSWORDS = { + 'localhost': 'localpwd', + '127.0.0.1': 'loopbackpwd', + 'default': 'defaultpassword', +} + + +def _generate_browser_conf(tmp_path): + """Read the default glances.conf, uncomment and populate [serverlist] and + [passwords] sections, write the result to *tmp_path* and return its path. + + This is regenerated at every test run so the test stays in sync with the + upstream default config without manual maintenance. + """ + 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 + # Match from section header to right before the next section header (or EOF) + 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): + """Generate a browser-enabled config file once per module.""" + return _generate_browser_conf(tmp_path_factory.mktemp('browser_conf')) + + +@pytest.fixture(scope='module') +def browser_config(browser_conf_path): + """Return a Config object loaded from the generated browser config.""" + return Config(browser_conf_path) + + +@pytest.fixture(scope='module') +def browser_args(browser_conf_path): + """Return a minimal args namespace suitable for GlancesServersList.""" + from glances.main import GlancesMain + + testargs = ['glances', '--browser', '--disable-autodiscover', '-C', browser_conf_path] + with patch('sys.argv', testargs): + core = GlancesMain() + return core.get_args() + + +@pytest.fixture(scope='module') +def servers_list(browser_config, browser_args): + """Return a fully initialised GlancesServersList (autodiscovery disabled).""" + return GlancesServersList(config=browser_config, args=browser_args) + + +# --------------------------------------------------------------------------- +# Tests – Config generation +# --------------------------------------------------------------------------- + + +class TestBrowserConfigGeneration: + """Verify the generated config has the expected sections and values.""" + + def test_serverlist_section_exists(self, browser_config): + assert browser_config.has_section('serverlist') + + def test_passwords_section_exists(self, browser_config): + assert browser_config.has_section('passwords') + + def test_server_count(self, browser_config): + """All defined servers should be loadable.""" + count = 0 + for i in range(1, 256): + if browser_config.get_value('serverlist', f'server_{i}_name') is not None: + count += 1 + assert count == len(STATIC_SERVERS) + + def test_password_values(self, browser_config): + items = dict(browser_config.items('passwords')) + for host, pwd in PASSWORDS.items(): + assert items[host] == pwd + + +# --------------------------------------------------------------------------- +# Tests – Static server list loading +# --------------------------------------------------------------------------- + + +class TestStaticServerList: + """Verify GlancesStaticServer correctly loads servers from config.""" + + def test_server_list_length(self, servers_list): + servers = servers_list.get_servers_list() + assert len(servers) == len(STATIC_SERVERS) + + def test_server_fields(self, servers_list): + """Each server dict must have the expected keys.""" + required_keys = {'key', 'name', 'ip', 'port', 'protocol', 'username', 'password', 'status', 'type'} + for server in servers_list.get_servers_list(): + assert required_keys.issubset(server.keys()), f"Missing keys in {server}" + + def test_server_names(self, servers_list): + names = [s['name'] for s in servers_list.get_servers_list()] + for srv in STATIC_SERVERS: + assert srv['name'] in names + + def test_server_protocols(self, servers_list): + for server in servers_list.get_servers_list(): + assert server['protocol'] in ('rpc', 'rest') + + def test_server_type_is_static(self, servers_list): + for server in servers_list.get_servers_list(): + assert server['type'] == 'STATIC' + + def test_server_initial_status(self, servers_list): + for server in servers_list.get_servers_list(): + assert server['status'] == 'UNKNOWN' + + def test_server_default_username(self, servers_list): + for server in servers_list.get_servers_list(): + assert server['username'] == 'glances' + + def test_server_default_empty_password(self, servers_list): + for server in servers_list.get_servers_list(): + assert server['password'] == '' + + +# --------------------------------------------------------------------------- +# Tests – Password list +# --------------------------------------------------------------------------- + + +class TestPasswordList: + """Verify GlancesPasswordList loading and lookup.""" + + def test_host_specific_password(self, servers_list): + assert servers_list.password.get_password('localhost') == 'localpwd' + + def test_loopback_password(self, servers_list): + assert servers_list.password.get_password('127.0.0.1') == 'loopbackpwd' + + def test_default_fallback(self, servers_list): + """Unknown host should fall back to 'default' password.""" + assert servers_list.password.get_password('unknown-host') == 'defaultpassword' + + def test_no_password_without_default(self): + """Without a default entry, unknown host should return None.""" + pwd = GlancesPasswordList() + pwd._password_dict = {'localhost': 'localpwd'} + assert pwd.get_password('unknown-host') is None + + +# --------------------------------------------------------------------------- +# Tests – Columns +# --------------------------------------------------------------------------- + + +class TestColumnsDefinition: + """Verify columns parsing from config.""" + + def test_columns_loaded(self, servers_list): + columns = servers_list.get_columns() + assert len(columns) > 0 + + def test_columns_structure(self, servers_list): + for col in servers_list.get_columns(): + assert 'plugin' in col + assert 'field' in col + + def test_columns_values(self, servers_list): + plugins = [c['plugin'] for c in servers_list.get_columns()] + assert 'system' in plugins + assert 'cpu' in plugins + assert 'mem' in plugins + + +# --------------------------------------------------------------------------- +# Tests – URI generation for STATIC servers +# --------------------------------------------------------------------------- + + +class TestGetUriStatic: + """Verify URI generation for static servers.""" + + def test_uri_without_password(self, servers_list): + """Static server with empty password → URI without credentials.""" + server = { + 'name': 'myhost', + 'ip': '10.0.0.1', + 'port': 61209, + 'username': 'glances', + 'password': '', + 'status': 'ONLINE', + 'type': 'STATIC', + } + uri = servers_list.get_uri(server) + assert uri == 'http://myhost:61209' + assert '@' not in uri + + def test_uri_with_password(self, servers_list): + """Static server with a non-empty password → URI with credentials.""" + server = { + 'name': 'myhost', + 'ip': '10.0.0.1', + 'port': 61209, + 'username': 'glances', + 'password': 'somehash', + 'status': 'ONLINE', + 'type': 'STATIC', + } + uri = servers_list.get_uri(server) + assert 'glances:somehash@myhost:61209' in uri + + def test_uri_protected_uses_saved_password(self, servers_list): + """PROTECTED static server with a saved password should get the hash injected.""" + server = { + 'name': 'localhost', + 'ip': '127.0.0.1', + 'port': 61209, + 'username': 'glances', + 'password': 'placeholder', + 'status': 'PROTECTED', + 'type': 'STATIC', + } + uri = servers_list.get_uri(server) + # Password should have been replaced by the hash of 'localpwd' + expected_hash = servers_list.password.get_hash('localpwd') + assert expected_hash in uri + assert server['password'] == expected_hash + + def test_uri_protected_default_fallback(self, servers_list): + """PROTECTED static server with unknown name falls back to 'default' password.""" + server = { + 'name': 'unknown-static-host', + 'ip': '10.0.0.2', + 'port': 61209, + 'username': 'glances', + 'password': 'placeholder', + 'status': 'PROTECTED', + 'type': 'STATIC', + } + uri = servers_list.get_uri(server) + expected_hash = servers_list.password.get_hash('defaultpassword') + assert expected_hash in uri + + def test_uri_static_uses_name_not_ip(self, servers_list): + """Static server URI should use server['name'] as the host.""" + server = { + 'name': 'myhost.local', + 'ip': '192.168.1.100', + 'port': 61209, + 'username': 'glances', + 'password': '', + 'status': 'ONLINE', + 'type': 'STATIC', + } + uri = servers_list.get_uri(server) + assert 'myhost.local' in uri + assert '192.168.1.100' not in uri + + +# --------------------------------------------------------------------------- +# Tests – URI generation for DYNAMIC servers (CVE fix validation) +# --------------------------------------------------------------------------- + + +class TestGetUriDynamic: + """Verify that dynamic (Zeroconf) servers use IP and never inherit saved passwords. + + These tests validate the fix for the Zeroconf credential exfiltration CVE. + """ + + def test_uri_dynamic_uses_ip_not_name(self, servers_list): + """DYNAMIC server URI must use the discovered IP, not the advertised name.""" + server = { + 'name': 'attacker-controlled-name', + 'ip': '192.168.1.50', + 'port': 61209, + 'username': 'glances', + 'password': '', + 'status': 'ONLINE', + 'type': 'DYNAMIC', + } + uri = servers_list.get_uri(server) + assert '192.168.1.50' in uri + assert 'attacker-controlled-name' not in uri + + def test_uri_dynamic_protected_no_saved_password(self, servers_list): + """DYNAMIC PROTECTED server must NOT inherit saved or default passwords.""" + server = { + 'name': 'localhost', # name matches a saved password + 'ip': '198.51.100.50', + 'port': 61209, + 'username': 'glances', + 'password': 'placeholder', + 'status': 'PROTECTED', + 'type': 'DYNAMIC', + } + uri = servers_list.get_uri(server) + # The password should remain as 'placeholder' (not replaced by saved hash) + assert server['password'] == 'placeholder' + # URI should use IP + assert '198.51.100.50' in uri + assert 'localhost' not in uri + + def test_uri_dynamic_no_default_password_fallback(self, servers_list): + """DYNAMIC server with unknown name must NOT fall back to default password.""" + server = { + 'name': 'fake-zeroconf-service', + 'ip': '198.51.100.99', + 'port': 61209, + 'username': 'glances', + 'password': 'placeholder', + 'status': 'PROTECTED', + 'type': 'DYNAMIC', + } + uri = servers_list.get_uri(server) + # password unchanged + assert server['password'] == 'placeholder' + # No hash of 'defaultpassword' should appear + default_hash = servers_list.password.get_hash('defaultpassword') + assert default_hash not in uri + + def test_get_connect_host_static(self, servers_list): + server = {'name': 'myhost', 'ip': '10.0.0.1', 'type': 'STATIC'} + assert servers_list._get_connect_host(server) == 'myhost' + + def test_get_connect_host_dynamic(self, servers_list): + server = {'name': 'advertised-name', 'ip': '10.0.0.2', 'type': 'DYNAMIC'} + assert servers_list._get_connect_host(server) == '10.0.0.2' + + def test_get_preconfigured_password_static(self, servers_list): + server = {'name': 'localhost', 'type': 'STATIC'} + assert servers_list._get_preconfigured_password(server) == 'localpwd' + + def test_get_preconfigured_password_dynamic_returns_none(self, servers_list): + server = {'name': 'localhost', 'type': 'DYNAMIC'} + assert servers_list._get_preconfigured_password(server) is None + + def test_get_preconfigured_password_dynamic_no_default(self, servers_list): + """Even with a 'default' password configured, dynamic servers get None.""" + server = {'name': 'unknown', 'type': 'DYNAMIC'} + assert servers_list._get_preconfigured_password(server) is None + + +# --------------------------------------------------------------------------- +# Tests – Simulated Zeroconf attack scenario +# --------------------------------------------------------------------------- + + +class TestZeroconfAttackScenario: + """End-to-end simulation of the Zeroconf credential exfiltration attack.""" + + def test_attacker_advertised_server_no_credential_leak(self, servers_list): + """Simulate: attacker advertises a fake Zeroconf service with a name matching + a real server. The browser marks it PROTECTED. Verify no credential is sent.""" + # Simulate a dynamic server entry as Zeroconf would create it + attacker_server = { + 'key': 'evil-service:61209._glances._tcp.local.', + 'name': 'localhost', # attacker uses name of a real server + 'ip': '198.51.100.66', # attacker's IP + 'port': 61209, + 'protocol': 'rpc', + 'username': 'glances', + 'password': '', + 'status': 'UNKNOWN', + 'type': 'DYNAMIC', + } + + # First probe: no password → URI without credentials + uri1 = servers_list.get_uri(attacker_server) + assert '@' not in uri1 + assert '198.51.100.66' in uri1 + + # Simulate server responding 401 → status becomes PROTECTED + attacker_server['password'] = None + attacker_server['status'] = 'PROTECTED' + + # Second probe: PROTECTED + DYNAMIC → must NOT inject saved password + uri2 = servers_list.get_uri(attacker_server) + # password stays None (converted to string in format) + assert attacker_server['password'] is None + # No credential hash in URI + localpwd_hash = servers_list.password.get_hash('localpwd') + default_hash = servers_list.password.get_hash('defaultpassword') + assert localpwd_hash not in uri2 + assert default_hash not in uri2
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-vx5f-957p-qpvmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-32634ghsaADVISORY
- github.com/nicolargo/glances/commit/61d38eec521703e41e4933d18d5a5ef6f854abd5ghsax_refsource_MISCWEB
- github.com/nicolargo/glances/releases/tag/v4.5.2ghsax_refsource_MISCWEB
- github.com/nicolargo/glances/security/advisories/GHSA-vx5f-957p-qpvmghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.