Skip to content

Commit 61d38ee

Browse files
committed
Merge branch 'GHSA-vx5f-957p-qpvm' into develop
2 parents 879ef86 + 2abe8d8 commit 61d38ee

File tree

4 files changed

+788
-5
lines changed

4 files changed

+788
-5
lines changed

glances/client_browser.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ def __display_server(self, server):
5252
# A password is needed to access to the server's stats
5353
if server['password'] is None:
5454
# First of all, check if a password is available in the [passwords] section
55-
clear_password = self.servers_list.password.get_password(server['name'])
55+
# Use _get_preconfigured_password to avoid leaking saved/default credentials
56+
# to untrusted dynamic (Zeroconf) server entries
57+
clear_password = self.servers_list._get_preconfigured_password(server)
5658
if (
5759
clear_password is None
5860
or self.servers_list.get_servers_list()[self.screen.active_server]['status'] == 'PROTECTED'

glances/servers_list.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,41 @@ def update_servers_stats(self):
116116
self.threads_list[key] = thread
117117
thread.start()
118118

119+
@staticmethod
120+
def _get_connect_host(server):
121+
"""Return the host to use for connecting to the server.
122+
123+
For dynamic (Zeroconf) servers, use the discovered IP address
124+
instead of the untrusted advertised name.
125+
"""
126+
if server.get('type') == 'DYNAMIC':
127+
return server['ip']
128+
return server['name']
129+
130+
def _get_preconfigured_password(self, server):
131+
"""Return the preconfigured password for the server.
132+
133+
Dynamic (Zeroconf) entries are untrusted and should not inherit
134+
saved or default credentials to prevent credential exfiltration
135+
via fake Zeroconf services.
136+
"""
137+
if server.get('type') == 'DYNAMIC':
138+
return None
139+
return self.password.get_password(server['name'])
140+
119141
def get_uri(self, server):
120142
"""Return the URI for the given server dict."""
143+
host = self._get_connect_host(server)
121144
# Select the connection mode (with or without password)
122145
if server['password'] != "":
123146
if server['status'] == 'PROTECTED':
124-
# Try with the preconfigure password (only if status is PROTECTED)
125-
clear_password = self.password.get_password(server['name'])
147+
# Try with the preconfigured password (only if status is PROTECTED)
148+
clear_password = self._get_preconfigured_password(server)
126149
if clear_password is not None:
127150
server['password'] = self.password.get_hash(clear_password)
128-
uri = 'http://{}:{}@{}:{}'.format(server['username'], server['password'], server['name'], server['port'])
151+
uri = 'http://{}:{}@{}:{}'.format(server['username'], server['password'], host, server['port'])
129152
else:
130-
uri = 'http://{}:{}'.format(server['name'], server['port'])
153+
uri = 'http://{}:{}'.format(host, server['port'])
131154
return uri
132155

133156
def set_in_selected(self, selected, key, value):

tests/test_browser_restful.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
#!/usr/bin/env python
2+
#
3+
# Glances - An eye on your system
4+
#
5+
# SPDX-FileCopyrightText: 2024 Nicolas Hennion <nicolas@nicolargo.com>
6+
#
7+
# SPDX-License-Identifier: LGPL-3.0-only
8+
#
9+
10+
"""Glances unit tests for the WebUI/RESTful API Central Browser mode.
11+
12+
Tests cover:
13+
- /api/<version>/serverslist endpoint returns a valid servers list
14+
- Credential fields (password, uri) are stripped from API responses
15+
- Dynamic (Zeroconf) server entries do not leak saved credentials via the API
16+
- Server list structure validation
17+
"""
18+
19+
import os
20+
import re
21+
import shlex
22+
import subprocess
23+
import time
24+
from pathlib import Path
25+
26+
import pytest
27+
import requests
28+
29+
from glances.outputs.glances_restful_api import GlancesRestfulApi
30+
31+
# ---------------------------------------------------------------------------
32+
# Constants
33+
# ---------------------------------------------------------------------------
34+
35+
SERVER_PORT = 61237 # Distinct port to avoid conflicts with other tests
36+
API_VERSION = GlancesRestfulApi.API_VERSION
37+
URL = f"http://localhost:{SERVER_PORT}/api/{API_VERSION}"
38+
39+
# Servers injected into the generated config
40+
STATIC_SERVERS = [
41+
{'name': 'localhost', 'alias': 'Local Test Server', 'port': '61209', 'protocol': 'rpc'},
42+
{'name': 'localhost', 'alias': 'Local REST Server', 'port': '61208', 'protocol': 'rest'},
43+
]
44+
45+
PASSWORDS = {
46+
'localhost': 'testpassword',
47+
'default': 'defaultpassword',
48+
}
49+
50+
DEFAULT_CONF = Path(__file__).resolve().parent.parent / 'conf' / 'glances.conf'
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# Helpers – generate a browser-enabled config
55+
# ---------------------------------------------------------------------------
56+
57+
58+
def _generate_browser_conf(tmp_path):
59+
"""Read the default glances.conf, activate [serverlist] and [passwords],
60+
write to tmp_path and return the file path."""
61+
source = DEFAULT_CONF.read_text(encoding='utf-8')
62+
63+
# --- Build [serverlist] replacement ---
64+
serverlist_lines = [
65+
'[serverlist]',
66+
'columns=system:hr_name,load:min5,cpu:total,mem:percent',
67+
]
68+
for idx, srv in enumerate(STATIC_SERVERS, start=1):
69+
serverlist_lines.append(f'server_{idx}_name={srv["name"]}')
70+
serverlist_lines.append(f'server_{idx}_alias={srv["alias"]}')
71+
serverlist_lines.append(f'server_{idx}_port={srv["port"]}')
72+
serverlist_lines.append(f'server_{idx}_protocol={srv["protocol"]}')
73+
serverlist_block = '\n'.join(serverlist_lines) + '\n'
74+
75+
# --- Build [passwords] replacement ---
76+
password_lines = ['[passwords]']
77+
for host, pwd in PASSWORDS.items():
78+
password_lines.append(f'{host}={pwd}')
79+
password_block = '\n'.join(password_lines) + '\n'
80+
81+
# Replace existing sections in-place
82+
source = re.sub(
83+
r'\[serverlist\].*?(?=\n\[|\Z)',
84+
serverlist_block,
85+
source,
86+
count=1,
87+
flags=re.DOTALL,
88+
)
89+
source = re.sub(
90+
r'\[passwords\].*?(?=\n\[|\Z)',
91+
password_block,
92+
source,
93+
count=1,
94+
flags=re.DOTALL,
95+
)
96+
97+
conf_file = tmp_path / 'glances.conf'
98+
conf_file.write_text(source, encoding='utf-8')
99+
return str(conf_file)
100+
101+
102+
# ---------------------------------------------------------------------------
103+
# Fixtures
104+
# ---------------------------------------------------------------------------
105+
106+
107+
@pytest.fixture(scope='module')
108+
def browser_conf_path(tmp_path_factory):
109+
return _generate_browser_conf(tmp_path_factory.mktemp('browser_api_conf'))
110+
111+
112+
@pytest.fixture(scope='module')
113+
def glances_browser_server(browser_conf_path):
114+
"""Start a Glances web server in browser mode with the generated config."""
115+
if os.path.isfile('.venv/bin/python'):
116+
cmdline = '.venv/bin/python'
117+
else:
118+
cmdline = 'python'
119+
cmdline += (
120+
f' -m glances -B 0.0.0.0 -w --browser'
121+
f' -p {SERVER_PORT} --disable-webui --disable-autodiscover'
122+
f' -C {browser_conf_path}'
123+
)
124+
args = shlex.split(cmdline)
125+
pid = subprocess.Popen(args)
126+
# Wait for the server to start
127+
time.sleep(5)
128+
yield pid
129+
pid.terminate()
130+
pid.wait(timeout=5)
131+
132+
133+
# ---------------------------------------------------------------------------
134+
# Helper
135+
# ---------------------------------------------------------------------------
136+
137+
138+
def http_get(url):
139+
return requests.get(url, headers={'Accept-encoding': 'identity'}, timeout=10)
140+
141+
142+
# ---------------------------------------------------------------------------
143+
# Tests – /api/<version>/serverslist endpoint
144+
# ---------------------------------------------------------------------------
145+
146+
147+
class TestServersListEndpoint:
148+
"""Validate the /serverslist API endpoint."""
149+
150+
def test_serverslist_returns_200(self, glances_browser_server):
151+
req = http_get(f'{URL}/serverslist')
152+
assert req.ok, f'Expected 200, got {req.status_code}'
153+
154+
def test_serverslist_returns_list(self, glances_browser_server):
155+
req = http_get(f'{URL}/serverslist')
156+
data = req.json()
157+
assert isinstance(data, list)
158+
159+
def test_serverslist_has_servers(self, glances_browser_server):
160+
"""At least the configured static servers should be returned."""
161+
req = http_get(f'{URL}/serverslist')
162+
data = req.json()
163+
assert len(data) >= len(STATIC_SERVERS), f'Expected at least {len(STATIC_SERVERS)} servers, got {len(data)}'
164+
165+
def test_serverslist_server_has_required_fields(self, glances_browser_server):
166+
"""Each server entry should have essential fields."""
167+
req = http_get(f'{URL}/serverslist')
168+
required_keys = {'key', 'name', 'ip', 'port', 'protocol', 'status', 'type'}
169+
for server in req.json():
170+
missing = required_keys - set(server.keys())
171+
assert not missing, f'Server {server.get("name")} missing keys: {missing}'
172+
173+
def test_serverslist_server_types(self, glances_browser_server):
174+
req = http_get(f'{URL}/serverslist')
175+
for server in req.json():
176+
assert server['type'] in ('STATIC', 'DYNAMIC')
177+
178+
def test_serverslist_server_protocols(self, glances_browser_server):
179+
req = http_get(f'{URL}/serverslist')
180+
for server in req.json():
181+
assert server['protocol'] in ('rpc', 'rest')
182+
183+
184+
# ---------------------------------------------------------------------------
185+
# Tests – Credential sanitization in API responses (CVE fix validation)
186+
# ---------------------------------------------------------------------------
187+
188+
189+
class TestServersListCredentialSanitization:
190+
"""Verify that password and uri fields are stripped from API responses.
191+
192+
This validates the fix for CVE-2026-32633.
193+
"""
194+
195+
def test_no_password_field_in_response(self, glances_browser_server):
196+
"""The 'password' field must be stripped from all server entries."""
197+
req = http_get(f'{URL}/serverslist')
198+
for server in req.json():
199+
assert 'password' not in server, f'Server {server.get("name")} still exposes "password" field'
200+
201+
def test_no_uri_field_in_response(self, glances_browser_server):
202+
"""The 'uri' field must be stripped from all server entries."""
203+
req = http_get(f'{URL}/serverslist')
204+
for server in req.json():
205+
assert 'uri' not in server, f'Server {server.get("name")} still exposes "uri" field'
206+
207+
def test_no_credential_in_any_field(self, glances_browser_server):
208+
"""No field value should contain embedded credentials (user:pass@ pattern)."""
209+
req = http_get(f'{URL}/serverslist')
210+
cred_pattern = re.compile(r'://[^/]*:[^/]*@')
211+
for server in req.json():
212+
for key, value in server.items():
213+
if isinstance(value, str):
214+
assert not cred_pattern.search(value), (
215+
f'Server {server.get("name")}, field "{key}" contains embedded credentials: {value}'
216+
)
217+
218+
219+
# ---------------------------------------------------------------------------
220+
# Tests – _sanitize_server static method
221+
# ---------------------------------------------------------------------------
222+
223+
224+
class TestSanitizeServer:
225+
"""Unit tests for GlancesRestfulApi._sanitize_server (no server needed)."""
226+
227+
def test_strips_password(self):
228+
server = {'name': 'host', 'password': 'secret', 'status': 'ONLINE'}
229+
safe = GlancesRestfulApi._sanitize_server(server)
230+
assert 'password' not in safe
231+
232+
def test_strips_uri(self):
233+
server = {'name': 'host', 'uri': 'http://u:p@host:61209', 'status': 'ONLINE'}
234+
safe = GlancesRestfulApi._sanitize_server(server)
235+
assert 'uri' not in safe
236+
237+
def test_preserves_other_fields(self):
238+
server = {
239+
'name': 'host',
240+
'ip': '10.0.0.1',
241+
'port': 61209,
242+
'protocol': 'rpc',
243+
'status': 'ONLINE',
244+
'type': 'STATIC',
245+
'password': 'secret',
246+
'uri': 'http://u:p@host:61209',
247+
}
248+
safe = GlancesRestfulApi._sanitize_server(server)
249+
assert safe['name'] == 'host'
250+
assert safe['ip'] == '10.0.0.1'
251+
assert safe['port'] == 61209
252+
assert safe['protocol'] == 'rpc'
253+
assert safe['status'] == 'ONLINE'
254+
assert safe['type'] == 'STATIC'
255+
256+
def test_does_not_mutate_original(self):
257+
server = {'name': 'host', 'password': 'secret', 'uri': 'http://x'}
258+
GlancesRestfulApi._sanitize_server(server)
259+
assert 'password' in server
260+
assert 'uri' in server
261+
262+
def test_handles_missing_fields(self):
263+
"""Server dict without password/uri should not raise."""
264+
server = {'name': 'host', 'status': 'ONLINE'}
265+
safe = GlancesRestfulApi._sanitize_server(server)
266+
assert safe == server
267+
268+
269+
# ---------------------------------------------------------------------------
270+
# Tests – Multiple sequential requests (stability)
271+
# ---------------------------------------------------------------------------
272+
273+
274+
class TestServersListStability:
275+
"""Ensure repeated calls return consistent, sanitized results."""
276+
277+
def test_repeated_calls_consistent(self, glances_browser_server):
278+
"""Multiple sequential requests should return the same server count."""
279+
counts = []
280+
for _ in range(3):
281+
req = http_get(f'{URL}/serverslist')
282+
assert req.ok
283+
counts.append(len(req.json()))
284+
assert len(set(counts)) == 1, f'Inconsistent server counts across calls: {counts}'
285+
286+
def test_repeated_calls_never_leak_credentials(self, glances_browser_server):
287+
"""Credentials must remain stripped across multiple polling cycles."""
288+
for _ in range(3):
289+
req = http_get(f'{URL}/serverslist')
290+
for server in req.json():
291+
assert 'password' not in server
292+
assert 'uri' not in server

0 commit comments

Comments
 (0)