VYPR
High severityNVD Advisory· Published Mar 18, 2026· Updated Mar 18, 2026

Glances Central Browser Autodiscovery Leaks Reusable Credentials to Zeroconf-Spoofed Servers

CVE-2026-32634

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.

PackageAffected versionsPatched versions
GlancesPyPI
< 4.5.24.5.2

Affected products

1

Patches

1
61d38eec5217

Merge branch 'GHSA-vx5f-957p-qpvm' into develop

https://github.com/nicolargo/glancesnicolargoMar 14, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.